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

495 lines
19 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
export type ChatDraftDelivery = {
readonly clientMessageId: string;
readonly isCurrentUser: boolean;
readonly deliveryState: "sending" | "sent" | "failed";
readonly failureReasonKey: string | null;
readonly retryable: boolean;
};
export type ChatDraftStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
export type UseChatDraftInput = {
readonly storageKey: string | null;
readonly deliveries: readonly ChatDraftDelivery[];
readonly sendMessage: (body: string, clientMessageId?: string, requestId?: string) => { readonly clientMessageId: string; readonly requestId: string } | null;
readonly restoreFailedMessage?: (
body: string,
clientMessageId: string,
requestId: string,
failureReasonKey: string,
retryable: boolean,
createdAt: number
) => { readonly clientMessageId: string; readonly requestId: string } | null;
readonly retryMessage?: (clientMessageId: string) => { readonly clientMessageId: string; readonly requestId: string } | null;
readonly updateTyping: (body: string) => void;
readonly storage?: ChatDraftStorage | null;
readonly debounceMs?: number;
};
export type UseChatDraftResult = {
readonly draft: string;
readonly loaded: boolean;
readonly updateDraft: (body: string) => void;
readonly sendDraft: (body: string) => boolean;
readonly discardPendingDraft: (clientMessageId: string) => void;
};
type PendingDraft = {
readonly body: string;
readonly requestId: string;
readonly revision: number;
readonly storageKey: string;
readonly createdAt: number;
failureObserved: boolean;
failureReasonKey: string | null;
retryable: boolean;
restored: boolean;
};
type PendingSave = {
readonly handle: ReturnType<typeof setTimeout>;
readonly storage: ChatDraftStorage | null;
readonly storageKey: string;
readonly body: string;
};
const DEFAULT_DEBOUNCE_MS = 300;
const PENDING_STORAGE_SUFFIX = ":pending";
const PENDING_STORAGE_VERSION = 2;
const MAX_DRAFT_LENGTH = 8_000;
const MAX_CLIENT_MESSAGE_ID_LENGTH = 256;
const MAX_REQUEST_ID_LENGTH = 128;
const MAX_FAILURE_REASON_KEY_LENGTH = 256;
const MAX_PENDING_DRAFTS = 16;
const MAX_PENDING_STORAGE_LENGTH = 150_000;
const MAX_PENDING_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
type PersistedPendingDraft = {
readonly body: string;
readonly clientMessageId: string;
readonly requestId: string;
readonly createdAt: number;
readonly failureObserved: boolean;
readonly failureReasonKey: string | null;
readonly retryable: boolean;
readonly restored: boolean;
readonly revision: number;
};
type PersistedDraftState = {
readonly version: typeof PENDING_STORAGE_VERSION;
readonly revision: number;
readonly pending: readonly PersistedPendingDraft[];
};
/** Keeps the visible composer responsive while persisting drafts and accepted
* sends separately until their local echoes settle. */
export function useChatDraft(input: UseChatDraftInput): UseChatDraftResult {
const [draft, setDraft] = useState("");
const [loaded, setLoaded] = useState(false);
const draftRef = useRef("");
const revisionRef = useRef(0);
const pendingRef = useRef(new Map<string, PendingDraft>());
const pendingSaveRef = useRef<PendingSave | null>(null);
const inputRef = useRef(input);
inputRef.current = input;
const setDraftValue = useCallback((value: string) => {
draftRef.current = value;
setDraft(value);
}, []);
const clearPendingSave = useCallback((persist: boolean) => {
const pending = pendingSaveRef.current;
if (!pending) return;
clearTimeout(pending.handle);
pendingSaveRef.current = null;
if (persist) writeDraft(pending.storage, pending.storageKey, pending.body);
}, []);
useEffect(() => {
clearPendingSave(true);
pendingRef.current.clear();
setLoaded(false);
const storageKey = input.storageKey;
if (!storageKey) {
revisionRef.current = 0;
setDraftValue("");
setLoaded(true);
return () => clearPendingSave(true);
}
const storage = resolveStorage(input.storage);
const persisted = readPendingState(storage, storageKey);
revisionRef.current = persisted.revision;
for (const pending of persisted.pending) {
pendingRef.current.set(pending.clientMessageId, { ...pending, storageKey });
}
const rehydratedClientMessageIds = rehydratePendingMessages(
persisted,
inputRef.current.deliveries,
inputRef.current.sendMessage,
inputRef.current.restoreFailedMessage
);
let storedDraft = readDraft(storage, storageKey);
const restored = findRecoverableDraft(persisted, inputRef.current.deliveries, rehydratedClientMessageIds);
if (!storedDraft && restored) {
const pending = pendingRef.current.get(restored.clientMessageId);
if (pending && !pending.restored) {
pending.failureObserved = true;
pending.restored = true;
persistPendingState(storage, storageKey, persisted.revision, pendingRef.current);
}
storedDraft = restored.body;
writeDraft(storage, storageKey, storedDraft);
} else if (storedDraft && isStaleSentDraft(storedDraft, persisted)) {
storedDraft = "";
writeDraft(storage, storageKey, "");
}
setDraftValue(storedDraft);
setLoaded(true);
return () => clearPendingSave(true);
}, [clearPendingSave, input.storage, input.storageKey, setDraftValue]);
useEffect(() => {
for (const [clientMessageId, pending] of pendingRef.current) {
const delivery = input.deliveries.find((candidate) => candidate.isCurrentUser && candidate.clientMessageId === clientMessageId);
if (!delivery) continue;
if (delivery.deliveryState === "sending") {
if (!pending.failureObserved) continue;
const clearRestoredDraft = pending.restored && revisionRef.current === pending.revision && draftRef.current === pending.body;
pending.failureObserved = false;
pending.failureReasonKey = null;
pending.retryable = false;
pending.restored = false;
if (clearRestoredDraft) {
setDraftValue("");
writeDraft(resolveStorage(inputRef.current.storage), pending.storageKey, "");
}
persistPendingState(resolveStorage(inputRef.current.storage), pending.storageKey, revisionRef.current, pendingRef.current);
continue;
}
if (delivery.deliveryState === "failed") {
if (pending.failureObserved) continue;
pending.failureObserved = true;
pending.failureReasonKey = delivery.failureReasonKey;
pending.retryable = delivery.retryable;
if (delivery.retryable && revisionRef.current === pending.revision && draftRef.current.length === 0) {
pending.restored = true;
persistPendingState(resolveStorage(inputRef.current.storage), pending.storageKey, revisionRef.current, pendingRef.current);
setDraftValue(pending.body);
writeDraft(resolveStorage(inputRef.current.storage), pending.storageKey, pending.body);
} else {
persistPendingState(resolveStorage(inputRef.current.storage), pending.storageKey, revisionRef.current, pendingRef.current);
}
continue;
}
if (delivery.deliveryState !== "sent") continue;
if (pending.restored && revisionRef.current === pending.revision && draftRef.current === pending.body) {
setDraftValue("");
writeDraft(resolveStorage(inputRef.current.storage), pending.storageKey, "");
}
pendingRef.current.delete(clientMessageId);
persistPendingState(resolveStorage(inputRef.current.storage), pending.storageKey, revisionRef.current, pendingRef.current);
}
}, [input.deliveries, setDraftValue]);
const updateDraft = useCallback(
(body: string) => {
revisionRef.current += 1;
setDraftValue(body);
inputRef.current.updateTyping(body);
clearPendingSave(false);
const storageKey = inputRef.current.storageKey;
if (!storageKey) return;
const delay = normalizeDebounce(inputRef.current.debounceMs);
const storage = resolveStorage(inputRef.current.storage);
persistPendingState(storage, storageKey, revisionRef.current, pendingRef.current);
const handle = setTimeout(() => {
const pending = pendingSaveRef.current;
if (!pending || pending.handle !== handle) return;
pendingSaveRef.current = null;
writeDraft(pending.storage, pending.storageKey, pending.body);
}, delay);
pendingSaveRef.current = { handle, storage, storageKey, body };
},
[clearPendingSave, setDraftValue]
);
const sendDraft = useCallback(
(body: string): boolean => {
const storageKey = inputRef.current.storageKey;
if (!storageKey) return false;
clearPendingSave(false);
writeDraft(resolveStorage(inputRef.current.storage), storageKey, body);
const restored = findPendingRetry(pendingRef.current, revisionRef.current, body);
if (!restored && pendingRef.current.size >= MAX_PENDING_DRAFTS) return false;
const failedInCurrentClient =
restored !== null &&
inputRef.current.deliveries.some(
(delivery) => delivery.isCurrentUser && delivery.clientMessageId === restored.clientMessageId && delivery.deliveryState === "failed"
);
const echo = failedInCurrentClient
? (inputRef.current.retryMessage?.(restored.clientMessageId) ?? null)
: inputRef.current.sendMessage(body, restored?.clientMessageId, restored?.requestId);
if (!echo) return false;
const storage = resolveStorage(inputRef.current.storage);
if (isValidClientMessageId(echo.clientMessageId) && isValidDraft(body)) {
pendingRef.current.set(echo.clientMessageId, {
body,
requestId: echo.requestId,
revision: revisionRef.current,
storageKey,
createdAt: Date.now(),
failureObserved: false,
failureReasonKey: null,
retryable: false,
restored: false,
});
persistPendingState(storage, storageKey, revisionRef.current, pendingRef.current);
}
setDraftValue("");
writeDraft(storage, storageKey, "");
return true;
},
[clearPendingSave, setDraftValue]
);
const discardPendingDraft = useCallback((clientMessageId: string) => {
const pending = pendingRef.current.get(clientMessageId);
if (!pending) return;
pendingRef.current.delete(clientMessageId);
persistPendingState(resolveStorage(inputRef.current.storage), pending.storageKey, revisionRef.current, pendingRef.current);
}, []);
return { draft, loaded, updateDraft, sendDraft, discardPendingDraft };
}
function resolveStorage(storage: ChatDraftStorage | null | undefined): ChatDraftStorage | null {
if (storage !== undefined) return storage;
return typeof window === "undefined" ? null : window.localStorage;
}
function readDraft(storage: ChatDraftStorage | null, storageKey: string): string {
try {
const draft = storage?.getItem(storageKey) ?? "";
if (isValidDraft(draft)) return draft;
storage?.removeItem(storageKey);
return "";
} catch {
return "";
}
}
function writeDraft(storage: ChatDraftStorage | null, storageKey: string, draft: string): void {
try {
if (draft && isValidDraft(draft)) storage?.setItem(storageKey, draft);
else storage?.removeItem(storageKey);
} catch {
// Local persistence must never block delivery or composer input.
}
}
function normalizeDebounce(value: number | undefined): number {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : DEFAULT_DEBOUNCE_MS;
}
function pendingStorageKey(storageKey: string): string {
return `${storageKey}${PENDING_STORAGE_SUFFIX}`;
}
function readPendingState(storage: ChatDraftStorage | null, storageKey: string): PersistedDraftState {
const empty = emptyPendingState();
try {
const key = pendingStorageKey(storageKey);
const raw = storage?.getItem(key);
if (!raw) return empty;
if (raw.length > MAX_PENDING_STORAGE_LENGTH) {
storage?.removeItem(key);
return empty;
}
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed) || parsed.version !== PENDING_STORAGE_VERSION || !isRevision(parsed.revision) || !Array.isArray(parsed.pending)) {
storage?.removeItem(key);
return empty;
}
const now = Date.now();
const pending: PersistedPendingDraft[] = [];
const seen = new Set<string>();
for (const candidate of parsed.pending) {
const normalized = normalizePersistedPending(candidate, parsed.revision, now);
if (!normalized || seen.has(normalized.clientMessageId)) continue;
seen.add(normalized.clientMessageId);
pending.push(normalized);
}
pending.sort((left, right) => left.createdAt - right.createdAt);
const bounded = pending.slice(-MAX_PENDING_DRAFTS);
const state: PersistedDraftState = { version: PENDING_STORAGE_VERSION, revision: parsed.revision, pending: bounded };
if (bounded.length === 0 || bounded.length !== parsed.pending.length) {
persistPendingState(storage, storageKey, state.revision, toPendingMap(state, storageKey));
}
return state;
} catch {
try {
storage?.removeItem(pendingStorageKey(storageKey));
} catch {
// Corrupt or inaccessible local persistence must not block the composer.
}
return empty;
}
}
function persistPendingState(storage: ChatDraftStorage | null, storageKey: string, revision: number, pendingDrafts: ReadonlyMap<string, PendingDraft>): void {
try {
const key = pendingStorageKey(storageKey);
const pending = [...pendingDrafts.entries()]
.filter(([, candidate]) => candidate.storageKey === storageKey)
.sort(([, left], [, right]) => left.createdAt - right.createdAt)
.slice(-MAX_PENDING_DRAFTS)
.map(([clientMessageId, candidate]) => ({
body: candidate.body,
clientMessageId,
requestId: candidate.requestId,
createdAt: candidate.createdAt,
failureObserved: candidate.failureObserved,
failureReasonKey: candidate.failureReasonKey,
retryable: candidate.retryable,
restored: candidate.restored,
revision: candidate.revision,
}));
if (pending.length === 0) {
storage?.removeItem(key);
return;
}
const serialized = JSON.stringify({ version: PENDING_STORAGE_VERSION, revision, pending } satisfies PersistedDraftState);
if (serialized.length <= MAX_PENDING_STORAGE_LENGTH) storage?.setItem(key, serialized);
else storage?.removeItem(key);
} catch {
// Local persistence must never block delivery or composer input.
}
}
function normalizePersistedPending(candidate: unknown, currentRevision: number, now: number): PersistedPendingDraft | null {
if (!isRecord(candidate)) return null;
if (!isValidClientMessageId(candidate.clientMessageId) || !isValidDraft(candidate.body)) return null;
if (!isValidRequestId(candidate.requestId)) return null;
if (!isRevision(candidate.revision) || candidate.revision > currentRevision) return null;
if (!isTimestamp(candidate.createdAt, now)) return null;
if (typeof candidate.failureObserved !== "boolean" || typeof candidate.restored !== "boolean") return null;
if (candidate.failureReasonKey !== null && !isValidFailureReasonKey(candidate.failureReasonKey)) return null;
if (typeof candidate.retryable !== "boolean") return null;
if (!candidate.failureObserved && (candidate.failureReasonKey !== null || candidate.retryable)) return null;
if (candidate.restored && !candidate.failureObserved) return null;
return {
body: candidate.body,
clientMessageId: candidate.clientMessageId,
requestId: candidate.requestId,
createdAt: candidate.createdAt,
failureObserved: candidate.failureObserved,
failureReasonKey: candidate.failureReasonKey,
retryable: candidate.retryable,
restored: candidate.restored,
revision: candidate.revision,
};
}
function emptyPendingState(): PersistedDraftState {
return { version: PENDING_STORAGE_VERSION, revision: 0, pending: [] };
}
function rehydratePendingMessages(
state: PersistedDraftState,
deliveries: readonly ChatDraftDelivery[],
sendMessage: UseChatDraftInput["sendMessage"],
restoreFailedMessage: UseChatDraftInput["restoreFailedMessage"]
): ReadonlySet<string> {
const rehydrated = new Set<string>();
for (const pending of state.pending) {
const alreadyVisible = deliveries.some((delivery) => delivery.isCurrentUser && delivery.clientMessageId === pending.clientMessageId);
if (alreadyVisible) continue;
const echo = pending.failureObserved
? (restoreFailedMessage?.(
pending.body,
pending.clientMessageId,
pending.requestId,
pending.failureReasonKey ?? "chat.message.sendFailed",
pending.retryable,
pending.createdAt
) ?? null)
: sendMessage(pending.body, pending.clientMessageId, pending.requestId);
if (echo?.clientMessageId === pending.clientMessageId && echo.requestId === pending.requestId) {
rehydrated.add(pending.clientMessageId);
}
}
return rehydrated;
}
function findRecoverableDraft(
state: PersistedDraftState,
deliveries: readonly ChatDraftDelivery[],
rehydratedClientMessageIds: ReadonlySet<string>
): PersistedPendingDraft | null {
return (
[...state.pending]
.reverse()
.find(
(pending) =>
!rehydratedClientMessageIds.has(pending.clientMessageId) &&
pending.revision === state.revision &&
(pending.restored || !deliveries.some((delivery) => delivery.isCurrentUser && delivery.clientMessageId === pending.clientMessageId))
) ?? null
);
}
function findPendingRetry(
pendingDrafts: ReadonlyMap<string, PendingDraft>,
revision: number,
body: string
): { readonly clientMessageId: string; readonly requestId: string } | null {
const match = [...pendingDrafts.entries()].reverse().find(([, pending]) => pending.restored && pending.revision === revision && pending.body === body);
return match ? { clientMessageId: match[0], requestId: match[1].requestId } : null;
}
function isStaleSentDraft(draft: string, state: PersistedDraftState): boolean {
return state.pending.some((pending) => !pending.restored && pending.revision === state.revision && pending.body === draft);
}
function toPendingMap(state: PersistedDraftState, storageKey: string): Map<string, PendingDraft> {
return new Map(state.pending.map((pending) => [pending.clientMessageId, { ...pending, storageKey }]));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isRevision(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
function isTimestamp(value: unknown, now: number): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= now && value >= now - MAX_PENDING_AGE_MS;
}
function isValidClientMessageId(value: unknown): value is string {
return typeof value === "string" && value.length > 0 && value.length <= MAX_CLIENT_MESSAGE_ID_LENGTH;
}
function isValidRequestId(value: unknown): value is string {
return typeof value === "string" && value.length > 0 && value.length <= MAX_REQUEST_ID_LENGTH && /^[A-Za-z0-9][A-Za-z0-9._:~-]*$/.test(value);
}
function isValidFailureReasonKey(value: unknown): value is string {
return typeof value === "string" && value.length > 0 && value.length <= MAX_FAILURE_REASON_KEY_LENGTH;
}
function isValidDraft(value: unknown): value is string {
return typeof value === "string" && value.length <= MAX_DRAFT_LENGTH;
}