import type { ChatReadResult } from "@hivegrid/realtime"; export type ChatInteractionScheduler = { setTimeout(callback: () => void, delayMs: number): unknown; clearTimeout(handle: unknown): void; }; export type ChatVisibilitySource = { readonly visibilityState: DocumentVisibilityState; addEventListener(type: "visibilitychange", listener: () => void): void; removeEventListener(type: "visibilitychange", listener: () => void): void; }; export type ChatTypingController = { update(body: string): void; stop(): void; dispose(): void; }; export type ChatReadReceiptBatcher = { beginVisible(chatId: string, messageId: string, createdAtIso: string): void; endVisible(chatId: string, messageId: string): void; acknowledge(chatId: string, messageId: string): void; retryPending(): number; flush(): number; dispose(): void; }; const globalScheduler: ChatInteractionScheduler = { setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs), clearTimeout: (handle) => globalThis.clearTimeout(handle as ReturnType), }; export function createChatTypingController(input: { readonly chatId: string; readonly send: (chatId: string, status: "started" | "stopped") => boolean; readonly scheduler?: ChatInteractionScheduler; readonly startDelayMs?: number; readonly idleStopMs?: number; readonly refreshMs?: number; }): ChatTypingController { const scheduler = input.scheduler ?? globalScheduler; const startDelayMs = positiveDelay(input.startDelayMs, 250); const idleStopMs = positiveDelay(input.idleStopMs, 1_500); const refreshMs = positiveDelay(input.refreshMs, 3_000); let startTimer: unknown; let stopTimer: unknown; let refreshTimer: unknown; let activeBody = false; let started = false; let disposed = false; const clearTimer = (kind: "start" | "stop" | "refresh") => { const handle = kind === "start" ? startTimer : kind === "stop" ? stopTimer : refreshTimer; if (handle !== undefined) scheduler.clearTimeout(handle); if (kind === "start") startTimer = undefined; else if (kind === "stop") stopTimer = undefined; else refreshTimer = undefined; }; const scheduleRefresh = () => { clearTimer("refresh"); if (!started || !activeBody || disposed) return; refreshTimer = scheduler.setTimeout(() => { refreshTimer = undefined; if (!started || !activeBody || disposed) return; input.send(input.chatId, "started"); scheduleRefresh(); }, refreshMs); }; const signalStarted = () => { startTimer = undefined; if (!activeBody || started || disposed) return; started = input.send(input.chatId, "started"); if (started) scheduleRefresh(); }; const stop = () => { activeBody = false; clearTimer("start"); clearTimer("stop"); clearTimer("refresh"); if (!started) return; started = false; input.send(input.chatId, "stopped"); }; return { update(body) { if (disposed) return; activeBody = body.trim().length > 0; if (!activeBody) { stop(); return; } if (!started && startTimer === undefined) { startTimer = scheduler.setTimeout(signalStarted, startDelayMs); } clearTimer("stop"); stopTimer = scheduler.setTimeout(stop, idleStopMs); }, stop, dispose() { if (disposed) return; stop(); disposed = true; }, }; } /** Qualifies exact message receipts after continuous foreground visibility and * delivers them in monotonic channel order. */ export function createChatReadReceiptBatcher(input: { readonly send: (chatId: string, messageId: string, requestId?: string) => ChatReadResult; readonly visibility: ChatVisibilitySource; readonly scheduler?: ChatInteractionScheduler; readonly minimumVisibleMs?: number; readonly flushIntervalMs?: number; readonly acknowledgementTimeoutMs?: number; readonly maxPerFlush?: number; }): ChatReadReceiptBatcher { const scheduler = input.scheduler ?? globalScheduler; const minimumVisibleMs = positiveDelay(input.minimumVisibleMs, 1_000); const flushIntervalMs = positiveDelay(input.flushIntervalMs, 2_000); const acknowledgementTimeoutMs = positiveDelay(input.acknowledgementTimeoutMs, 10_000); const maxPerFlush = positiveInteger(input.maxPerFlush, 50); const visibilityEntries = new Map(); const pending = new Map(); const completed = new Set(); let flushTimer: unknown; let disposed = false; const clearFlushTimer = () => { if (flushTimer !== undefined) scheduler.clearTimeout(flushTimer); flushTimer = undefined; }; const clearAcknowledgementTimer = (receipt: ChatPendingRead) => { if (receipt.acknowledgementTimer !== undefined) scheduler.clearTimeout(receipt.acknowledgementTimer); receipt.acknowledgementTimer = undefined; }; const scheduleAcknowledgementTimeout = (key: string, receipt: ChatPendingRead) => { clearAcknowledgementTimer(receipt); receipt.acknowledgementTimer = scheduler.setTimeout(() => { receipt.acknowledgementTimer = undefined; if (disposed || pending.get(key) !== receipt) return; receipt.inFlight = false; scheduleFlush(); }, acknowledgementTimeoutMs); }; const flushPending = (allowHidden = false, scheduleNext = true): number => { clearFlushTimer(); if (disposed || (!allowHidden && input.visibility.visibilityState !== "visible")) return 0; const blockedChats = new Set(); const ordered = [...pending.entries()].sort((left, right) => comparePendingRead(left[1], right[1])); let sent = 0; let attempted = 0; for (const [key, receipt] of ordered) { if (attempted >= maxPerFlush) break; if (blockedChats.has(receipt.chatId)) continue; if (receipt.inFlight) { blockedChats.add(receipt.chatId); continue; } attempted += 1; const result = receipt.requestId ? input.send(receipt.chatId, receipt.messageId, receipt.requestId) : input.send(receipt.chatId, receipt.messageId); receipt.requestId = result.requestId; if (!result.sent) { blockedChats.add(receipt.chatId); continue; } receipt.inFlight = true; scheduleAcknowledgementTimeout(key, receipt); sent += 1; } if (scheduleNext) scheduleFlush(); return sent; }; const flush = (): number => flushPending(); const scheduleFlush = () => { if (disposed || flushTimer !== undefined || input.visibility.visibilityState !== "visible" || !hasFlushCandidate(pending)) return; flushTimer = scheduler.setTimeout(flush, flushIntervalMs); }; const clearQualificationTimer = (entry: ChatMessageVisibilityEntry) => { if (entry.qualificationTimer !== undefined) scheduler.clearTimeout(entry.qualificationTimer); entry.qualificationTimer = undefined; }; const scheduleQualification = (key: string, entry: ChatMessageVisibilityEntry) => { if (disposed || !entry.intersecting || entry.qualificationTimer !== undefined || input.visibility.visibilityState !== "visible") return; entry.qualificationTimer = scheduler.setTimeout(() => { entry.qualificationTimer = undefined; if (disposed || !entry.intersecting || input.visibility.visibilityState !== "visible") return; visibilityEntries.delete(key); if (!completed.has(key) && !pending.has(key)) { pending.set(key, { chatId: entry.chatId, messageId: entry.messageId, createdAtIso: entry.createdAtIso, requestId: null, inFlight: false, acknowledgementTimer: undefined, }); scheduleFlush(); } }, minimumVisibleMs); }; const onVisibilityChange = () => { if (input.visibility.visibilityState === "visible") { for (const [key, entry] of visibilityEntries) scheduleQualification(key, entry); scheduleFlush(); return; } clearFlushTimer(); for (const entry of visibilityEntries.values()) clearQualificationTimer(entry); }; input.visibility.addEventListener("visibilitychange", onVisibilityChange); return { beginVisible(chatId, messageId, createdAtIso) { if (disposed) return; const key = readKey(chatId, messageId); if (completed.has(key) || pending.has(key)) return; const existing = visibilityEntries.get(key); if (existing) { existing.intersecting = true; existing.createdAtIso = createdAtIso; scheduleQualification(key, existing); return; } const entry: ChatMessageVisibilityEntry = { chatId, messageId, createdAtIso, intersecting: true, qualificationTimer: undefined, }; visibilityEntries.set(key, entry); scheduleQualification(key, entry); }, endVisible(chatId, messageId) { if (disposed) return; const key = readKey(chatId, messageId); const entry = visibilityEntries.get(key); if (!entry) return; entry.intersecting = false; clearQualificationTimer(entry); visibilityEntries.delete(key); }, acknowledge(chatId, messageId) { if (disposed) return; const key = readKey(chatId, messageId); const entry = visibilityEntries.get(key); if (entry) clearQualificationTimer(entry); visibilityEntries.delete(key); const receipt = pending.get(key); if (receipt) clearAcknowledgementTimer(receipt); pending.delete(key); completed.add(key); scheduleFlush(); }, retryPending() { if (disposed) return 0; for (const receipt of pending.values()) { clearAcknowledgementTimer(receipt); receipt.inFlight = false; } return flushPending(); }, flush, dispose() { if (disposed) return; clearFlushTimer(); for (const entry of visibilityEntries.values()) clearQualificationTimer(entry); // The channel teardown still has a live socket, so qualified receipts // get one final best-effort batch even when the document is hidden. for (const receipt of pending.values()) { clearAcknowledgementTimer(receipt); receipt.inFlight = false; } flushPending(true, false); for (const receipt of pending.values()) clearAcknowledgementTimer(receipt); disposed = true; visibilityEntries.clear(); pending.clear(); completed.clear(); input.visibility.removeEventListener("visibilitychange", onVisibilityChange); }, }; } type ChatMessageVisibilityEntry = { readonly chatId: string; readonly messageId: string; createdAtIso: string; intersecting: boolean; qualificationTimer: unknown; }; type ChatPendingRead = { readonly chatId: string; readonly messageId: string; readonly createdAtIso: string; requestId: string | null; inFlight: boolean; acknowledgementTimer: unknown; }; function comparePendingRead(left: ChatPendingRead, right: ChatPendingRead): number { if (left.chatId < right.chatId) return -1; if (left.chatId > right.chatId) return 1; if (left.createdAtIso < right.createdAtIso) return -1; if (left.createdAtIso > right.createdAtIso) return 1; if (left.messageId < right.messageId) return -1; if (left.messageId > right.messageId) return 1; return 0; } function hasFlushCandidate(pending: ReadonlyMap): boolean { const blockedChats = new Set(); const ordered = [...pending.values()].sort(comparePendingRead); for (const receipt of ordered) { if (blockedChats.has(receipt.chatId)) continue; if (!receipt.inFlight) return true; blockedChats.add(receipt.chatId); } return false; } function readKey(chatId: string, messageId: string): string { return `${chatId}\u0000${messageId}`; } function positiveDelay(value: number | undefined, fallback: number): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; } function positiveInteger(value: number | undefined, fallback: number): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; }