From e0861849b7b19d4403a9abeed4372ed296053772 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 15 Sep 2026 21:32:06 -0400 Subject: [PATCH] fix(composer): persist drafts while typing, not only on unmount Drafts were written solely in the effect cleanup, so F5 in the open room lost them and the draft indicator never showed for the current room. Persist on a 500 ms debounce and on pagehide with the same {userId, nodes} shape; an empty editor clears the stored draft. Also passes the full content to the schedule modal (#36). Fixes #37 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/features/room/RoomInput.tsx | 78 +++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 22 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index bfa21ce61..84c92abc6 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -146,6 +146,9 @@ const EmojiBoard = React.lazy(() => import('../../components/emoji-board').then((m) => ({ default: m.EmojiBoard })), ); +/** [Gitea #37] Debounce for persisting the composer draft while typing. */ +const DRAFT_PERSIST_DEBOUNCE_MS = 500; + interface RoomInputProps { editor: Editor; fileDropContainerRef: RefObject; @@ -430,27 +433,54 @@ export const RoomInput = forwardRef( } }, [editor, msgDraft, draftKey, setMsgDraft, mx]); - useEffect( - () => () => { - if (!isEmptyEditor(editor)) { - const parsedDraft = JSON.parse(JSON.stringify(editor.children)); - setMsgDraft(parsedDraft); - // [Gitea #41] Tag the persisted draft with the writing user's id so a - // different account logging into this browser can't have it hydrated - // into their composer (see useHydrateMsgDrafts / clearPlaintextCaches). - localStorage.setItem( - `draft-msg-${draftKey}`, - JSON.stringify({ userId: mx.getUserId(), nodes: parsedDraft }), - ); - } else { - setMsgDraft([]); - localStorage.removeItem(`draft-msg-${draftKey}`); + // Persist the current editor state to the draft atom + localStorage; an empty + // editor clears both so the draft indicators never claim a draft that isn't there. + const persistDraft = useCallback(() => { + if (!isEmptyEditor(editor)) { + const parsedDraft = JSON.parse(JSON.stringify(editor.children)); + setMsgDraft(parsedDraft); + // [Gitea #41] Tag the persisted draft with the writing user's id so a + // different account logging into this browser can't have it hydrated + // into their composer (see useHydrateMsgDrafts / clearPlaintextCaches). + localStorage.setItem( + `draft-msg-${draftKey}`, + JSON.stringify({ userId: mx.getUserId(), nodes: parsedDraft }), + ); + } else { + setMsgDraft([]); + localStorage.removeItem(`draft-msg-${draftKey}`); + } + }, [draftKey, editor, setMsgDraft, mx]); + + // [Gitea #37] Drafts used to be written only in the unmount cleanup below, so + // a reload/tab-close in the open room lost them (and DraftIndicator never + // showed for the room being typed in). Debounce a persist while typing and + // flush it on pagehide; the cleanup still persists on unmount / draftKey change. + const persistDraftTimer = useRef | null>(null); + const schedulePersistDraft = useCallback(() => { + if (persistDraftTimer.current) clearTimeout(persistDraftTimer.current); + persistDraftTimer.current = setTimeout(() => { + persistDraftTimer.current = null; + persistDraft(); + }, DRAFT_PERSIST_DEBOUNCE_MS); + }, [persistDraft]); + + useEffect(() => { + const flush = () => { + if (persistDraftTimer.current) { + clearTimeout(persistDraftTimer.current); + persistDraftTimer.current = null; } + persistDraft(); + }; + window.addEventListener('pagehide', flush); + return () => { + window.removeEventListener('pagehide', flush); + flush(); resetEditor(editor); resetEditorHistory(editor); - }, - [draftKey, editor, setMsgDraft, mx], - ); + }; + }, [editor, persistDraft]); const handleFileMetadata = useCallback( (fileItem: TUploadItem, metadata: TUploadMetadata) => { @@ -748,6 +778,9 @@ export const RoomInput = forwardRef( next.set(roomId, [...current, { delayId, roomId, content, sendAt }]); return next; }); + // [Gitea #36] Only reached after scheduleMessage() succeeded, so the reply + // draft (already baked into `content['m.relates_to']`) can be cleared here; + // a failed/cancelled schedule keeps the composer and reply target intact. resetEditor(editor); resetEditorHistory(editor); setMsgDraft([]); @@ -1066,7 +1099,10 @@ export const RoomInput = forwardRef( onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} onPaste={handlePaste} - onChange={(value) => setCharCount(toPlainText(value, isMarkdown).trim().length)} + onChange={(value) => { + setCharCount(toPlainText(value, isMarkdown).trim().length); + schedulePersistDraft(); + }} top={ replyDraft && (
@@ -1514,9 +1550,7 @@ export const RoomInput = forwardRef( {scheduleOpen && ( { setScheduleOpen(false);