495 lines
19 KiB
TypeScript
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;
|
|
}
|