350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
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<typeof globalThis.setTimeout>),
|
|
};
|
|
|
|
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<string, ChatMessageVisibilityEntry>();
|
|
const pending = new Map<string, ChatPendingRead>();
|
|
const completed = new Set<string>();
|
|
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<string>();
|
|
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<string, ChatPendingRead>): boolean {
|
|
const blockedChats = new Set<string>();
|
|
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;
|
|
}
|