150 lines
8.2 KiB
TypeScript
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) };
|
|
}
|