From 7e3b9fd0398f9e59a32e7227012a4545b8ca1610 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 8 Sep 2026 22:42:22 +0200 Subject: [PATCH] fix(collab): keep every panel on screen, and stop leaking spool and links The desktop layout rendered notes and links as a pair and dropped whichever of the two was on its own, so turning the new Links feature off on an otherwise default install made the Notes panel disappear. Both rows are now split by kind and rendered only when they hold something, and one function decides what a panel id renders, because the branch that lost a panel did so by not listing it. The message and its attachment rows are written in one transaction. The caller has already committed the image bytes by then, so a message that landed without them left those bytes with nothing pointing at them and nothing to sweep them. Deleting one now drops the blobs after the rows are gone rather than before. A body the Zod pipe rejects used to leave up to four spooled images on disk: pipes run after interceptors, so multer had already written them and the handler's own cleanup was never reached. An interceptor removes them on any failure before the handler, which also covers every other schema mismatch, and the length check that could never fire is gone. Adding, pinning and deleting a link now says something when it fails instead of raising an unhandled rejection, the list has real types, and the rejected-image toast is translated in all 23 locales rather than shipping English everywhere. The picking, previewing and clearing of chat images moved into one hook both shells use: it was copied into two, and both rebuilt every preview on each change and leaked an object URL per image per pick. Tests: the four link routes end to end with the validation pipe the harness was missing, the image filter against the disguised-extension case, and the layout combinations that drop a panel. --- client/src/components/Collab/CollabLinks.tsx | 74 ++++++++++++++--- .../components/Collab/CollabPanel.test.tsx | 36 +++++++++ client/src/components/Collab/CollabPanel.tsx | 80 +++++++------------ client/src/components/Collab/useChatImages.ts | 77 ++++++++++++++++++ client/src/components/Collab/useCollabChat.ts | 32 +++----- .../mobile/screens/trip/tabs/MCollabChat.tsx | 31 +++---- server/src/nest/collab/collab.controller.ts | 14 ++-- server/src/nest/collab/collab.service.ts | 31 ++++--- .../nest/common/spool-cleanup.interceptor.ts | 38 +++++++++ server/tests/e2e/collab.e2e.test.ts | 80 ++++++++++++++++++- .../tests/unit/nest/collab.controller.test.ts | 31 ++++++- shared/src/i18n/ar/collab.ts | 1 + shared/src/i18n/br/collab.ts | 1 + shared/src/i18n/ca/collab.ts | 1 + shared/src/i18n/cs/collab.ts | 1 + shared/src/i18n/de/collab.ts | 1 + shared/src/i18n/en/collab.ts | 1 + shared/src/i18n/es/collab.ts | 1 + shared/src/i18n/fr/collab.ts | 1 + shared/src/i18n/gr/collab.ts | 1 + shared/src/i18n/hu/collab.ts | 1 + shared/src/i18n/id/collab.ts | 1 + shared/src/i18n/it/collab.ts | 1 + shared/src/i18n/ja/collab.ts | 1 + shared/src/i18n/ko/collab.ts | 1 + shared/src/i18n/nl/collab.ts | 1 + shared/src/i18n/pl/collab.ts | 1 + shared/src/i18n/ru/collab.ts | 1 + shared/src/i18n/sv/collab.ts | 1 + shared/src/i18n/tr/collab.ts | 1 + shared/src/i18n/uk/collab.ts | 1 + shared/src/i18n/vi/collab.ts | 1 + shared/src/i18n/zh-TW/collab.ts | 1 + shared/src/i18n/zh/collab.ts | 1 + 34 files changed, 429 insertions(+), 118 deletions(-) create mode 100644 client/src/components/Collab/useChatImages.ts create mode 100644 server/src/nest/common/spool-cleanup.interceptor.ts 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) {