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