364 lines
14 KiB
TypeScript
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;
|
|
}
|