diff --git a/client/src/components/Collab/CollabChat.helpers.ts b/client/src/components/Collab/CollabChat.helpers.ts
index 84de232c1..bc4240bdd 100644
--- a/client/src/components/Collab/CollabChat.helpers.ts
+++ b/client/src/components/Collab/CollabChat.helpers.ts
@@ -7,36 +7,3 @@ export function emojiToCodepoint(emoji) {
}
return codepoints.join('-')
}
-
-// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC
-export function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) }
-
-export function formatTime(isoString, is12h) {
- const d = parseUTC(isoString)
- const h = d.getHours()
- const mm = String(d.getMinutes()).padStart(2, '0')
- if (is12h) {
- const period = h >= 12 ? 'PM' : 'AM'
- const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
- return `${h12}:${mm} ${period}`
- }
- return `${String(h).padStart(2, '0')}:${mm}`
-}
-
-export function formatDateSeparator(isoString, t) {
- const d = parseUTC(isoString)
- const now = new Date()
- const yesterday = new Date(); yesterday.setDate(now.getDate() - 1)
-
- if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today'
- if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday'
-
- return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
-}
-
-export function shouldShowDateSeparator(msg, prevMsg) {
- if (!prevMsg) return true
- const d1 = parseUTC(msg.created_at).toDateString()
- const d2 = parseUTC(prevMsg.created_at).toDateString()
- return d1 !== d2
-}
diff --git a/client/src/components/Collab/CollabChatMessages.tsx b/client/src/components/Collab/CollabChatMessages.tsx
index 91001f39f..b8741e564 100644
--- a/client/src/components/Collab/CollabChatMessages.tsx
+++ b/client/src/components/Collab/CollabChatMessages.tsx
@@ -2,7 +2,7 @@ import React from 'react'
import { Trash2, Reply, ChevronUp } from 'lucide-react'
import { Tooltip } from '../shared/Tooltip'
import { URL_REGEX } from './CollabChat.constants'
-import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers'
+import { formatChatClockTime, formatChatDateSeparator, shouldShowChatDateSeparator } from './collabModel'
import { MessageText } from './CollabChatMessageText'
import { CollabChatAttachment } from './CollabChatAttachment'
import { LinkPreview } from './CollabChatLinkPreview'
@@ -43,7 +43,7 @@ export function ChatMessages(props: any) {
const nextMsg = messages[idx + 1]
const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id)
const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id)
- const showDate = shouldShowDateSeparator(msg, prevMsg)
+ const showDate = shouldShowChatDateSeparator(msg, prevMsg)
const showAvatar = !own && isLastInGroup
const bigEmoji = isEmojiOnly(msg.text)
const hasReply = msg.reply_text || msg.reply_to
@@ -54,14 +54,14 @@ export function ChatMessages(props: any) {
{showDate && (
- {formatDateSeparator(msg.created_at, t)}
+ {formatChatDateSeparator(msg.created_at, t)}
)}
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'}
- {formatTime(msg.created_at, is12h)}
+ {formatChatClockTime(msg.created_at, is12h)}
@@ -85,7 +85,7 @@ export function ChatMessages(props: any) {
background: 'var(--bg-card)', padding: '3px 12px', borderRadius: 99,
letterSpacing: 0.3, textTransform: 'uppercase',
}}>
- {formatDateSeparator(msg.created_at, t)}
+ {formatChatDateSeparator(msg.created_at, t)}
)}
@@ -243,7 +243,7 @@ export function ChatMessages(props: any) {
{/* Timestamp — only on last message of group */}
{isLastInGroup && (
- {formatTime(msg.created_at, is12h)}
+ {formatChatClockTime(msg.created_at, is12h)}
)}
diff --git a/client/src/mobile/screens/trip/tabs/collabModel.ts b/client/src/components/Collab/collabModel.ts
similarity index 97%
rename from client/src/mobile/screens/trip/tabs/collabModel.ts
rename to client/src/components/Collab/collabModel.ts
index d818e8b67..42f7efa8c 100644
--- a/client/src/mobile/screens/trip/tabs/collabModel.ts
+++ b/client/src/components/Collab/collabModel.ts
@@ -1,5 +1,6 @@
/**
- * Collab view-model — types + pure helpers shared by MCollabChat/Notes/Polls.
+ * Collab view-model — types + pure helpers shared by the desktop Collab panel
+ * and the phone's MCollabChat/Notes/Polls.
* Mirrors the real `/api/trips/:tripId/collab/*` response shapes (verified
* against `server/src/nest/collab/collab.controller.ts` +
* `server/src/services/collabService.ts`, byte-identical to the legacy Express
@@ -49,7 +50,7 @@ export interface ChatMessage {
/** The 8 quick-tap reactions (long-press / double-tap popover). Same set the
* desktop right-click menu offers — kept in sync for cross-platform muscle
- * memory, redefined locally since we don't import Desktop Collab files. */
+ * memory. */
export const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
/** Bubble corner radii, spec 03 §6.1 — constant per sender, not per group
@@ -60,7 +61,7 @@ export const OWN_BUBBLE_RADIUS = { borderStartStartRadius: 16, borderStartEndRad
export const OTHER_BUBBLE_RADIUS = { borderStartStartRadius: 4, borderStartEndRadius: 16, borderEndEndRadius: 16, borderEndStartRadius: 16 } as const
// SQLite stores UTC without a 'Z' suffix — append one so JS parses it as UTC
-// rather than local time (same fix the desktop helpers apply).
+// rather than local time.
export function parseUtcDate(iso: string): Date {
return new Date(iso && !iso.endsWith('Z') ? `${iso}Z` : iso)
}
@@ -84,7 +85,8 @@ export function formatChatClockTime(iso: string, is12h: boolean): string {
return `${String(h).padStart(2, '0')}:${mm}`
}
-export function formatChatDateSeparator(iso: string, t: Translate, locale: string): string {
+/** Today / Yesterday, else the date; without a locale the browser's own (the desktop chat). */
+export function formatChatDateSeparator(iso: string, t: Translate, locale?: string): string {
const d = parseUtcDate(iso)
const now = new Date()
const yesterday = new Date()
diff --git a/client/src/components/Collab/useCollabChat.ts b/client/src/components/Collab/useCollabChat.ts
index 7660efdbd..c2decb90e 100644
--- a/client/src/components/Collab/useCollabChat.ts
+++ b/client/src/components/Collab/useCollabChat.ts
@@ -1,11 +1,10 @@
import { useState, useEffect, useRef, useCallback, type ClipboardEvent, type DragEvent } from 'react'
-import { collabApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
-import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
-import { useChatImages, MAX_CHAT_IMAGES } from './useChatImages'
+import { useCollabChatData } from './useCollabChatData'
+import { isEmojiOnlyText } from './collabModel'
import { useToast } from '../shared/Toast'
export function useCollabChat(tripId: any, currentUser: any) {
@@ -16,183 +15,63 @@ export function useCollabChat(tripId: any, currentUser: any) {
const trip = useTripStore((s) => s.trip)
const canEdit = can('collab_edit', trip)
- const [messages, setMessages] = useState([])
- const [loading, setLoading] = useState(true)
- const [hasMore, setHasMore] = useState(false)
- const [loadingMore, setLoadingMore] = useState(false)
- const [text, setText] = useState('')
- const [replyTo, setReplyTo] = useState(null)
const [hoveredId, setHoveredId] = useState(null)
- const [sending, setSending] = useState(false)
- const images = useChatImages()
- const [uploadProgress, setUploadProgress] = useState(0)
const [showEmoji, setShowEmoji] = useState(false)
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
const [deletingIds, setDeletingIds] = useState(new Set())
const deleteTimersRef = useRef[]>([])
+ const hideEmoji = useCallback(() => setShowEmoji(false), [])
+ const chat = useCollabChatData({
+ tripId, t, toast, scrollOnRemoteDelete: true, trackUploadProgress: true, onSent: hideEmoji,
+ })
+ const {
+ messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore,
+ text, setText, replyTo, setReplyTo, sending, setSending, images, uploadProgress, scrollRef, textareaRef,
+ isAtBottom, messagesRef, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, addImageFiles, handleSend,
+ deleteMessage, reactToMessage,
+ } = chat
useEffect(() => {
return () => { deleteTimersRef.current.forEach(clearTimeout) }
}, [])
const containerRef = useRef(null)
- const messagesRef = useRef(messages)
- messagesRef.current = messages
- const scrollRef = useRef(null)
- const textareaRef = useRef(null)
const emojiBtnRef = useRef(null)
const imageInputRef = useRef(null)
- const isAtBottom = useRef(true)
- const scrollToBottom = useCallback((behavior = 'auto') => {
- const el = scrollRef.current
- if (!el) return
- requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior }))
- }, [])
-
- const checkAtBottom = useCallback(() => {
- const el = scrollRef.current
- if (!el) return
- isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48
- }, [])
-
- /* ── load messages ── */
- useEffect(() => {
- let cancelled = false
- setLoading(true)
- collabApi.getMessages(tripId).then(data => {
- if (cancelled) return
- const msgs = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
- setMessages(msgs)
- setHasMore(msgs.length >= 100)
- setLoading(false)
- setTimeout(() => scrollToBottom(), 30)
- }).catch(() => { if (!cancelled) setLoading(false) })
- return () => { cancelled = true }
- }, [tripId, scrollToBottom])
-
- /* ── load more ── */
- const handleLoadMore = useCallback(async () => {
- if (loadingMore || messages.length === 0) return
- setLoadingMore(true)
- const el = scrollRef.current
- const prevHeight = el ? el.scrollHeight : 0
- try {
- const data = await collabApi.getMessages(tripId, messages[0]?.id)
- const older = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
- if (older.length === 0) { setHasMore(false) }
- else {
- setMessages(prev => [...older, ...prev])
- setHasMore(older.length >= 100)
- requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight })
- }
- } catch {} finally { setLoadingMore(false) }
- }, [tripId, loadingMore, messages])
-
- /* ── websocket ── */
- useEffect(() => {
- const handler = (event) => {
- if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) {
- setMessages(prev => prev.some(m => m.id === event.message.id) ? prev : [...prev, event.message])
- if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 30)
- }
- if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) {
- setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, _deleted: true } : m))
- if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50)
- }
- if (event.type === 'collab:message:reacted' && String(event.tripId) === String(tripId)) {
- setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, reactions: event.reactions } : m))
- }
- }
- addListener(handler)
- return () => removeListener(handler)
- }, [tripId, scrollToBottom])
-
- /* ── auto-resize textarea ── */
- const handleTextChange = useCallback((e) => {
- setText(e.target.value)
- const ta = textareaRef.current
- if (ta) {
- ta.style.height = 'auto'
- const h = Math.min(ta.scrollHeight, 100)
- ta.style.height = h + 'px'
- ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden'
- }
- }, [])
-
- /* ── send ── */
- const addImageFiles = useCallback((incoming: File[] | FileList) => {
- images.add(incoming, ({ rejected, overflow }) => {
- if (rejected) toast.error(t('collab.chat.imageRejected'))
- if (overflow) toast.error(t('collab.chat.imageLimit', { count: MAX_CHAT_IMAGES }))
- })
- }, [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 && !images.files.length) || sending) return
- setSending(true)
- try {
- let data
- 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
- data = await collabApi.sendMessage(tripId, payload)
- }
- if (data?.message) {
- setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
- }
- 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); setUploadProgress(0) }
- }, [text, sending, replyTo, tripId, scrollToBottom, toast, t, images])
-
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
}, [handleSend])
const handleDelete = useCallback(async (msgId) => {
- const msg = messages.find(m => m.id === msgId)
requestAnimationFrame(() => {
setDeletingIds(prev => new Set(prev).add(msgId))
})
const timer = setTimeout(async () => {
- try {
- await collabApi.deleteMessage(tripId, msgId)
- setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
- } catch { toast.error(t('common.error')) }
+ await deleteMessage(msgId)
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
}, 400)
deleteTimersRef.current.push(timer)
- }, [tripId, toast, t])
+ }, [deleteMessage])
const handleReact = useCallback(async (msgId, emoji) => {
setReactMenu(null)
- try {
- const data = await collabApi.reactMessage(tripId, msgId, emoji)
- setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m))
- } catch { toast.error(t('common.error')) }
- }, [tripId, toast, t])
+ await reactToMessage(msgId, emoji)
+ }, [reactToMessage])
const handleEmojiSelect = useCallback((emoji) => {
setText(prev => prev + emoji)
textareaRef.current?.focus()
- }, [])
+ }, [setText, textareaRef])
const isOwn = (msg) => String(msg.user_id) === String(currentUser.id)
// Check if message is only emoji (1-3 emojis, no other text)
- const isEmojiOnly = (text) => {
- const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}[️]?(?:\p{Extended_Pictographic}[️]?)*){1,3}$/u
- return emojiRegex.test(text.trim())
- }
+ const isEmojiOnly = isEmojiOnlyText
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/components/Collab/useCollabChatData.test.ts b/client/src/components/Collab/useCollabChatData.test.ts
new file mode 100644
index 000000000..f40c22762
--- /dev/null
+++ b/client/src/components/Collab/useCollabChatData.test.ts
@@ -0,0 +1,287 @@
+// FE-COLLAB-CHATDATA-001 to FE-COLLAB-CHATDATA-016: the trip chat logic behind the desktop
+// Collab panel (quiet older-page errors, scroll on remote delete, upload progress) and the
+// phone's chat tab (toasted older-page errors, sending gated on the edit permission).
+import { act, renderHook, waitFor } from '@testing-library/react';
+import type { ChangeEvent } from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { collabApi } from '../../api/client';
+import { addListener, removeListener } from '../../api/websocket';
+import type { ChatMessage } from './collabModel';
+import { useCollabChatData, type CollabChatDataOptions } from './useCollabChatData';
+
+const t = (key: string, params?: Record) =>
+ params ? `${key}:${Object.values(params).join(',')}` : key;
+
+function msg(id: number, over: Partial = {}): ChatMessage {
+ return {
+ id,
+ trip_id: 1,
+ user_id: 2,
+ text: `Message ${id}`,
+ reply_to: null,
+ username: 'Alice',
+ avatar: null,
+ avatar_url: null,
+ created_at: '2026-05-01T10:00:00Z',
+ reactions: [],
+ ...over,
+ };
+}
+
+function setup(over: Partial = {}) {
+ const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
+ const hook = renderHook(() => useCollabChatData({ tripId: 1, t, toast, ...over }));
+ return { ...hook, toast };
+}
+
+async function loaded(over: Partial = {}) {
+ const view = setup(over);
+ await waitFor(() => expect(view.result.current.loading).toBe(false));
+ return view;
+}
+
+function wsHandler(): (event: Record) => void {
+ const calls = vi.mocked(addListener).mock.calls;
+ return calls[calls.length - 1][0] as (event: Record) => void;
+}
+
+function typeText(result: { current: ReturnType }, value: string) {
+ act(() => {
+ result.current.handleTextChange({ target: { value } } as ChangeEvent);
+ });
+}
+
+beforeEach(() => {
+ vi.mocked(addListener).mockClear();
+ vi.mocked(removeListener).mockClear();
+ vi.spyOn(collabApi, 'getMessages').mockResolvedValue({ messages: [msg(1), msg(2, { deleted: 1 })] });
+ vi.spyOn(collabApi, 'sendMessage').mockResolvedValue({ message: msg(10, { text: 'hi' }) });
+ vi.spyOn(collabApi, 'deleteMessage').mockResolvedValue({});
+ vi.spyOn(collabApi, 'reactMessage').mockResolvedValue({ reactions: [{ emoji: '👍', count: 1, users: [] }] });
+ URL.createObjectURL = vi.fn(() => 'blob:mock');
+ URL.revokeObjectURL = vi.fn();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('useCollabChatData', () => {
+ it('FE-COLLAB-CHATDATA-001: loads the newest page and marks deleted messages', async () => {
+ const { result } = setup();
+ expect(result.current.loading).toBe(true);
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(collabApi.getMessages).toHaveBeenCalledWith(1);
+ expect(result.current.messages.map((m) => [m.id, !!m._deleted])).toEqual([
+ [1, false],
+ [2, true],
+ ]);
+ expect(result.current.hasMore).toBe(false);
+ });
+
+ it('FE-COLLAB-CHATDATA-002: a full page offers older messages, a bare array is read too', async () => {
+ const page = Array.from({ length: 100 }, (_, i) => msg(i + 1));
+ vi.mocked(collabApi.getMessages).mockResolvedValue(page);
+ const { result } = await loaded();
+ expect(result.current.messages).toHaveLength(100);
+ expect(result.current.hasMore).toBe(true);
+ });
+
+ it('FE-COLLAB-CHATDATA-003: a failed first load just stops loading', async () => {
+ vi.mocked(collabApi.getMessages).mockRejectedValue(new Error('offline'));
+ const { result } = await loaded();
+ expect(result.current.messages).toEqual([]);
+ });
+
+ it('FE-COLLAB-CHATDATA-004: loading older messages asks before the oldest and prepends them', async () => {
+ const { result } = await loaded();
+ vi.mocked(collabApi.getMessages).mockResolvedValue({ messages: [msg(-1)] });
+ await act(async () => {
+ await result.current.handleLoadMore();
+ });
+ expect(collabApi.getMessages).toHaveBeenLastCalledWith(1, '1');
+ expect(result.current.messages.map((m) => m.id)).toEqual([-1, 1, 2]);
+ expect(result.current.hasMore).toBe(false);
+ expect(result.current.loadingMore).toBe(false);
+ });
+
+ it('FE-COLLAB-CHATDATA-005: an empty older page ends paging; nothing loads before the first message', async () => {
+ vi.mocked(collabApi.getMessages).mockResolvedValue({ messages: [] });
+ const { result } = await loaded();
+ await act(async () => {
+ await result.current.handleLoadMore();
+ });
+ expect(collabApi.getMessages).toHaveBeenCalledTimes(1);
+ });
+
+ it('FE-COLLAB-CHATDATA-006: a failed older page is quiet on desktop and toasted on the phone', async () => {
+ const desktop = await loaded();
+ vi.mocked(collabApi.getMessages).mockRejectedValue(new Error('boom'));
+ await act(async () => {
+ await desktop.result.current.handleLoadMore();
+ });
+ expect(desktop.toast.error).not.toHaveBeenCalled();
+ expect(desktop.result.current.loadingMore).toBe(false);
+
+ vi.mocked(collabApi.getMessages).mockResolvedValue({ messages: [msg(1)] });
+ const phone = await loaded({ toastLoadMoreErrors: true });
+ vi.mocked(collabApi.getMessages).mockRejectedValue(new Error('boom'));
+ await act(async () => {
+ await phone.result.current.handleLoadMore();
+ });
+ expect(phone.toast.error).toHaveBeenCalledWith('common.error');
+ });
+
+ it('FE-COLLAB-CHATDATA-007: live events add, delete and react, and only for this trip', async () => {
+ const { result } = await loaded();
+ const handler = wsHandler();
+ act(() => {
+ handler({ type: 'collab:message:created', tripId: 2, message: msg(50) });
+ handler({ type: 'collab:message:created', tripId: 1, message: msg(51) });
+ handler({ type: 'collab:message:created', tripId: '1', message: msg(51) });
+ handler({ type: 'collab:message:deleted', tripId: 1, messageId: 1 });
+ handler({ type: 'collab:message:reacted', tripId: 1, messageId: 51, reactions: [{ emoji: '🔥' }] });
+ });
+ expect(result.current.messages.map((m) => m.id)).toEqual([1, 2, 51]);
+ expect(result.current.messages[0]._deleted).toBe(true);
+ expect(result.current.messages[2].reactions).toEqual([{ emoji: '🔥' }]);
+ });
+
+ it('FE-COLLAB-CHATDATA-008: a remote delete scrolls down only where asked', async () => {
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
+ cb(0);
+ return 0;
+ });
+ const smooth = expect.objectContaining({ behavior: 'smooth' });
+
+ const phone = await loaded();
+ const phoneList = document.createElement('div');
+ phoneList.scrollTo = vi.fn();
+ phone.result.current.scrollRef.current = phoneList;
+ act(() => wsHandler()({ type: 'collab:message:deleted', tripId: 1, messageId: 1 }));
+ await new Promise((resolve) => setTimeout(resolve, 120));
+ expect(phoneList.scrollTo).not.toHaveBeenCalledWith(smooth);
+
+ const desktop = await loaded({ scrollOnRemoteDelete: true });
+ const desktopList = document.createElement('div');
+ desktopList.scrollTo = vi.fn();
+ desktop.result.current.scrollRef.current = desktopList;
+ act(() => wsHandler()({ type: 'collab:message:deleted', tripId: 1, messageId: 1 }));
+ await waitFor(() => expect(desktopList.scrollTo).toHaveBeenCalledWith(smooth));
+ });
+
+ it('FE-COLLAB-CHATDATA-009: unmounting removes the live listener', async () => {
+ const { unmount } = await loaded();
+ const handler = wsHandler();
+ unmount();
+ expect(removeListener).toHaveBeenCalledWith(handler);
+ });
+
+ it('FE-COLLAB-CHATDATA-010: sending a text with a reply posts JSON, appends and clears the draft', async () => {
+ const onSent = vi.fn();
+ const { result } = await loaded({ onSent });
+ typeText(result, ' hi ');
+ act(() => result.current.setReplyTo(msg(1)));
+ await act(async () => {
+ await result.current.handleSend();
+ });
+ expect(collabApi.sendMessage).toHaveBeenCalledWith(1, { text: 'hi', reply_to: 1 });
+ expect(result.current.messages.map((m) => m.id)).toEqual([1, 2, 10]);
+ expect(result.current.text).toBe('');
+ expect(result.current.replyTo).toBeNull();
+ expect(onSent).toHaveBeenCalledTimes(1);
+ expect(result.current.sending).toBe(false);
+ });
+
+ it('FE-COLLAB-CHATDATA-011: nothing is sent for a blank draft or without the permission', async () => {
+ const { result } = await loaded();
+ typeText(result, ' ');
+ await act(async () => {
+ await result.current.handleSend();
+ });
+ const phone = await loaded({ canSend: false });
+ typeText(phone.result, 'hi');
+ await act(async () => {
+ await phone.result.current.handleSend();
+ });
+ expect(collabApi.sendMessage).not.toHaveBeenCalled();
+ });
+
+ it('FE-COLLAB-CHATDATA-012: images go as a form, with upload progress only where asked', async () => {
+ const image = new File([new Uint8Array(10)], 'a.png', { type: 'image/png' });
+ const phone = await loaded();
+ act(() => phone.result.current.addImageFiles([image]));
+ await act(async () => {
+ await phone.result.current.handleSend();
+ });
+ expect(vi.mocked(collabApi.sendMessage).mock.calls[0]).toHaveLength(2);
+ expect(vi.mocked(collabApi.sendMessage).mock.calls[0][1]).toBeInstanceOf(FormData);
+ expect(phone.result.current.images.files).toEqual([]);
+
+ const desktop = await loaded({ trackUploadProgress: true });
+ act(() => desktop.result.current.addImageFiles([image]));
+ typeText(desktop.result, 'look');
+ await act(async () => {
+ await desktop.result.current.handleSend();
+ });
+ const [, form, opts] = vi.mocked(collabApi.sendMessage).mock.calls[1];
+ expect((form as FormData).get('text')).toBe('look');
+ expect((form as FormData).getAll('images')).toHaveLength(1);
+ expect(opts).toEqual({ onUploadProgress: expect.any(Function) });
+ expect(desktop.result.current.uploadProgress).toBe(0);
+ });
+
+ it('FE-COLLAB-CHATDATA-013: a rejected or surplus image is toasted', async () => {
+ const { result, toast } = await loaded();
+ act(() => result.current.addImageFiles([new File(['x'], 'a.txt', { type: 'text/plain' })]));
+ expect(toast.error).toHaveBeenCalledWith('collab.chat.imageRejected');
+ const png = () => new File([new Uint8Array(1)], 'a.png', { type: 'image/png' });
+ act(() => result.current.addImageFiles([png(), png(), png(), png(), png()]));
+ expect(toast.error).toHaveBeenCalledWith('collab.chat.imageLimit:4');
+ });
+
+ it('FE-COLLAB-CHATDATA-014: a failed send keeps the draft and toasts', async () => {
+ vi.mocked(collabApi.sendMessage).mockRejectedValue(new Error('boom'));
+ const onSent = vi.fn();
+ const { result, toast } = await loaded({ onSent });
+ typeText(result, 'hi');
+ await act(async () => {
+ await result.current.handleSend();
+ });
+ expect(result.current.text).toBe('hi');
+ expect(onSent).not.toHaveBeenCalled();
+ expect(toast.error).toHaveBeenCalledWith('common.error');
+ expect(result.current.sending).toBe(false);
+ });
+
+ it('FE-COLLAB-CHATDATA-015: deleting marks the message, a failure toasts', async () => {
+ const { result, toast } = await loaded();
+ await act(async () => {
+ await result.current.deleteMessage(1);
+ });
+ expect(collabApi.deleteMessage).toHaveBeenCalledWith(1, 1);
+ expect(result.current.messages[0]._deleted).toBe(true);
+
+ vi.mocked(collabApi.deleteMessage).mockRejectedValue(new Error('boom'));
+ await act(async () => {
+ await result.current.deleteMessage(2);
+ });
+ expect(toast.error).toHaveBeenCalledWith('common.error');
+ });
+
+ it('FE-COLLAB-CHATDATA-016: reacting stores the server reactions, a failure toasts', async () => {
+ const { result, toast } = await loaded();
+ await act(async () => {
+ await result.current.reactToMessage(1, '👍');
+ });
+ expect(collabApi.reactMessage).toHaveBeenCalledWith(1, 1, '👍');
+ expect(result.current.messages[0].reactions).toEqual([{ emoji: '👍', count: 1, users: [] }]);
+
+ vi.mocked(collabApi.reactMessage).mockRejectedValue(new Error('boom'));
+ await act(async () => {
+ await result.current.reactToMessage(1, '🔥');
+ });
+ expect(toast.error).toHaveBeenCalledWith('common.error');
+ });
+});
diff --git a/client/src/components/Collab/useCollabChatData.ts b/client/src/components/Collab/useCollabChatData.ts
new file mode 100644
index 000000000..8237033c0
--- /dev/null
+++ b/client/src/components/Collab/useCollabChatData.ts
@@ -0,0 +1,269 @@
+import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
+
+import { collabApi } from '../../api/client';
+import { addListener, removeListener } from '../../api/websocket';
+import type { useTranslation } from '../../i18n';
+import type { useToast } from '../shared/Toast';
+import type { ChatMessage, ChatReaction } from './collabModel';
+import { MAX_CHAT_IMAGES, useChatImages } from './useChatImages';
+
+/** A full page of messages; a shorter page means there is nothing older to load. */
+const PAGE_SIZE = 100;
+
+type MessagesResponse = ChatMessage[] | { messages?: ChatMessage[] };
+
+function readMessages(data: MessagesResponse): ChatMessage[] {
+ return (Array.isArray(data) ? data : data.messages || []).map((m) => (m.deleted ? { ...m, _deleted: true } : m));
+}
+
+export interface CollabChatDataOptions {
+ tripId: number | string;
+ t: ReturnType['t'];
+ toast: ReturnType;
+ /** The phone toasts a failed older page; the desktop panel stays quiet. */
+ toastLoadMoreErrors?: boolean;
+ /** The desktop panel scrolls down after a remote delete as it does after a new message. */
+ scrollOnRemoteDelete?: boolean;
+ /** The desktop panel reports how far an image upload got. */
+ trackUploadProgress?: boolean;
+ /** The phone refuses to send without the collab edit permission. */
+ canSend?: boolean;
+ /** Runs once a message went out, next to clearing the draft. */
+ onSent?: () => void;
+}
+
+/**
+ * The trip chat behind the desktop Collab panel and the phone's chat tab, which draw their
+ * own message list and composer over it: the newest page on mount, older pages on demand,
+ * the live WebSocket updates, the draft with its images and reply, and sending, deleting
+ * and reacting. Collab has no store slice, so the messages live here.
+ */
+export function useCollabChatData({
+ tripId,
+ t,
+ toast,
+ toastLoadMoreErrors = false,
+ scrollOnRemoteDelete = false,
+ trackUploadProgress = false,
+ canSend = true,
+ onSent,
+}: CollabChatDataOptions) {
+ const [messages, setMessages] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [hasMore, setHasMore] = useState(false);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [text, setText] = useState('');
+ const [replyTo, setReplyTo] = useState(null);
+ const [sending, setSending] = useState(false);
+ const [uploadProgress, setUploadProgress] = useState(0);
+ const images = useChatImages();
+
+ const scrollRef = useRef(null);
+ const textareaRef = useRef(null);
+ const isAtBottom = useRef(true);
+ const messagesRef = useRef([]);
+ messagesRef.current = messages;
+
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
+ const el = scrollRef.current;
+ if (!el) return;
+ requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior }));
+ }, []);
+
+ const checkAtBottom = useCallback(() => {
+ const el = scrollRef.current;
+ if (!el) return;
+ isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ collabApi
+ .getMessages(tripId)
+ .then((data: MessagesResponse) => {
+ if (cancelled) return;
+ const msgs = readMessages(data);
+ setMessages(msgs);
+ setHasMore(msgs.length >= PAGE_SIZE);
+ setLoading(false);
+ setTimeout(() => scrollToBottom(), 30);
+ })
+ .catch(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [tripId, scrollToBottom]);
+
+ useEffect(() => {
+ const handler = (event: Record) => {
+ if (String(event.tripId) !== String(tripId)) return;
+ if (event.type === 'collab:message:created') {
+ const message = event.message as ChatMessage;
+ setMessages((prev) => (prev.some((m) => m.id === message.id) ? prev : [...prev, message]));
+ if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 30);
+ }
+ if (event.type === 'collab:message:deleted') {
+ const messageId = event.messageId as number;
+ setMessages((prev) => prev.map((m) => (m.id === messageId ? { ...m, _deleted: true } : m)));
+ if (scrollOnRemoteDelete && isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50);
+ }
+ if (event.type === 'collab:message:reacted') {
+ const messageId = event.messageId as number;
+ const reactions = event.reactions as ChatReaction[];
+ setMessages((prev) => prev.map((m) => (m.id === messageId ? { ...m, reactions } : m)));
+ }
+ };
+ addListener(handler);
+ return () => removeListener(handler);
+ }, [tripId, scrollToBottom, scrollOnRemoteDelete]);
+
+ const handleLoadMore = useCallback(async () => {
+ const current = messagesRef.current;
+ if (loadingMore || current.length === 0) return;
+ setLoadingMore(true);
+ const el = scrollRef.current;
+ const prevHeight = el ? el.scrollHeight : 0;
+ try {
+ const beforeId = current[0]?.id;
+ const data: MessagesResponse = await collabApi.getMessages(
+ tripId,
+ beforeId != null ? String(beforeId) : undefined
+ );
+ const older = readMessages(data);
+ if (older.length === 0) {
+ setHasMore(false);
+ } else {
+ setMessages((prev) => [...older, ...prev]);
+ setHasMore(older.length >= PAGE_SIZE);
+ requestAnimationFrame(() => {
+ if (el) el.scrollTop = el.scrollHeight - prevHeight;
+ });
+ }
+ } catch {
+ if (toastLoadMoreErrors) toast.error(t('common.error'));
+ } finally {
+ setLoadingMore(false);
+ }
+ }, [tripId, loadingMore, toastLoadMoreErrors, toast, t]);
+
+ /** Keeps the draft box as tall as its text, up to 100px, then scrolls inside it. */
+ const handleTextChange = useCallback((e: ChangeEvent) => {
+ setText(e.target.value);
+ const ta = textareaRef.current;
+ if (ta) {
+ ta.style.height = 'auto';
+ const h = Math.min(ta.scrollHeight, 100);
+ ta.style.height = `${h}px`;
+ ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden';
+ }
+ }, []);
+
+ const addImageFiles = useCallback(
+ (incoming: File[] | FileList) => {
+ images.add(incoming, ({ rejected, overflow }) => {
+ if (rejected) toast.error(t('collab.chat.imageRejected'));
+ if (overflow) toast.error(t('collab.chat.imageLimit', { count: MAX_CHAT_IMAGES }));
+ });
+ },
+ [images, toast, t]
+ );
+
+ const handleSend = useCallback(async () => {
+ const body = text.trim();
+ if ((!body && !images.files.length) || sending || !canSend) return;
+ setSending(true);
+ try {
+ let data: { message?: ChatMessage } | undefined;
+ 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 = trackUploadProgress
+ ? await collabApi.sendMessage(tripId, form, {
+ onUploadProgress: (e) => setUploadProgress(e.total ? Math.round((e.loaded / e.total) * 100) : 0),
+ })
+ : await collabApi.sendMessage(tripId, form);
+ } else {
+ const payload: { text: string; reply_to?: number } = { text: body };
+ if (replyTo) payload.reply_to = replyTo.id;
+ data = await collabApi.sendMessage(tripId, payload);
+ }
+ const sent = data?.message;
+ if (sent) {
+ setMessages((prev) => (prev.some((m) => m.id === sent.id) ? prev : [...prev, sent]));
+ }
+ setText('');
+ setReplyTo(null);
+ onSent?.();
+ 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);
+ setUploadProgress(0);
+ }
+ }, [text, sending, canSend, replyTo, tripId, trackUploadProgress, onSent, scrollToBottom, toast, t, images]);
+
+ /** Deletes a message on the server and leaves its "deleted" placeholder in the list. */
+ const deleteMessage = useCallback(
+ async (msgId: number) => {
+ try {
+ await collabApi.deleteMessage(tripId, msgId);
+ setMessages((prev) => prev.map((m) => (m.id === msgId ? { ...m, _deleted: true } : m)));
+ } catch {
+ toast.error(t('common.error'));
+ }
+ },
+ [tripId, toast, t]
+ );
+
+ const reactToMessage = useCallback(
+ async (msgId: number, emoji: string) => {
+ try {
+ const data = (await collabApi.reactMessage(tripId, msgId, emoji)) as { reactions: ChatReaction[] };
+ setMessages((prev) => prev.map((m) => (m.id === msgId ? { ...m, reactions: data.reactions } : m)));
+ } catch {
+ toast.error(t('common.error'));
+ }
+ },
+ [tripId, toast, t]
+ );
+
+ return {
+ messages,
+ setMessages,
+ loading,
+ setLoading,
+ hasMore,
+ setHasMore,
+ loadingMore,
+ setLoadingMore,
+ text,
+ setText,
+ replyTo,
+ setReplyTo,
+ sending,
+ setSending,
+ uploadProgress,
+ images,
+ scrollRef,
+ textareaRef,
+ isAtBottom,
+ messagesRef,
+ scrollToBottom,
+ checkAtBottom,
+ handleLoadMore,
+ handleTextChange,
+ addImageFiles,
+ handleSend,
+ deleteMessage,
+ reactToMessage,
+ };
+}
diff --git a/client/src/mobile/screens/trip/tabs/MCollabChat.tsx b/client/src/mobile/screens/trip/tabs/MCollabChat.tsx
index 50147a5d1..2a2655273 100644
--- a/client/src/mobile/screens/trip/tabs/MCollabChat.tsx
+++ b/client/src/mobile/screens/trip/tabs/MCollabChat.tsx
@@ -2,9 +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, MAX_CHAT_IMAGES } from '../../../../components/Collab/useChatImages'
-import { collabApi } from '../../../../api/client'
-import { addListener, removeListener } from '../../../../api/websocket'
+import { useCollabChatData } from '../../../../components/Collab/useCollabChatData'
import { useAuthStore } from '../../../../store/authStore'
import { useTranslation } from '../../../../i18n'
import type { TripPlanner } from '../MTripShell'
@@ -18,10 +16,8 @@ import {
isSameSender,
shouldShowChatDateSeparator,
type ChatMessage,
- type ChatReaction,
-} from './collabModel'
+} from '../../../../components/Collab/collabModel'
-const PAGE_SIZE = 100
const LONG_PRESS_MS = 500
const DOUBLE_TAP_MS = 300
const MOVE_CANCEL_PX = 10
@@ -36,15 +32,11 @@ interface MCollabChatProps {
planner: TripPlanner
}
-interface GetMessagesResponse { messages: ChatMessage[] }
-interface SendMessageResponse { message: ChatMessage }
-interface ReactMessageResponse { reactions: ChatReaction[] }
-
/**
- * Trip-tab Collab / Chat. Owns its own state + WebSocket listener per the
- * architecture note in 10-tab-databindings.md §8.2 — chat never touches
- * `planner.tripActions`, it talks to `collabApi` directly, exactly like
- * `useCollabChat.ts` on desktop (reimplemented rather than imported).
+ * Trip-tab Collab / Chat. Messages, the WebSocket listener and the draft come
+ * from useCollabChatData, the hook the desktop panel's useCollabChat.ts reads
+ * too (10-tab-databindings.md §8.2: chat never touches `planner.tripActions`);
+ * this tab keeps its long-press popover.
*
* Layout deviates from the other tabs on purpose (spec 03 §6.1): its own
* flex-column, not TabScroller, so the composer can sit flush above the dock.
@@ -57,167 +49,23 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
const is12h = planner.settings.time_format === '12h'
const currentUserId = user?.id ?? null
- const [messages, setMessages] = useState([])
- const [loading, setLoading] = useState(true)
- const [hasMore, setHasMore] = useState(false)
- const [loadingMore, setLoadingMore] = useState(false)
- const [text, setText] = useState('')
- const [replyTo, setReplyTo] = useState(null)
- const [sending, setSending] = useState(false)
- const images = useChatImages()
+ const {
+ messages, loading, hasMore, loadingMore, text, replyTo, setReplyTo, sending, images,
+ scrollRef, textareaRef, checkAtBottom, handleLoadMore, handleTextChange, addImageFiles, handleSend,
+ deleteMessage, reactToMessage,
+ } = useCollabChatData({ tripId, t, toast, toastLoadMoreErrors: true, canSend: canEdit })
const [popover, setPopover] = useState<{ msg: ChatMessage; x: number; y: number } | null>(null)
-
- const scrollRef = useRef(null)
- const textareaRef = useRef(null)
const imageInputRef = useRef(null)
- const isAtBottomRef = useRef(true)
- const messagesRef = useRef([])
- messagesRef.current = messages
-
- const scrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
- const el = scrollRef.current
- if (!el) return
- requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior }))
- }, [])
-
- const checkAtBottom = useCallback(() => {
- const el = scrollRef.current
- if (!el) return
- isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48
- }, [])
-
- // ── Initial load ──
- useEffect(() => {
- let cancelled = false
- setLoading(true)
- collabApi.getMessages(tripId).then((data: GetMessagesResponse) => {
- if (cancelled) return
- const msgs = (data.messages || []).map(m => (m.deleted ? { ...m, _deleted: true } : m))
- setMessages(msgs)
- setHasMore(msgs.length >= PAGE_SIZE)
- setLoading(false)
- setTimeout(() => scrollToBottom(), 30)
- }).catch(() => { if (!cancelled) setLoading(false) })
- return () => { cancelled = true }
- }, [tripId, scrollToBottom])
-
- // ── WebSocket (own listener, not handleRemoteEvent — collab has no store slice) ──
- useEffect(() => {
- const handler = (event: Record) => {
- if (String(event.tripId) !== String(tripId)) return
- if (event.type === 'collab:message:created') {
- const message = event.message as ChatMessage
- setMessages(prev => (prev.some(m => m.id === message.id) ? prev : [...prev, message]))
- if (isAtBottomRef.current) setTimeout(() => scrollToBottom('smooth'), 30)
- }
- if (event.type === 'collab:message:deleted') {
- const messageId = event.messageId as number
- setMessages(prev => prev.map(m => (m.id === messageId ? { ...m, _deleted: true } : m)))
- }
- if (event.type === 'collab:message:reacted') {
- const messageId = event.messageId as number
- const reactions = event.reactions as ChatReaction[]
- setMessages(prev => prev.map(m => (m.id === messageId ? { ...m, reactions } : m)))
- }
- }
- addListener(handler)
- return () => removeListener(handler)
- }, [tripId, scrollToBottom])
-
- const handleLoadMore = useCallback(async () => {
- const current = messagesRef.current
- if (loadingMore || current.length === 0) return
- setLoadingMore(true)
- const el = scrollRef.current
- const prevHeight = el ? el.scrollHeight : 0
- try {
- const beforeId = current[0]?.id
- const data = (await collabApi.getMessages(tripId, beforeId != null ? String(beforeId) : undefined)) as GetMessagesResponse
- const older = (data.messages || []).map(m => (m.deleted ? { ...m, _deleted: true } : m))
- if (older.length === 0) {
- setHasMore(false)
- } else {
- setMessages(prev => [...older, ...prev])
- setHasMore(older.length >= PAGE_SIZE)
- requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight })
- }
- } catch {
- toast.error(t('common.error'))
- } finally {
- setLoadingMore(false)
- }
- }, [tripId, loadingMore, toast, t])
-
- const handleTextChange = (e: React.ChangeEvent) => {
- setText(e.target.value)
- const ta = textareaRef.current
- if (ta) {
- ta.style.height = 'auto'
- const h = Math.min(ta.scrollHeight, 100)
- ta.style.height = `${h}px`
- ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden'
- }
- }
-
- const addImageFiles = (incoming: FileList | File[]) => {
- images.add(incoming, ({ rejected, overflow }) => {
- if (rejected) toast.error(t('collab.chat.imageRejected'))
- if (overflow) toast.error(t('collab.chat.imageLimit', { count: MAX_CHAT_IMAGES }))
- })
- }
-
- const handleSend = useCallback(async () => {
- const body = text.trim()
- if ((!body && !images.files.length) || sending || !canEdit) return
- setSending(true)
- try {
- let data: SendMessageResponse
- 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)) as SendMessageResponse
- } else {
- const payload: { text: string; reply_to?: number } = { text: body }
- if (replyTo) payload.reply_to = replyTo.id
- data = (await collabApi.sendMessage(tripId, payload)) as SendMessageResponse
- }
- if (data.message) {
- setMessages(prev => (prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message]))
- }
- setText('')
- setReplyTo(null)
- images.clear()
- if (textareaRef.current) textareaRef.current.style.height = 'auto'
- isAtBottomRef.current = true
- setTimeout(() => scrollToBottom('smooth'), 50)
- } catch {
- toast.error(t('common.error'))
- } finally {
- setSending(false)
- }
- }, [text, sending, canEdit, replyTo, tripId, scrollToBottom, toast, t, images])
const handleDelete = useCallback(async (msgId: number) => {
setPopover(null)
- try {
- await collabApi.deleteMessage(tripId, msgId)
- setMessages(prev => prev.map(m => (m.id === msgId ? { ...m, _deleted: true } : m)))
- } catch {
- toast.error(t('common.error'))
- }
- }, [tripId, toast, t])
+ await deleteMessage(msgId)
+ }, [deleteMessage])
const handleReact = useCallback(async (msgId: number, emoji: string) => {
setPopover(null)
- try {
- const data = (await collabApi.reactMessage(tripId, msgId, emoji)) as ReactMessageResponse
- setMessages(prev => prev.map(m => (m.id === msgId ? { ...m, reactions: data.reactions } : m)))
- } catch {
- toast.error(t('common.error'))
- }
- }, [tripId, toast, t])
+ await reactToMessage(msgId, emoji)
+ }, [reactToMessage])
const openReply = (msg: ChatMessage) => {
setPopover(null)
diff --git a/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx b/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx
index 876148279..333f9a23b 100644
--- a/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx
+++ b/client/src/mobile/screens/trip/tabs/MCollabNotes.tsx
@@ -21,7 +21,7 @@ import {
sortNotes,
type CollabNoteData,
type CollabNoteFile,
-} from './collabModel'
+} from '../../../../components/Collab/collabModel'
interface MCollabNotesProps {
planner: TripPlanner
diff --git a/client/src/mobile/screens/trip/tabs/MCollabPolls.tsx b/client/src/mobile/screens/trip/tabs/MCollabPolls.tsx
index 8fd382347..da003f2f9 100644
--- a/client/src/mobile/screens/trip/tabs/MCollabPolls.tsx
+++ b/client/src/mobile/screens/trip/tabs/MCollabPolls.tsx
@@ -22,7 +22,7 @@ import {
splitPolls,
totalPollVotes,
type CollabPollData,
-} from './collabModel'
+} from '../../../../components/Collab/collabModel'
interface MCollabPollsProps {
planner: TripPlanner
diff --git a/client/tests/unit/mobile/trip/MCollabChat.test.tsx b/client/tests/unit/mobile/trip/MCollabChat.test.tsx
index 8b7553dee..2d36cc724 100644
--- a/client/tests/unit/mobile/trip/MCollabChat.test.tsx
+++ b/client/tests/unit/mobile/trip/MCollabChat.test.tsx
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import MCollabChat from '../../../../src/mobile/screens/trip/tabs/MCollabChat'
-import type { ChatMessage } from '../../../../src/mobile/screens/trip/tabs/collabModel'
+import type { ChatMessage } from '../../../../src/components/Collab/collabModel'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { addListener, removeListener } from '../../../../src/api/websocket'
import { useAuthStore } from '../../../../src/store/authStore'
diff --git a/client/tests/unit/mobile/trip/MCollabNotes.test.tsx b/client/tests/unit/mobile/trip/MCollabNotes.test.tsx
index 7069bb8d5..8172ac3b9 100644
--- a/client/tests/unit/mobile/trip/MCollabNotes.test.tsx
+++ b/client/tests/unit/mobile/trip/MCollabNotes.test.tsx
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import MCollabNotes from '../../../../src/mobile/screens/trip/tabs/MCollabNotes'
-import type { CollabNoteData } from '../../../../src/mobile/screens/trip/tabs/collabModel'
+import type { CollabNoteData } from '../../../../src/components/Collab/collabModel'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { addListener, removeListener } from '../../../../src/api/websocket'
import { openFile } from '../../../../src/utils/fileDownload'
diff --git a/client/tests/unit/mobile/trip/MCollabPolls.test.tsx b/client/tests/unit/mobile/trip/MCollabPolls.test.tsx
index 59294b1de..9cec40cef 100644
--- a/client/tests/unit/mobile/trip/MCollabPolls.test.tsx
+++ b/client/tests/unit/mobile/trip/MCollabPolls.test.tsx
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { collabApi } from '../../../../src/api/client'
import { addListener, removeListener } from '../../../../src/api/websocket'
import MCollabPolls from '../../../../src/mobile/screens/trip/tabs/MCollabPolls'
-import type { CollabPollData, PollVoter } from '../../../../src/mobile/screens/trip/tabs/collabModel'
+import type { CollabPollData, PollVoter } from '../../../../src/components/Collab/collabModel'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import { useAuthStore } from '../../../../src/store/authStore'
import { buildUser } from '../../../helpers/factories'
diff --git a/client/tests/unit/mobile/trip/collabModel.test.ts b/client/tests/unit/mobile/trip/collabModel.test.ts
index c51b484f1..e676256df 100644
--- a/client/tests/unit/mobile/trip/collabModel.test.ts
+++ b/client/tests/unit/mobile/trip/collabModel.test.ts
@@ -25,7 +25,7 @@ import {
type CollabNoteData,
type CollabPollData,
type PollVoter,
-} from '../../../../src/mobile/screens/trip/tabs/collabModel';
+} from '../../../../src/components/Collab/collabModel';
// FE-MOB-CLBM-001 to FE-MOB-CLBM-023