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

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;
}