mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(collab): share the chat logic between desktop and phone
collabModel moves from the phone tabs into components/Collab so both shells read it, and the desktop chat drops its own copies of the time, date separator and emoji only helpers for the model's. useCollabChatData holds the messages, paging, live updates, the draft with its images, and sending, deleting and reacting. The desktop hook keeps its emoji picker, hover and delete animation, the phone tab its long press popover; where they differed (older page errors, scrolling after a remote delete, upload progress, the edit permission on send) the hook takes an option.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-card)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
{formatDateSeparator(msg.created_at, t)}
|
||||
{formatChatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
|
||||
<span style={{ display: 'inline-flex', gap: 6, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
<span>{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'}</span>
|
||||
<span style={{ fontStyle: 'normal' }}>{formatTime(msg.created_at, is12h)}</span>
|
||||
<span style={{ fontStyle: 'normal' }}>{formatChatClockTime(msg.created_at, is12h)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
@@ -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)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -243,7 +243,7 @@ export function ChatMessages(props: any) {
|
||||
{/* Timestamp — only on last message of group */}
|
||||
{isLastInGroup && (
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
|
||||
{formatTime(msg.created_at, is12h)}
|
||||
{formatChatClockTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+6
-4
@@ -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()
|
||||
@@ -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<ReturnType<typeof setTimeout>[]>([])
|
||||
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<HTMLInputElement>(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 }
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
params ? `${key}:${Object.values(params).join(',')}` : key;
|
||||
|
||||
function msg(id: number, over: Partial<ChatMessage> = {}): 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<CollabChatDataOptions> = {}) {
|
||||
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<CollabChatDataOptions> = {}) {
|
||||
const view = setup(over);
|
||||
await waitFor(() => expect(view.result.current.loading).toBe(false));
|
||||
return view;
|
||||
}
|
||||
|
||||
function wsHandler(): (event: Record<string, unknown>) => void {
|
||||
const calls = vi.mocked(addListener).mock.calls;
|
||||
return calls[calls.length - 1][0] as (event: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
function typeText(result: { current: ReturnType<typeof useCollabChatData> }, value: string) {
|
||||
act(() => {
|
||||
result.current.handleTextChange({ target: { value } } as ChangeEvent<HTMLTextAreaElement>);
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<typeof useTranslation>['t'];
|
||||
toast: ReturnType<typeof useToast>;
|
||||
/** 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<ChatMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [text, setText] = useState('');
|
||||
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const images = useChatImages();
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isAtBottom = useRef(true);
|
||||
const messagesRef = useRef<ChatMessage[]>([]);
|
||||
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<string, unknown>) => {
|
||||
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<HTMLTextAreaElement>) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<ChatMessage[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [text, setText] = useState('')
|
||||
const [replyTo, setReplyTo] = useState<ChatMessage | null>(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<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
const isAtBottomRef = useRef(true)
|
||||
const messagesRef = useRef<ChatMessage[]>([])
|
||||
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<string, unknown>) => {
|
||||
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<HTMLTextAreaElement>) => {
|
||||
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)
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
sortNotes,
|
||||
type CollabNoteData,
|
||||
type CollabNoteFile,
|
||||
} from './collabModel'
|
||||
} from '../../../../components/Collab/collabModel'
|
||||
|
||||
interface MCollabNotesProps {
|
||||
planner: TripPlanner
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
splitPolls,
|
||||
totalPollVotes,
|
||||
type CollabPollData,
|
||||
} from './collabModel'
|
||||
} from '../../../../components/Collab/collabModel'
|
||||
|
||||
interface MCollabPollsProps {
|
||||
planner: TripPlanner
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user