import { useCallback, useEffect, useRef, useState } from 'react'; import { chatApi } from '@/src/lib/chat-api'; import { applyChatAccess, chatCursor, isChatAccessError, mergeChatMessages, type ChatAttachment, type ChatDetail, type ChatPerson, type ChatReactions, type MobileChatMessage, type MobileChatRoom, } from '@/src/lib/chat'; const errorText = (error: unknown) => error instanceof Error ? error.message : 'Chat konnte nicht geladen werden.'; // Each mounted conversation belongs to one session and one room. Late responses cannot // repopulate an unmounted conversation or a conversation whose access was revoked. export function useChatConversation(token: string, room: MobileChatRoom, enabled: boolean) { const [detail, setDetail] = useState(null); const [messages, setMessages] = useState([]); const [members, setMembers] = useState([]); const [reactions, setReactions] = useState({}); const [loading, setLoading] = useState(true); const [olderLoading, setOlderLoading] = useState(false); const [hasMore, setHasMore] = useState(false); const [sending, setSending] = useState(false); const [reacting, setReacting] = useState(false); const [error, setError] = useState(''); const [reload, setReload] = useState(0); const generation = useRef(0); const state = useRef<{ detail: ChatDetail | null; messages: MobileChatMessage[] }>({ detail: null, messages: [] }); const busy = useRef({ send: false, reaction: false, older: false }); const reactionRevision = useRef(0); const roomRef = useRef(room); roomRef.current = room; const current = useCallback((version: number) => generation.current === version, []); const update = useCallback((next: typeof state.current) => { state.current = next; setDetail(next.detail); setMessages(next.messages); }, []); const fail = useCallback((err: unknown, version: number) => { if (!current(version)) return; setError(errorText(err)); if (isChatAccessError(err)) { ++generation.current; ++reactionRevision.current; update({ detail: null, messages: [] }); setMembers([]); setReactions({}); setLoading(false); setSending(false); setReacting(false); setOlderLoading(false); setHasMore(false); } }, [current, update]); useEffect(() => { const version = ++generation.current; const target = roomRef.current; busy.current = { send: false, reaction: false, older: false }; setSending(false); setReacting(false); setOlderLoading(false); if (!enabled) return; let polling = false; async function read(nextMessages: MobileChatMessage[]) { const cursor = chatCursor(target, nextMessages); if (current(version) && cursor) { try { await chatApi.read(token, target, cursor); } catch { /* Read receipts do not block the chat. */ } } } async function refreshReactions(nextMessages: MobileChatMessage[]) { if (busy.current.reaction) return; const revision = ++reactionRevision.current; try { const result = await chatApi.reactions(token, target, nextMessages); if (current(version) && reactionRevision.current === revision) setReactions(result); } catch { /* A temporary reaction failure must not hide the conversation. */ } } async function load() { setLoading(true); setError(''); setHasMore(false); update({ detail: null, messages: [] }); setMembers([]); setReactions({}); try { const [nextDetail, page, people] = await Promise.all([ chatApi.detail(token, target), chatApi.messages(token, target), chatApi.members(token, target), ]); if (!current(version)) return; const nextMessages = target.source === 'collaboration' ? applyChatAccess(page.messages, nextDetail) : page.messages; update({ detail: nextDetail, messages: nextMessages }); setMembers(people); setHasMore(!!page.hasMore); await Promise.all([read(nextMessages), refreshReactions(nextMessages)]); } catch (err) { fail(err, version); } finally { if (current(version)) setLoading(false); } } async function poll() { if (!current(version) || polling || !state.current.detail || busy.current.older) return; polling = true; try { const [nextDetail, page, people] = await Promise.all([ chatApi.detail(token, target), chatApi.messages(token, target, { after: chatCursor(target, state.current.messages) }), chatApi.members(token, target), ]); if (!current(version)) return; const collaboration = target.source === 'collaboration'; const retained = collaboration ? applyChatAccess(state.current.messages, nextDetail, state.current.detail) : state.current.messages; let nextMessages = mergeChatMessages(retained, page.messages, collaboration); if (collaboration) nextMessages = applyChatAccess(nextMessages, nextDetail); update({ detail: nextDetail, messages: nextMessages }); setMembers(people); setError(''); await Promise.all([read(nextMessages), refreshReactions(nextMessages)]); } catch (err) { fail(err, version); } finally { polling = false; } } void load(); const timer = setInterval(() => void poll(), 3000); return () => { ++generation.current; ++reactionRevision.current; clearInterval(timer); }; }, [current, enabled, fail, reload, room.key, token, update]); async function send(text: string, image: ChatAttachment | null, clientId: string) { if (!enabled || !state.current.detail?.canSend || busy.current.send) return false; const version = generation.current; const membershipRevision = state.current.detail.revision; busy.current.send = true; setSending(true); setError(''); try { const message = await chatApi.send(token, room, text, image, clientId); // Clear the matching draft after a successful delivery, even if its room is no // longer open. Never append a late message after access/history has changed. if (!current(version) || state.current.detail?.revision !== membershipRevision) return true; const next = mergeChatMessages(state.current.messages, [message], room.source === 'collaboration'); update({ ...state.current, messages: next }); // The send is complete even if the read receipt cannot be delivered. const cursor = chatCursor(room, next); if (cursor) void chatApi.read(token, room, cursor).catch(() => undefined); return true; } catch (err) { fail(err, version); return false; } finally { if (current(version)) { busy.current.send = false; setSending(false); } } } async function older() { const before = state.current.messages.find(message => message.seq)?.seq; if (!enabled || !before || !hasMore || busy.current.older) return; const version = generation.current; busy.current.older = true; setOlderLoading(true); try { const page = await chatApi.messages(token, room, { before }); if (!current(version) || !state.current.detail) return; const next = applyChatAccess(mergeChatMessages(state.current.messages, page.messages, true), state.current.detail); update({ ...state.current, messages: next }); setHasMore(!!page.hasMore); } catch (err) { fail(err, version); } finally { if (current(version)) { busy.current.older = false; setOlderLoading(false); } } } async function react(message: MobileChatMessage, emoji: string) { if (!enabled || !state.current.detail?.canSend || state.current.detail.offline || message.pending || message.failed || busy.current.reaction) return; const version = generation.current; ++reactionRevision.current; busy.current.reaction = true; setReacting(true); try { const active = !(reactions[message.id] || []).some(item => item.emoji === emoji && item.own); const result = await chatApi.react(token, room, message.id, emoji, active); if (current(version)) setReactions(previous => ({ ...previous, [message.id]: result.reactions[message.id] || [] })); } catch (err) { if (current(version)) setError(errorText(err)); } finally { if (current(version)) { busy.current.reaction = false; setReacting(false); } } } return { detail, messages, members, reactions, loading, olderLoading, hasMore, sending, reacting, error, send, older, react, retry: () => setReload(value => value + 1) }; }