diff --git a/client/src/components/Collab/CollabLinks.tsx b/client/src/components/Collab/CollabLinks.tsx index 614cd3124..2727356a2 100644 --- a/client/src/components/Collab/CollabLinks.tsx +++ b/client/src/components/Collab/CollabLinks.tsx @@ -4,6 +4,14 @@ import { collabApi } from '../../api/client' import { useTranslation } from '../../i18n' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' +import { useToast } from '../shared/Toast' + +interface CollabLink { + id: number + title: string + url: string + pinned: boolean | number +} function LinkIcon({ url, title }: { url: string; title: string }) { const [failed, setFailed] = useState(false) @@ -17,31 +25,73 @@ export default function CollabLinks({ tripId }: { tripId: number }) { const { t } = useTranslation() const trip = useTripStore(s => s.trip) const canEdit = useCanDo()('collab_edit', trip) - const [links, setLinks] = useState([]) + const toast = useToast() + const [links, setLinks] = useState([]) const [title, setTitle] = useState('') const [url, setUrl] = useState('') const [busy, setBusy] = useState(false) const [expanded, setExpanded] = useState(false) - const load = () => collabApi.getLinks(tripId).then(d => setLinks(d.links || [])).catch(() => {}) - useEffect(() => { load() }, [tripId]) + + useEffect(() => { + let cancelled = false + collabApi.getLinks(tripId) + .then(d => { if (!cancelled) setLinks(d.links || []) }) + .catch(err => { + if (cancelled) return + console.error('Failed to load collab links:', err) + toast.error(t('common.error')) + }) + return () => { cancelled = true } + }, [tripId, toast, t]) + + // Every write says something when it fails. Silence left the pin looking + // unchanged and the delete looking ignored, with an unhandled rejection in + // the console as the only trace. const add = async (event?: FormEvent) => { event?.preventDefault() if (!title.trim() || !url.trim() || busy) return setBusy(true) - try { const d = await collabApi.createLink(tripId, { title: title.trim(), url: url.trim() }); setLinks(v => [d.link, ...v]); setTitle(''); setUrl(''); setExpanded(false) } finally { setBusy(false) } + try { + const d = await collabApi.createLink(tripId, { title: title.trim(), url: url.trim() }) + setLinks(v => [d.link, ...v]) + setTitle(''); setUrl(''); setExpanded(false) + } catch (err) { + console.error('Failed to add collab link:', err) + toast.error(t('common.error')) + } finally { + setBusy(false) + } + } + + const toggle = async (link: CollabLink) => { + try { + const d = await collabApi.updateLink(tripId, link.id, { pinned: !link.pinned }) + setLinks(v => v.map(x => x.id === link.id ? d.link : x).sort((a, b) => Number(b.pinned) - Number(a.pinned))) + } catch (err) { + console.error('Failed to pin collab link:', err) + toast.error(t('common.error')) + } + } + + const remove = async (id: number) => { + try { + await collabApi.deleteLink(tripId, id) + setLinks(v => v.filter(x => x.id !== id)) + } catch (err) { + console.error('Failed to delete collab link:', err) + toast.error(t('common.error')) + } } - const toggle = async (link: any) => { const d = await collabApi.updateLink(tripId, link.id, { pinned: !link.pinned }); setLinks(v => v.map(x => x.id === link.id ? d.link : x).sort((a, b) => Number(b.pinned) - Number(a.pinned))) } - const remove = async (id: number) => { await collabApi.deleteLink(tripId, id); setLinks(v => v.filter(x => x.id !== id)) } return
-
+
{canEdit &&
- + {expanded && }
} -
{links.length === 0 ?
{t('collab.links.empty') || 'No shared links yet'}
: links.map(link =>
{link.title}
{link.url}
)}
+
{links.length === 0 ?
{t('collab.links.empty')}
: links.map(link =>
{link.title}
{link.url}
)}
} diff --git a/client/src/components/Collab/CollabPanel.test.tsx b/client/src/components/Collab/CollabPanel.test.tsx index d8e3c8c99..721256f88 100644 --- a/client/src/components/Collab/CollabPanel.test.tsx +++ b/client/src/components/Collab/CollabPanel.test.tsx @@ -264,6 +264,42 @@ describe('CollabPanel feature combinations', () => { expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument() }) + // The three-or-more branch used to render notes and links as a pair and drop + // whichever of the two was on its own, so these walk every shape of it. + it('FE-W5CPN-014: three right panels including notes but not links keeps notes', () => { + setViewport(1280) + render() + expect(screen.getByTestId('collab-notes')).toBeInTheDocument() + expect(screen.getByTestId('collab-polls')).toBeInTheDocument() + expect(screen.getByTestId('whats-next')).toBeInTheDocument() + expect(screen.queryByTestId('collab-links')).not.toBeInTheDocument() + }) + + it('FE-W5CPN-015: three right panels including links but not notes keeps links', () => { + setViewport(1280) + render() + expect(screen.getByTestId('collab-links')).toBeInTheDocument() + expect(screen.getByTestId('collab-polls')).toBeInTheDocument() + expect(screen.getByTestId('whats-next')).toBeInTheDocument() + expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument() + }) + + it('FE-W5CPN-016: all four right panels render, notes and links above the other two', () => { + setViewport(1280) + render() + for (const id of ['collab-chat', 'collab-notes', 'collab-links', 'collab-polls', 'whats-next']) { + expect(screen.getByTestId(id)).toBeInTheDocument() + } + }) + + it('FE-W5CPN-017: notes and links alone with a third panel and no chat all render', () => { + setViewport(1280) + render() + expect(screen.getByTestId('collab-notes')).toBeInTheDocument() + expect(screen.getByTestId('collab-links')).toBeInTheDocument() + expect(screen.getByTestId('collab-polls')).toBeInTheDocument() + }) + it('FE-W5CPN-013: mobile falls back to the first tab when the active one gets disabled', () => { setViewport(375) const { rerender } = render() diff --git a/client/src/components/Collab/CollabPanel.tsx b/client/src/components/Collab/CollabPanel.tsx index 04eac72b3..02c94ddd7 100644 --- a/client/src/components/Collab/CollabPanel.tsx +++ b/client/src/components/Collab/CollabPanel.tsx @@ -80,6 +80,23 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures } features.whatsnext && 'whatsnext', ].filter(Boolean) as string[] + // One place decides what a panel id renders. It was spelled out at five call + // sites, and the layout branch below dropped a panel precisely because one of + // those copies did not list it. + const renderPanel = (p: string) => ( + <> + {p === 'notes' && } + {p === 'links' && } + {p === 'polls' && } + {p === 'whatsnext' && } + + ) + const panelRow = (ids: string[]) => ( +
+ {ids.map(p =>
{renderPanel(p)}
)} +
+ ) + if (tabs.length === 0) return null if (isDesktop) { @@ -104,36 +121,19 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
- {rightPanels.length === 1 && ( -
- {rightPanels[0] === 'notes' && } - {rightPanels[0] === 'links' && } - {rightPanels[0] === 'polls' && } - {rightPanels[0] === 'whatsnext' && } -
- )} - {rightPanels.length === 2 &&
- {rightPanels.map(p => ( -
- {p === 'notes' && } - {p === 'links' && } - {p === 'polls' && } - {p === 'whatsnext' && } -
- ))} -
} - {rightPanels.length >= 3 && <> - {rightPanels.includes('notes') && rightPanels.includes('links') &&
-
-
-
} -
- {rightPanels.filter(p => !(p === 'notes' || p === 'links')).map(p =>
- {p === 'polls' && } - {p === 'whatsnext' && } -
)} -
- } + {rightPanels.length <= 2 && panelRow(rightPanels)} + {rightPanels.length >= 3 && (() => { + // Two rows, split by kind rather than by a pair being present: + // the old condition needed notes AND links together, so turning + // the new Links feature off dropped Notes out of the layout + // entirely. Each row renders only when it has something in it. + const written = rightPanels.filter(p => p === 'notes' || p === 'links') + const rest = rightPanels.filter(p => p !== 'notes' && p !== 'links') + return <> + {written.length > 0 && panelRow(written)} + {rest.length > 0 && panelRow(rest)} + + })()}
) @@ -141,28 +141,10 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures } // Chat off — remaining panels share full width const panels = rightPanels - if (panels.length === 1) { - return ( -
-
- {panels[0] === 'notes' && } - {panels[0] === 'links' && } - {panels[0] === 'polls' && } - {panels[0] === 'whatsnext' && } -
-
- ) - } - return (
{panels.map(p => ( -
- {p === 'notes' && } - {p === 'links' && } - {p === 'polls' && } - {p === 'whatsnext' && } -
+
{renderPanel(p)}
))}
) diff --git a/client/src/components/Collab/useChatImages.ts b/client/src/components/Collab/useChatImages.ts new file mode 100644 index 000000000..2cca6a19c --- /dev/null +++ b/client/src/components/Collab/useChatImages.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +/** + * The images pinned to a chat message before it is sent. + * + * One hook for both shells. The desktop hook and the phone screen each carried + * their own copy of the same picking, previewing and clearing, which is two + * places for the same leak and a duplication bill on every line. + * + * Object URLs are held in a ref and revoked when the file they belong to goes, + * when the list is cleared and on unmount. Rebuilding every preview on each + * change, which is what both copies did, leaked one URL per image per keystroke + * of picking, and did it from inside a setState updater, so React ran it twice + * in development and leaked twice as fast. + */ +export const CHAT_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] +export const MAX_CHAT_IMAGES = 4 +export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024 + +export interface ChatImages { + files: File[] + previews: string[] + /** Returns false when something was dropped, so the caller can say why. */ + add: (incoming: FileList | File[]) => boolean + remove: (index: number) => void + clear: () => void +} + +export function useChatImages(): ChatImages { + const [files, setFiles] = useState([]) + const [previews, setPreviews] = useState([]) + const urls = useRef([]) + + const revokeAll = useCallback(() => { + urls.current.forEach(URL.revokeObjectURL) + urls.current = [] + }, []) + + useEffect(() => revokeAll, [revokeAll]) + + const add = useCallback((incoming: FileList | File[]) => { + const all = Array.from(incoming) + const valid = all.filter(f => CHAT_IMAGE_TYPES.includes(f.type) && f.size <= MAX_CHAT_IMAGE_BYTES) + if (valid.length) { + setFiles(prev => { + const room = MAX_CHAT_IMAGES - prev.length + const added = valid.slice(0, Math.max(0, room)) + // Only the new files get a URL; the ones already in the list keep theirs. + const fresh = added.map(URL.createObjectURL) + urls.current = [...urls.current, ...fresh] + setPreviews(p => [...p, ...fresh]) + return [...prev, ...added] + }) + } + return valid.length === all.length + }, []) + + const remove = useCallback((index: number) => { + setFiles(prev => prev.filter((_, i) => i !== index)) + setPreviews(prev => { + const gone = prev[index] + if (gone) { + URL.revokeObjectURL(gone) + urls.current = urls.current.filter(u => u !== gone) + } + return prev.filter((_, i) => i !== index) + }) + }, []) + + const clear = useCallback(() => { + revokeAll() + setFiles([]) + setPreviews([]) + }, [revokeAll]) + + return { files, previews, add, remove, clear } +} diff --git a/client/src/components/Collab/useCollabChat.ts b/client/src/components/Collab/useCollabChat.ts index be149fc85..fb26bd3e5 100644 --- a/client/src/components/Collab/useCollabChat.ts +++ b/client/src/components/Collab/useCollabChat.ts @@ -5,6 +5,7 @@ import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' +import { useChatImages } from './useChatImages' import { useToast } from '../shared/Toast' export function useCollabChat(tripId: any, currentUser: any) { @@ -23,8 +24,7 @@ export function useCollabChat(tripId: any, currentUser: any) { const [replyTo, setReplyTo] = useState(null) const [hoveredId, setHoveredId] = useState(null) const [sending, setSending] = useState(false) - const [imageFiles, setImageFiles] = useState([]) - const [imagePreviews, setImagePreviews] = useState([]) + const images = useChatImages() const [uploadProgress, setUploadProgress] = useState(0) const [showEmoji, setShowEmoji] = useState(false) const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y } @@ -122,28 +122,20 @@ export function useCollabChat(tripId: any, currentUser: any) { /* ── send ── */ const addImageFiles = useCallback((incoming: File[] | FileList) => { - const valid = Array.from(incoming).filter(f => ['image/jpeg','image/png','image/gif','image/webp'].includes(f.type) && f.size <= 10 * 1024 * 1024) - if (valid.length !== Array.from(incoming).length) toast.error('Only JPEG, PNG, GIF, and WebP images up to 10 MB are allowed') - setImageFiles(prev => { - const next = [...prev, ...valid].slice(0, 4) - setImagePreviews(next.map(f => URL.createObjectURL(f))) - return next - }) - }, [toast]) - const removeImage = useCallback((index: number) => { - setImageFiles(prev => { const next = prev.filter((_, i) => i !== index); setImagePreviews(urls => { if (urls[index]) URL.revokeObjectURL(urls[index]); return next.map(f => URL.createObjectURL(f)) }); return next }) - }, []) + if (!images.add(incoming)) toast.error(t('collab.chat.imageRejected')) + }, [images, toast, t]) + const removeImage = images.remove const handlePaste = useCallback((e: ClipboardEvent) => { if (e.clipboardData.files.length) addImageFiles(e.clipboardData.files) }, [addImageFiles]) const handleDrop = useCallback((e: DragEvent) => { e.preventDefault(); addImageFiles(e.dataTransfer.files) }, [addImageFiles]) const handleSend = useCallback(async () => { const body = text.trim() - if ((!body && !imageFiles.length) || sending) return + if ((!body && !images.files.length) || sending) return setSending(true) try { let data - if (imageFiles.length) { - const form = new FormData(); if (body) form.append('text', body); if (replyTo) form.append('reply_to', String(replyTo.id)); imageFiles.forEach(file => form.append('images', file)) + if (images.files.length) { + const form = new FormData(); if (body) form.append('text', body); if (replyTo) form.append('reply_to', String(replyTo.id)); images.files.forEach(file => form.append('images', file)) data = await collabApi.sendMessage(tripId, form, { onUploadProgress: e => setUploadProgress(e.total ? Math.round((e.loaded / e.total) * 100) : 0) }) } else { const payload: { text: string; reply_to?: number } = { text: body }; if (replyTo) payload.reply_to = replyTo.id @@ -152,12 +144,12 @@ export function useCollabChat(tripId: any, currentUser: any) { if (data?.message) { setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message]) } - setText(''); setReplyTo(null); setShowEmoji(false); imagePreviews.forEach(URL.revokeObjectURL); setImageFiles([]); setImagePreviews([]); setUploadProgress(0) + setText(''); setReplyTo(null); setShowEmoji(false); images.clear() if (textareaRef.current) textareaRef.current.style.height = 'auto' isAtBottom.current = true setTimeout(() => scrollToBottom('smooth'), 50) - } catch { toast.error(t('common.error')) } finally { setSending(false) } - }, [text, sending, replyTo, tripId, scrollToBottom, toast, t, imageFiles, imagePreviews]) + } catch { toast.error(t('common.error')) } finally { setSending(false); setUploadProgress(0) } + }, [text, sending, replyTo, tripId, scrollToBottom, toast, t, images]) const handleKeyDown = useCallback((e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } @@ -199,5 +191,5 @@ export function useCollabChat(tripId: any, currentUser: any) { return emojiRegex.test(text.trim()) } - return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles, imagePreviews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } + return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles: images.files, imagePreviews: images.previews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } } diff --git a/client/src/mobile/screens/trip/tabs/MCollabChat.tsx b/client/src/mobile/screens/trip/tabs/MCollabChat.tsx index 6837e4834..966d0add8 100644 --- a/client/src/mobile/screens/trip/tabs/MCollabChat.tsx +++ b/client/src/mobile/screens/trip/tabs/MCollabChat.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { ArrowUp, ChevronUp, ImagePlus, Loader2, Reply, Trash2 } from 'lucide-react' import MDancingTrek from '../../../components/MDancingTrek' +import { useChatImages } from '../../../../components/Collab/useChatImages' import { collabApi } from '../../../../api/client' import { addListener, removeListener } from '../../../../api/websocket' import { useAuthStore } from '../../../../store/authStore' @@ -63,8 +64,7 @@ export default function MCollabChat({ planner }: MCollabChatProps) { const [text, setText] = useState('') const [replyTo, setReplyTo] = useState(null) const [sending, setSending] = useState(false) - const [imageFiles, setImageFiles] = useState([]) - const [imagePreviews, setImagePreviews] = useState([]) + const images = useChatImages() const [popover, setPopover] = useState<{ msg: ChatMessage; x: number; y: number } | null>(null) const scrollRef = useRef(null) @@ -160,25 +160,20 @@ export default function MCollabChat({ planner }: MCollabChatProps) { } const addImageFiles = (incoming: FileList | File[]) => { - const valid = Array.from(incoming).filter(f => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(f.type) && f.size <= 10 * 1024 * 1024) - setImageFiles(prev => { - const next = [...prev, ...valid].slice(0, 4) - setImagePreviews(next.map(f => URL.createObjectURL(f))) - return next - }) + if (!images.add(incoming)) toast.error(t('collab.chat.imageRejected')) } const handleSend = useCallback(async () => { const body = text.trim() - if ((!body && !imageFiles.length) || sending || !canEdit) return + if ((!body && !images.files.length) || sending || !canEdit) return setSending(true) try { let data: SendMessageResponse - if (imageFiles.length) { + if (images.files.length) { const form = new FormData() if (body) form.append('text', body) if (replyTo) form.append('reply_to', String(replyTo.id)) - imageFiles.forEach(file => form.append('images', file)) + images.files.forEach(file => form.append('images', file)) data = (await collabApi.sendMessage(tripId, form)) as SendMessageResponse } else { const payload: { text: string; reply_to?: number } = { text: body } @@ -190,9 +185,7 @@ export default function MCollabChat({ planner }: MCollabChatProps) { } setText('') setReplyTo(null) - imagePreviews.forEach(URL.revokeObjectURL) - setImageFiles([]) - setImagePreviews([]) + images.clear() if (textareaRef.current) textareaRef.current.style.height = 'auto' isAtBottomRef.current = true setTimeout(() => scrollToBottom('smooth'), 50) @@ -201,7 +194,7 @@ export default function MCollabChat({ planner }: MCollabChatProps) { } finally { setSending(false) } - }, [text, sending, canEdit, replyTo, tripId, scrollToBottom, toast, t, imageFiles, imagePreviews]) + }, [text, sending, canEdit, replyTo, tripId, scrollToBottom, toast, t, images]) const handleDelete = useCallback(async (msgId: number) => { setPopover(null) @@ -318,12 +311,12 @@ export default function MCollabChat({ planner }: MCollabChatProps) { {canEdit ? ( <> - {imagePreviews.length > 0 && ( + {images.previews.length > 0 && (
- {imagePreviews.map((url, i) => ( + {images.previews.map((url, i) => (
- +
))}
@@ -346,7 +339,7 @@ export default function MCollabChat({ planner }: MCollabChatProps) {