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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-15 21:32:06 -04:00
co-authored by Claude Opus 5
parent bec248b228
commit e0861849b7
+43 -9
View File
@@ -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<HTMLElement>;
@@ -430,8 +433,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}
}, [editor, msgDraft, draftKey, setMsgDraft, mx]);
useEffect(
() => () => {
// 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);
@@ -446,11 +450,37 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
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<ReturnType<typeof setTimeout> | 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<HTMLDivElement, RoomInputProps>(
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<HTMLDivElement, RoomInputProps>(
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 && (
<div>
@@ -1514,9 +1550,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
{scheduleOpen && (
<ScheduleMessageModal
roomId={roomId}
initialBody={
typeof scheduleContent?.body === 'string' ? scheduleContent.body : undefined
}
initialContent={scheduleContent}
onScheduled={handleScheduled}
onClose={() => {
setScheduleOpen(false);