Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/chat/client/useChatChannelRealtime.ts
T

364 lines
14 KiB
TypeScript

import {
CHAT_EVENT_NAMES,
createChatRealtimeClient,
isPersistentChatEvent,
type ChatLocalEcho,
type ChatMention,
type ChatRealtimeClient,
type ChatRealtimeClientOptions,
type ChatRealtimeMessage,
type ChatRemotePresence,
type ChatTokenProvider,
} from "@hivegrid/realtime";
import {
groupChatMessagesByDay,
reconcileChatMessages,
type ChatMessageActionsVM,
type ChatMessageCandidate,
type ChatResolvedAuthorVM,
} from "@hivegrid/view-models";
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
import {
candidateFromChatEvent,
candidateFromLocalEcho,
chatChannelRealtimeReducer,
createChatRealtimeScopeKey,
createInitialChatChannelRealtimeState,
filterHydratedChatCandidates,
overlayChatReadReceipts,
safeResolveChatAuthor,
type ChatCandidateMapping,
} from "./chatChannelRealtimeReducer";
import {
createChatReadReceiptBatcher,
createChatTypingController,
type ChatInteractionScheduler,
type ChatReadReceiptBatcher,
type ChatTypingController,
type ChatVisibilitySource,
} from "./chatInteractionControllers";
import { buildChatWebSocketUrl, createChatTokenProvider, type ChatGatewayRouting, type ChatTokenFetch } from "./chatTokenProvider";
export type ChatRealtimeClientFactory = (options: ChatRealtimeClientOptions) => ChatRealtimeClient;
export type UseChatChannelRealtimeInput = {
readonly enabled?: boolean;
readonly tenantSlug: string;
readonly chatId: string;
readonly initialCursor: string;
readonly csrfToken: string | null;
readonly routing: ChatGatewayRouting;
readonly initialMessages: readonly ChatMessageCandidate[];
readonly currentUser: ChatResolvedAuthorVM;
readonly unknownAuthorLabel: string;
readonly resolveAuthor: (userId: string) => ChatResolvedAuthorVM | null | undefined;
readonly messageActions?: (message: ChatRealtimeMessage) => ChatMessageActionsVM;
readonly revalidate: () => void | Promise<void>;
readonly fetchImpl?: ChatTokenFetch;
readonly clientFactory?: ChatRealtimeClientFactory;
readonly tokenProviderFactory?: (input: {
readonly tenantSlug: string;
readonly csrfToken: string;
readonly fetchImpl?: ChatTokenFetch;
}) => ChatTokenProvider;
readonly visibility?: ChatVisibilitySource;
readonly interactionScheduler?: ChatInteractionScheduler;
};
export type UseChatChannelRealtimeResult = {
readonly connection: ReturnType<typeof createInitialChatChannelRealtimeState>["connection"];
readonly messages: ReturnType<typeof reconcileChatMessages>;
readonly messageGroups: ReturnType<typeof groupChatMessagesByDay>;
readonly typingLabels: readonly string[];
readonly presence: readonly ChatRemotePresence[];
readonly sendMessage: (body: string, mentions?: readonly ChatMention[], clientMessageId?: string, requestId?: string) => ChatLocalEcho | null;
readonly restoreFailedMessage: (
body: string,
clientMessageId: string,
requestId: string,
failureI18nKey: string,
retryable: boolean,
createdAtMs: number
) => ChatLocalEcho | null;
readonly retryMessage: (clientMessageId: string) => ChatLocalEcho | null;
readonly discardMessage: (clientMessageId: string) => boolean;
readonly updateTyping: (body: string) => void;
readonly stopTyping: () => void;
readonly beginMessageVisible: (messageId: string, createdAtIso: string) => void;
readonly endMessageVisible: (messageId: string) => void;
readonly retryConnection: () => void;
};
export function useChatChannelRealtime(input: UseChatChannelRealtimeInput): UseChatChannelRealtimeResult {
const initialScopeKey = createChatRealtimeScopeKey(input.tenantSlug, input.chatId);
const [state, dispatch] = useReducer(chatChannelRealtimeReducer, initialScopeKey, createInitialChatChannelRealtimeState);
const clientRef = useRef<ChatRealtimeClient | null>(null);
const typingRef = useRef<ChatTypingController | null>(null);
const readBatcherRef = useRef<ChatReadReceiptBatcher | null>(null);
const retryConnectionRef = useRef<(() => void) | null>(null);
const appliedCursorRef = useRef<string | null>(null);
const inputRef = useRef(input);
inputRef.current = input;
const chatId = input.chatId;
const tenantSlug = input.tenantSlug;
const csrfToken = input.csrfToken;
const enabled = input.enabled;
const apiHost = input.routing.apiHost;
const protocol = input.routing.protocol;
const scopeKey = createChatRealtimeScopeKey(tenantSlug, chatId);
const scopedState = useMemo(() => (state.scopeKey === scopeKey ? state : createInitialChatChannelRealtimeState(scopeKey)), [scopeKey, state]);
const messages = useMemo(() => {
const candidates = [...input.initialMessages, ...filterHydratedChatCandidates(input.initialMessages, scopedState.candidates)];
return reconcileChatMessages(
overlayChatReadReceipts(candidates, scopedState.readReceipts, {
currentUser: input.currentUser,
unknownAuthorLabel: input.unknownAuthorLabel,
resolveAuthor: input.resolveAuthor,
...(input.messageActions ? { messageActions: input.messageActions } : {}),
}),
{ allowPins: true }
);
}, [
input.currentUser,
input.initialMessages,
input.messageActions,
input.resolveAuthor,
input.unknownAuthorLabel,
scopedState.candidates,
scopedState.readReceipts,
]);
const messageGroups = useMemo(() => groupChatMessagesByDay(messages), [messages]);
useEffect(() => {
const setup = inputRef.current;
const active = enabled !== false && csrfToken !== null && csrfToken.trim().length > 0;
dispatch({ type: "reset", scopeKey });
appliedCursorRef.current = null;
if (!active) return;
let mounted = true;
let revalidationQueued = false;
let reconnectObserved = false;
const requestRevalidation = () => {
if (!mounted || revalidationQueued) return;
revalidationQueued = true;
queueMicrotask(() => {
revalidationQueued = false;
if (!mounted) return;
try {
void Promise.resolve(inputRef.current.revalidate()).catch(() => undefined);
} catch {
// Transport state must not be affected by a route revalidation error.
}
});
};
const mapping = (): ChatCandidateMapping => ({
currentUser: inputRef.current.currentUser,
unknownAuthorLabel: inputRef.current.unknownAuthorLabel,
resolveAuthor: inputRef.current.resolveAuthor,
...(inputRef.current.messageActions ? { messageActions: inputRef.current.messageActions } : {}),
});
const visibility = setup.visibility ?? getDocumentVisibility();
const clientFactory = setup.clientFactory ?? createChatRealtimeClient;
const tokenProviderFactory = setup.tokenProviderFactory ?? createChatTokenProvider;
let client: ChatRealtimeClient;
try {
client = clientFactory({
url: buildChatWebSocketUrl({ apiHost, protocol }),
tokenProvider: tokenProviderFactory({
tenantSlug,
csrfToken,
...(setup.fetchImpl ? { fetchImpl: setup.fetchImpl } : {}),
}),
onStatus(status, detail) {
if (!mounted) return;
dispatch({
type: "connection",
scopeKey,
connection: {
state: status,
reconnectAttempt: detail.reconnectAttempt,
retryAtIso: detail.retryAtMs === null ? null : new Date(detail.retryAtMs).toISOString(),
},
});
if (status === "reconnecting") reconnectObserved = true;
if (status === "online") {
clientRef.current?.setPresence(visibility.visibilityState === "visible" ? "online" : "away");
readBatcherRef.current?.retryPending();
if (reconnectObserved) requestRevalidation();
reconnectObserved = false;
}
},
onEvent(event) {
if (!mounted || event.chatId !== chatId) return;
if (event.event === CHAT_EVENT_NAMES.readReceipt) {
if (event.data.userId === inputRef.current.currentUser.userId) {
readBatcherRef.current?.acknowledge(event.chatId, event.data.messageId);
}
dispatch({ type: "read_receipt", scopeKey, receipt: { messageId: event.data.messageId, userId: event.data.userId } });
}
try {
const candidate = candidateFromChatEvent(event, mapping());
if (candidate) dispatch({ type: "upsert_candidate", scopeKey, candidate });
} catch {
// A stale or unknown projection must never tear down the stream.
}
if (isPersistentChatEvent(event)) requestRevalidation();
},
onLocalEcho(echo, reason) {
if (!mounted || echo.chatId !== chatId) return;
if (reason === "discarded" || reason === "evicted") {
dispatch({
type: "remove_candidate",
scopeKey,
authorUserId: inputRef.current.currentUser.userId,
clientMessageId: echo.clientMessageId,
});
return;
}
try {
dispatch({ type: "upsert_candidate", scopeKey, candidate: candidateFromLocalEcho(echo, mapping()) });
} catch {
// Invalid local timestamps or projections are ignored, not fatal.
}
},
onTyping(chatId, entries) {
if (!mounted || chatId !== setup.chatId) return;
const currentUserId = inputRef.current.currentUser.userId;
const labels = entries.filter((entry) => entry.userId !== currentUserId).map((entry) => safeResolveChatAuthor(entry.userId, mapping()).displayName);
dispatch({ type: "typing", scopeKey, labels });
},
onPresence(chatId, entries) {
if (mounted && chatId === setup.chatId) dispatch({ type: "presence", scopeKey, entries });
},
onResetRequired({ chatId }) {
if (mounted && chatId === setup.chatId) requestRevalidation();
},
});
} catch {
dispatch({ type: "connection", scopeKey, connection: { state: "blocked", reconnectAttempt: 0, retryAtIso: null } });
return;
}
clientRef.current = client;
typingRef.current = createChatTypingController({
chatId,
send: (chatId, status) => client.setTyping(chatId, status),
...(setup.interactionScheduler ? { scheduler: setup.interactionScheduler } : {}),
});
readBatcherRef.current = createChatReadReceiptBatcher({
send: (chatId, messageId, requestId) => (requestId ? client.markRead(chatId, messageId, requestId) : client.markRead(chatId, messageId)),
visibility,
...(setup.interactionScheduler ? { scheduler: setup.interactionScheduler } : {}),
});
retryConnectionRef.current = () => {
reconnectObserved = true;
client.retryNow();
};
// Subscribe before connect so the authenticate frame carries the resume cursor.
client.subscribe(chatId, setup.initialCursor);
appliedCursorRef.current = setup.initialCursor;
client.connect();
const onVisibilityChange = () => {
if (visibility.visibilityState === "visible") {
client.setPresence("online");
} else {
typingRef.current?.stop();
client.setPresence("away");
}
};
visibility.addEventListener("visibilitychange", onVisibilityChange);
return () => {
mounted = false;
visibility.removeEventListener("visibilitychange", onVisibilityChange);
typingRef.current?.dispose();
readBatcherRef.current?.dispose();
typingRef.current = null;
readBatcherRef.current = null;
retryConnectionRef.current = null;
client.unsubscribe(chatId);
client.close();
if (clientRef.current === client) clientRef.current = null;
};
}, [apiHost, chatId, csrfToken, enabled, protocol, scopeKey, tenantSlug]);
useEffect(() => {
const client = clientRef.current;
if (!client || appliedCursorRef.current === input.initialCursor) return;
client.subscribe(input.chatId, input.initialCursor);
appliedCursorRef.current = input.initialCursor;
}, [input.chatId, input.initialCursor]);
const sendMessage = useCallback((body: string, mentions: readonly ChatMention[] = [], clientMessageId?: string, requestId?: string) => {
const client = clientRef.current;
if (!client) return null;
typingRef.current?.stop();
return client.sendMessage({
chatId: inputRef.current.chatId,
body,
mentions,
...(clientMessageId ? { clientMessageId } : {}),
...(requestId ? { requestId } : {}),
});
}, []);
const restoreFailedMessage = useCallback(
(body: string, clientMessageId: string, requestId: string, failureI18nKey: string, retryable: boolean, createdAtMs: number) =>
clientRef.current?.restoreFailedMessage({
chatId: inputRef.current.chatId,
body,
mentions: [],
clientMessageId,
requestId,
failureI18nKey,
retryable,
createdAtMs,
}) ?? null,
[]
);
const retryMessage = useCallback((clientMessageId: string) => clientRef.current?.retryMessage(clientMessageId) ?? null, []);
const discardMessage = useCallback((clientMessageId: string) => clientRef.current?.discardMessage(clientMessageId) ?? false, []);
const updateTyping = useCallback((body: string) => typingRef.current?.update(body), []);
const stopTyping = useCallback(() => typingRef.current?.stop(), []);
const beginMessageVisible = useCallback((messageId: string, createdAtIso: string) => {
readBatcherRef.current?.beginVisible(inputRef.current.chatId, messageId, createdAtIso);
}, []);
const endMessageVisible = useCallback((messageId: string) => {
readBatcherRef.current?.endVisible(inputRef.current.chatId, messageId);
}, []);
const retryConnection = useCallback(() => retryConnectionRef.current?.(), []);
return {
connection: scopedState.connection,
messages,
messageGroups,
typingLabels: scopedState.typingLabels,
presence: scopedState.presence,
sendMessage,
restoreFailedMessage,
retryMessage,
discardMessage,
updateTyping,
stopTyping,
beginMessageVisible,
endMessageVisible,
retryConnection,
};
}
function getDocumentVisibility(): ChatVisibilitySource {
if (typeof document === "undefined") {
return {
visibilityState: "visible",
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
}
return document;
}