Files
FEDEO/mobile/hooks/use-chat-conversation.ts

150 lines
8.2 KiB
TypeScript

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<ChatDetail | null>(null);
const [messages, setMessages] = useState<MobileChatMessage[]>([]);
const [members, setMembers] = useState<ChatPerson[]>([]);
const [reactions, setReactions] = useState<ChatReactions>({});
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) };
}