import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAdapter } from '../provider'; import { isOwnMessage } from '../types'; import type { Attachment, Message, SendOpts } from '../types'; const TYPING_TTL_MS = 3500; export interface UiMessage extends Message { mine: boolean; } export interface MessagesState { messages: UiMessage[]; loading: boolean; error: string | null; send: (content: string, opts?: SendOpts) => Promise; react: (messageId: string, emoji: string) => Promise; upload: (file: File) => Promise; typingUserIds: string[]; seenIds: Set; sendTyping: () => void; canReact: boolean; canUpload: boolean; } let optimisticSeq = 0; export function useMessages(threadId: string | null): MessagesState { const adapter = useAdapter(); const [raw, setRaw] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [typing, setTyping] = useState>({}); const [seenIds, setSeenIds] = useState>(new Set()); const currentActorId = adapter.currentActorId(); const actorRef = useRef(currentActorId); actorRef.current = currentActorId; // Load history, then subscribe. Reconciliation is by message id, so an echoed // send never duplicates the optimistic row. useEffect(() => { if (!threadId) { setRaw([]); setLoading(false); return; } let alive = true; setLoading(true); setRaw([]); setError(null); setSeenIds(new Set()); setTyping({}); adapter .history(threadId) .then((h) => { if (!alive) return; // Merge, don't clobber: a live message can arrive via subscribe while this // history fetch is still in flight. Blindly setting raw = h would drop it. setRaw((live) => { const histIds = new Set(h.map((m) => m.id)); const extras = live.filter((m) => !histIds.has(m.id)); return extras.length ? [...h, ...extras] : h; }); setError(null); }) .catch((e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); }) .finally(() => { if (alive) setLoading(false); }); const off = adapter.subscribe(threadId, (e) => { if (!alive) return; switch (e.kind) { case 'message': setRaw((l) => (l.some((m) => m.id === e.message.id) ? l : [...l, e.message])); break; case 'typing': if (e.userId !== actorRef.current) { setTyping((t) => ({ ...t, [e.userId]: Date.now() + TYPING_TTL_MS })); } break; case 'receipt': // Only the OTHER side reading my message counts as "seen". if (e.actorId !== actorRef.current) { setSeenIds((s) => (s.has(e.messageId) ? s : new Set(s).add(e.messageId))); } break; case 'reaction': setRaw((l) => l.map((m) => (m.id === e.messageId ? { ...m, reactions: e.reactions } : m))); break; } }); return () => { alive = false; off(); }; }, [adapter, threadId]); const messages: UiMessage[] = useMemo( () => raw.map((m) => ({ ...m, mine: isOwnMessage(m, currentActorId) })), [raw, currentActorId], ); const send = useCallback( async (content: string, opts?: SendOpts) => { if (!threadId) return; const tempId = `optimistic_${optimisticSeq++}`; const optimistic: Message = { id: tempId, actorId: actorRef.current, text: content, at: new Date().toISOString(), pending: true, reactions: [], ...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}), ...(opts?.attachment ? { attachment: opts.attachment } : {}), }; setRaw((l) => [...l, optimistic]); try { const saved = await adapter.send(threadId, content, opts); setError(null); // Replace the optimistic row with the server's. If the subscribe echo already // added the real message, just drop the optimistic one. setRaw((l) => { const withoutTemp = l.filter((m) => m.id !== tempId); return withoutTemp.some((m) => m.id === saved.id) ? withoutTemp : [...withoutTemp, saved]; }); } catch (e: unknown) { setRaw((l) => l.filter((m) => m.id !== tempId)); setError(e instanceof Error ? e.message : String(e)); throw e; } }, [adapter, threadId], ); const react = useCallback( async (messageId: string, emoji: string) => { if (!threadId || !adapter.react) return; await adapter.react(threadId, messageId, emoji); }, [adapter, threadId], ); const upload = useCallback( async (file: File): Promise => { if (!adapter.upload) throw new Error('uploads are not supported by this adapter'); return adapter.upload(file); }, [adapter], ); const sendTyping = useCallback(() => { if (threadId) adapter.sendTyping(threadId); }, [adapter, threadId]); // The newest acknowledged (non-pending) message id — what we report as read. const lastReadableId = useMemo(() => { for (let i = raw.length - 1; i >= 0; i--) { if (!raw[i]!.pending) return raw[i]!.id; } return null; }, [raw]); // Report my read of the newest message (drives the other side's "seen" tick). // Keyed on the id, not the whole array, so reaction/optimistic churn doesn't re-fire it. useEffect(() => { if (!threadId || !lastReadableId) return; void adapter.markRead(threadId, lastReadableId).catch(() => {}); }, [adapter, threadId, lastReadableId]); const typingUserIds = useMemo(() => { const now = Date.now(); return Object.entries(typing) .filter(([, exp]) => exp > now) .map(([u]) => u); }, [typing]); // Expire stale typing entries. Bumping `typing` to a new reference forces the // memo above to recompute with a fresh `now`, dropping entries past their TTL. // (A bump of unrelated state can't do this — the memo is keyed on `typing`, so it // would return its cached array and the indicator would stick forever.) useEffect(() => { if (typingUserIds.length === 0) return; const t = setTimeout(() => setTyping((p) => ({ ...p })), TYPING_TTL_MS); return () => clearTimeout(t); }, [typingUserIds.length, typing]); // Only my messages that the other side has read. const seenMine = useMemo(() => { const out = new Set(); for (const id of seenIds) if (messages.some((m) => m.id === id && m.mine)) out.add(id); return out; }, [seenIds, messages]); return { messages, loading, error, send, react, upload, typingUserIds, seenIds: seenMine, sendTyping, canReact: typeof adapter.react === 'function', canUpload: typeof adapter.upload === 'function', }; }