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