From ab577283f9eabf9028b6587dd0b75344243a77c3 Mon Sep 17 00:00:00 2001 From: Alois Date: Sun, 2 Aug 2026 01:25:39 +0200 Subject: [PATCH] (feat): add draft caching --- packages/cache/src/index.ts | 30 +++--- packages/cache/src/schemas.ts | 6 ++ packages/chat/src/context.tsx | 163 ++++++++++++++++++++++++++++++-- packages/chat/src/screen.tsx | 7 +- packages/markdown/src/input.tsx | 11 ++- 5 files changed, 194 insertions(+), 23 deletions(-) diff --git a/packages/cache/src/index.ts b/packages/cache/src/index.ts index 43c5018..135d85b 100644 --- a/packages/cache/src/index.ts +++ b/packages/cache/src/index.ts @@ -13,9 +13,11 @@ import { } from "./helpers"; import { accountIdSchema, + chatDraftSchema, contactsSchema, conversationWindowSchema, userProfileSchema, + type ChatDraft, type Contact, type ConversationWindow, type UserProfile, @@ -24,7 +26,7 @@ import { export * from "./helpers"; export * from "./schemas"; -type CacheStore = "contacts" | "profiles" | "conversations"; +type CacheStore = "contacts" | "profiles" | "conversations" | "drafts"; export interface SecureValueCodec { encode(value: unknown): unknown | Promise; @@ -237,19 +239,25 @@ export function createCache(accountId: string, options: CacheOptions = {}) { }, delete: (userId: number) => remove("conversations", String(userId)), }, + drafts: { + get: (userId: number) => read("drafts", String(userId), chatDraftSchema), + put: (userId: number, draft: ChatDraft) => + write("drafts", String(userId), chatDraftSchema, draft), + delete: (userId: number) => remove("drafts", String(userId)), + }, clearAccount: async () => { ensureOpen(); await Promise.all( - (["contacts", "profiles", "conversations"] as CacheStore[]).map( - async (store) => { - const storedEntries = await entries(store); - await Promise.all( - storedEntries.map(([key]) => - deleteDatabaseEntry("cache", storedKey(store, key)), - ), - ); - }, - ), + ( + ["contacts", "profiles", "conversations", "drafts"] as CacheStore[] + ).map(async (store) => { + const storedEntries = await entries(store); + await Promise.all( + storedEntries.map(([key]) => + deleteDatabaseEntry("cache", storedKey(store, key)), + ), + ); + }), ); }, close: async () => { diff --git a/packages/cache/src/schemas.ts b/packages/cache/src/schemas.ts index 268988c..152b471 100644 --- a/packages/cache/src/schemas.ts +++ b/packages/cache/src/schemas.ts @@ -20,8 +20,14 @@ export const conversationWindowSchema = z.object({ LastMessageAt: z.number(), Messages: z.array(cachedMessageSchema), }); +// Unlike cached messages, draft content is plaintext and must use a secure codec. +export const chatDraftSchema = z.object({ + Content: z.string(), + ReplyId: z.number().optional(), +}); export type Contact = z.infer; export type UserProfile = z.infer; export type CachedMessage = z.infer; export type ConversationWindow = z.infer; +export type ChatDraft = z.infer; diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx index cb56671..78c1bb2 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -27,7 +27,7 @@ import { useMTP } from "@tensamin/mtp"; import { log, toast } from "@tensamin/shared/log"; import { useSession } from "@tensamin/storage/session"; import { useUser } from "@tensamin/user/context"; -import { createCache } from "@tensamin/cache"; +import { createCache, type ChatDraft } from "@tensamin/cache"; import { secureValueCodec } from "@tensamin/storage/secure"; export const context = createContext(undefined); @@ -147,6 +147,17 @@ type SendMessageGet = ( type GetChatSecret = (userId: number) => Promise; +type StoredDraftState = ChatDraft & { + accountId: number; + userId: number; + loaded: boolean; + revision: number; +}; + +function draftKey(accountId: number, userId: number) { + return `${accountId}:${userId}`; +} + export async function getMessage({ sendTime, ownId, @@ -226,6 +237,10 @@ export default function Provider({ children }: { children: ReactNode }) { const [liveMessagesState, setLiveMessagesState] = useState([]); const [ownId, setOwnId] = useState(0); + const [drafts, setDrafts] = useState>({}); + const draftsRef = useRef>({}); + const loadingDraftsRef = useRef(new Set()); + const draftWriteQueuesRef = useRef(new Map>()); const [currentChatSecretState, setCurrentChatSecretState] = useState<{ userId: number; value: Uint8Array | null; @@ -258,6 +273,142 @@ export default function Provider({ children }: { children: ReactNode }) { load("user_id").then(setOwnId); }, [load]); + const persistDraft = useCallback( + (key: string, accountId: number, userId: number, draft: ChatDraft) => { + const previous = + draftWriteQueuesRef.current.get(key) ?? Promise.resolve(); + const next = previous + .catch(() => undefined) + .then(async () => { + const cache = createCache(String(accountId), { + codec: secureValueCodec, + }); + if (draft.Content === "" && draft.ReplyId === undefined) { + await cache.drafts.delete(userId); + } else { + await cache.drafts.put(userId, draft); + } + }) + .catch((err) => { + log(1, "chat", "red", "Failed to cache chat draft", err); + }); + draftWriteQueuesRef.current.set(key, next); + }, + [], + ); + + const updateDraft = useCallback( + ( + accountId: number, + userId: number, + update: (current: ChatDraft) => ChatDraft, + ) => { + const key = draftKey(accountId, userId); + const current = draftsRef.current[key] ?? { + accountId, + userId, + Content: "", + loaded: false, + revision: 0, + }; + const changed = update(current); + const next: StoredDraftState = { + ...current, + ...changed, + revision: current.revision + 1, + }; + const nextDrafts = { ...draftsRef.current, [key]: next }; + draftsRef.current = nextDrafts; + setDrafts(nextDrafts); + + if (next.loaded) { + persistDraft(key, accountId, userId, { + Content: next.Content, + ReplyId: next.ReplyId, + }); + } + }, + [persistDraft], + ); + + useEffect(() => { + if ( + !Number.isSafeInteger(ownId) || + ownId <= 0 || + !Number.isSafeInteger(userIdValue) || + userIdValue <= 0 + ) { + return; + } + + const key = draftKey(ownId, userIdValue); + if (draftsRef.current[key]?.loaded || loadingDraftsRef.current.has(key)) { + return; + } + loadingDraftsRef.current.add(key); + + void (async () => { + let stored: ChatDraft | undefined; + try { + stored = await createCache(String(ownId), { + codec: secureValueCodec, + }).drafts.get(userIdValue); + } catch (err) { + log(1, "chat", "red", "Failed to restore chat draft", err); + } finally { + const current = draftsRef.current[key]; + const next: StoredDraftState = + current && current.revision > 0 + ? { ...current, loaded: true } + : { + accountId: ownId, + userId: userIdValue, + Content: stored?.Content ?? "", + ReplyId: stored?.ReplyId, + loaded: true, + revision: 0, + }; + const nextDrafts = { ...draftsRef.current, [key]: next }; + draftsRef.current = nextDrafts; + setDrafts(nextDrafts); + loadingDraftsRef.current.delete(key); + + if (next.revision > 0) { + persistDraft(key, ownId, userIdValue, { + Content: next.Content, + ReplyId: next.ReplyId, + }); + } + } + })(); + }, [ownId, persistDraft, userIdValue]); + + const activeDraftKey = + ownId > 0 && userIdValue > 0 ? draftKey(ownId, userIdValue) : undefined; + const activeDraft = activeDraftKey ? drafts[activeDraftKey] : undefined; + const composerValue = activeDraft?.Content ?? ""; + const replyTo = activeDraft?.ReplyId; + const setComposerValue = useCallback( + (value: string) => { + if (ownId <= 0 || userIdValue <= 0) return; + updateDraft(ownId, userIdValue, (current) => ({ + ...current, + Content: value, + })); + }, + [ownId, updateDraft, userIdValue], + ); + const setReplyTo = useCallback( + (value: number | undefined) => { + if (ownId <= 0 || userIdValue <= 0) return; + updateDraft(ownId, userIdValue, (current) => ({ + ...current, + ReplyId: value, + })); + }, + [ownId, updateDraft, userIdValue], + ); + useEffect(() => { if (!userIdValue) return; @@ -907,12 +1058,6 @@ export default function Provider({ children }: { children: ReactNode }) { userIdValue, ]); - // Replys - const [replyTo, setReplyTo] = useState(undefined); - useEffect(() => { - setReplyTo(undefined); - }, [userIdValue]); - return ( ; error: string; errorDescription: string; + composerValue: string; + setComposerValue: (value: string) => void; replyTo: number | undefined; setReplyTo: (value: number | undefined) => void; }; diff --git a/packages/chat/src/screen.tsx b/packages/chat/src/screen.tsx index 5ae113b..a8a4ec0 100644 --- a/packages/chat/src/screen.tsx +++ b/packages/chat/src/screen.tsx @@ -89,6 +89,8 @@ export default function Screen() { inputBoxRef, error, errorDescription, + composerValue, + setComposerValue, } = useChat(); const scrollRef = useRef(null); const topSentinelRef = useRef(null); @@ -104,7 +106,6 @@ export default function Screen() { const [lastLiveMessageCount, setLastLiveMessageCount] = useState(0); const [didInitialScroll, setDidInitialScroll] = useState(false); const [viewportHeight, setViewportHeight] = useState(0); - const [value, setValue] = useState(""); const [editingMessageId, setEditingMessageId] = useState(null); const previousEditingMessageIdRef = useRef(null); @@ -558,8 +559,8 @@ export default function Screen() {
diff --git a/packages/markdown/src/input.tsx b/packages/markdown/src/input.tsx index 3a1710e..58ebe9f 100644 --- a/packages/markdown/src/input.tsx +++ b/packages/markdown/src/input.tsx @@ -324,6 +324,7 @@ export default function Input(props: InputProps) { const elementRef = useRef(null); const viewRef = useRef(undefined); + const setValueRef = useRef(props.setValue); const onSubmitRef = useRef(props.onSubmit); const onEmojiSelectRef = useRef( props.onEmojiSelect, @@ -334,10 +335,16 @@ export default function Input(props: InputProps) { const completionCompartment = completionCompartmentRef.current; useEffect(() => { + setValueRef.current = props.setValue; onSubmitRef.current = props.onSubmit; onEmojiSelectRef.current = props.onEmojiSelect; invertEnterBehaviorRef.current = Boolean(props.invertEnterBehavior); - }, [props.onEmojiSelect, props.onSubmit, props.invertEnterBehavior]); + }, [ + props.onEmojiSelect, + props.onSubmit, + props.invertEnterBehavior, + props.setValue, + ]); useEffect(() => { if (!elementRef.current) return; @@ -346,7 +353,7 @@ export default function Input(props: InputProps) { doc: props.value, extensions: createEditorExtensions( (value) => { - props.setValue(value); + setValueRef.current(value); }, () => props.placeholder, () => invertEnterBehaviorRef.current,