From 6e83fd37b38f628cbeeed142c812306060a59f6e Mon Sep 17 00:00:00 2001 From: Tisya Gupta Date: Thu, 15 Oct 2026 11:49:43 +0530 Subject: [PATCH] feat(react): auto-save message drafts per room and thread (#1370) --- packages/react/src/lib/draftMessage.js | 30 ++-- packages/react/src/lib/draftMessage.test.js | 135 ++++++++++++++++++ .../react/src/views/ChatInput/ChatInput.js | 99 ++++++++----- 3 files changed, 224 insertions(+), 40 deletions(-) create mode 100644 packages/react/src/lib/draftMessage.test.js diff --git a/packages/react/src/lib/draftMessage.js b/packages/react/src/lib/draftMessage.js index 24a5bf2ad..1e95fb772 100644 --- a/packages/react/src/lib/draftMessage.js +++ b/packages/react/src/lib/draftMessage.js @@ -1,4 +1,4 @@ -const getDraftKey = (roomId, threadId, userId, host) => { +export const getDraftKey = (roomId, threadId, userId, host) => { const scope = `${host || ''}_${userId || ''}`; return threadId ? `ec_draft_${scope}_${roomId}_${threadId}` @@ -6,25 +6,39 @@ const getDraftKey = (roomId, threadId, userId, host) => { }; export function getDraftMessage(roomId, threadId, userId, host) { - if (typeof localStorage !== 'undefined' && roomId) { + if (typeof localStorage === 'undefined' || !roomId) { + return null; + } + try { return localStorage.getItem(getDraftKey(roomId, threadId, userId, host)); + } catch (e) { + return null; } - return null; } export function saveDraftMessage(roomId, threadId, userId, host, message) { if (typeof localStorage === 'undefined' || !roomId) { return; } - if (message && message.trim().length) { - localStorage.setItem(getDraftKey(roomId, threadId, userId, host), message); - } else { - localStorage.removeItem(getDraftKey(roomId, threadId, userId, host)); + try { + const key = getDraftKey(roomId, threadId, userId, host); + if (message && message.trim().length) { + localStorage.setItem(key, message); + } else { + localStorage.removeItem(key); + } + } catch (e) { + // ignore quota or storage access errors } } export function deleteDraftMessage(roomId, threadId, userId, host) { - if (typeof localStorage !== 'undefined' && roomId) { + if (typeof localStorage === 'undefined' || !roomId) { + return; + } + try { localStorage.removeItem(getDraftKey(roomId, threadId, userId, host)); + } catch (e) { + // ignore errors } } diff --git a/packages/react/src/lib/draftMessage.test.js b/packages/react/src/lib/draftMessage.test.js new file mode 100644 index 000000000..c0fe36103 --- /dev/null +++ b/packages/react/src/lib/draftMessage.test.js @@ -0,0 +1,135 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + getDraftKey, + getDraftMessage, + saveDraftMessage, + deleteDraftMessage, +} from './draftMessage.js'; + +// Setup mock localStorage in Node test environment +class MockLocalStorage { + constructor() { + this.store = {}; + } + + getItem(key) { + return this.store[key] ?? null; + } + + setItem(key, value) { + this.store[key] = String(value); + } + + removeItem(key) { + delete this.store[key]; + } + + clear() { + this.store = {}; + } +} + +globalThis.localStorage = new MockLocalStorage(); + +test('getDraftKey constructs room draft keys properly', () => { + assert.equal( + getDraftKey('room123', null, 'userA', 'https://chat.example.com'), + 'ec_draft_https://chat.example.com_userA_room123' + ); + + assert.equal( + getDraftKey('room123', 'thread456', 'userA', 'https://chat.example.com'), + 'ec_draft_https://chat.example.com_userA_room123_thread456' + ); + + assert.equal(getDraftKey('GENERAL', null, null, null), 'ec_draft___GENERAL'); +}); + +test('saveDraftMessage saves non-empty text to localStorage', () => { + globalThis.localStorage.clear(); + saveDraftMessage( + 'room123', + null, + 'userA', + 'https://chat.example.com', + 'Hello world' + ); + + assert.equal( + getDraftMessage('room123', null, 'userA', 'https://chat.example.com'), + 'Hello world' + ); +}); + +test('saveDraftMessage removes key when message is empty or whitespace only', () => { + globalThis.localStorage.clear(); + saveDraftMessage( + 'room123', + null, + 'userA', + 'https://chat.example.com', + 'Initial draft' + ); + assert.equal( + getDraftMessage('room123', null, 'userA', 'https://chat.example.com'), + 'Initial draft' + ); + + saveDraftMessage('room123', null, 'userA', 'https://chat.example.com', ' '); + assert.equal( + getDraftMessage('room123', null, 'userA', 'https://chat.example.com'), + null + ); +}); + +test('saveDraftMessage and getDraftMessage separate thread drafts from room drafts', () => { + globalThis.localStorage.clear(); + saveDraftMessage( + 'room123', + null, + 'userA', + 'https://chat.example.com', + 'Room draft' + ); + saveDraftMessage( + 'room123', + 'thread1', + 'userA', + 'https://chat.example.com', + 'Thread draft' + ); + + assert.equal( + getDraftMessage('room123', null, 'userA', 'https://chat.example.com'), + 'Room draft' + ); + assert.equal( + getDraftMessage('room123', 'thread1', 'userA', 'https://chat.example.com'), + 'Thread draft' + ); +}); + +test('deleteDraftMessage deletes saved draft from localStorage', () => { + globalThis.localStorage.clear(); + saveDraftMessage( + 'room123', + null, + 'userA', + 'https://chat.example.com', + 'Some draft' + ); + deleteDraftMessage('room123', null, 'userA', 'https://chat.example.com'); + + assert.equal( + getDraftMessage('room123', null, 'userA', 'https://chat.example.com'), + null + ); +}); + +test('getDraftMessage returns null when roomId is missing', () => { + assert.equal( + getDraftMessage('', null, 'userA', 'https://chat.example.com'), + null + ); +}); diff --git a/packages/react/src/views/ChatInput/ChatInput.js b/packages/react/src/views/ChatInput/ChatInput.js index 309ca918f..6b6ecbb76 100644 --- a/packages/react/src/views/ChatInput/ChatInput.js +++ b/packages/react/src/views/ChatInput/ChatInput.js @@ -35,15 +35,15 @@ import useSearchMentionUser from '../../hooks/useSearchMentionUser'; import useSearchEmoji from '../../hooks/useSearchEmoji'; import formatSelection from '../../lib/formatSelection'; import { parseEmoji } from '../../lib/emoji'; +import useDropBox from '../../hooks/useDropBox'; +import useAIComposer from '../../hooks/useAIComposer'; +import AIComposerToolbar from '../AIComposerToolbar'; +import { installContentEditableApi } from '../../lib/contentEditableComposer'; import { getDraftMessage, saveDraftMessage, deleteDraftMessage, } from '../../lib/draftMessage'; -import useDropBox from '../../hooks/useDropBox'; -import useAIComposer from '../../hooks/useAIComposer'; -import AIComposerToolbar from '../AIComposerToolbar'; -import { installContentEditableApi } from '../../lib/contentEditableComposer'; const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { const { styleOverrides, classNames } = useComponentOverrides('ChatInput'); @@ -51,7 +51,6 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { const aiAdapter = ECOptions?.aiAdapter ?? null; const { theme } = useTheme(); const styles = getChatInputStyles(theme); - const draftHost = RCInstance.getHost(); const inputRef = useRef(null); const typingRef = useRef(); @@ -156,6 +155,10 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { })); const userInfo = { _id: userId, username, name }; + const draftHost = RCInstance?.getHost + ? RCInstance.getHost() + : ECOptions?.host; + const currentRoomId = ECOptions?.roomId || channelInfo?._id; const dispatchToastMessage = useToastBarDispatch(); const showCommands = useShowCommands( @@ -210,36 +213,64 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { messageRef.current.value = editMessage.attachments[0]?.description || editMessage.msg; messageRef.current.focus(); + setIsDraftRestored(false); } else if (editMessage.msg) { messageRef.current.value = editMessage.msg; messageRef.current.focus(); + setIsDraftRestored(false); } else { - const draft = getDraftMessage( - ECOptions.roomId, - threadId, - userId, - draftHost - ); + const draft = getDraftMessage(currentRoomId, threadId, userId, draftHost); messageRef.current.value = draft || ''; setDisableButton(!draft); setIsDraftRestored(!!draft); } - }, [editMessage]); + }, [editMessage, currentRoomId, threadId, userId, draftHost]); useEffect(() => { if (!messageRef.current || editMessage.msg || editMessage.attachments) { return; } - const draft = getDraftMessage( - ECOptions.roomId, - threadId, - userId, - draftHost - ); + const draft = getDraftMessage(currentRoomId, threadId, userId, draftHost); messageRef.current.value = draft || ''; setDisableButton(!draft); setIsDraftRestored(!!draft); - }, [ECOptions.roomId, threadId]); + }, [currentRoomId, threadId, userId, draftHost]); + + useEffect(() => { + const flushDraft = () => { + clearTimeout(draftTimerRef.current); + if ( + messageRef.current && + !(editMessage.msg || editMessage.attachments) && + currentRoomId + ) { + saveDraftMessage( + currentRoomId, + threadId, + userId, + draftHost, + messageRef.current.value + ); + } + }; + + const handleBeforeUnload = () => { + flushDraft(); + }; + + if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', handleBeforeUnload); + window.addEventListener('pagehide', handleBeforeUnload); + } + + return () => { + flushDraft(); + if (typeof window !== 'undefined') { + window.removeEventListener('beforeunload', handleBeforeUnload); + window.removeEventListener('pagehide', handleBeforeUnload); + } + }; + }, [currentRoomId, threadId, userId, draftHost, editMessage]); useEffect(() => { if ( @@ -289,7 +320,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { const message = messageRef.current.value.trim(); messageRef.current.value = ''; clearTimeout(draftTimerRef.current); - deleteDraftMessage(ECOptions.roomId, threadId, userId, draftHost); + deleteDraftMessage(currentRoomId, threadId, userId, draftHost); setIsDraftRestored(false); setEditMessage({}); setIsMsgLong(false); @@ -356,7 +387,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { messageRef.current.value = ''; setDisableButton(true); clearTimeout(draftTimerRef.current); - deleteDraftMessage(ECOptions.roomId, threadId, userId, draftHost); + deleteDraftMessage(currentRoomId, threadId, userId, draftHost); setIsDraftRestored(false); let pendingMessage = ''; @@ -441,7 +472,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { messageRef.current.value = ''; setDisableButton(true); clearTimeout(draftTimerRef.current); - deleteDraftMessage(ECOptions.roomId, threadId, userId, draftHost); + deleteDraftMessage(currentRoomId, threadId, userId, draftHost); setIsDraftRestored(false); setEditMessage({}); await execCommand(command.replace('/', ''), params); @@ -586,19 +617,12 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { const draftText = messageRef.current?.value ?? ''; clearTimeout(draftTimerRef.current); draftTimerRef.current = setTimeout(() => { - saveDraftMessage( - ECOptions.roomId, - threadId, - userId, - draftHost, - draftText - ); + saveDraftMessage(currentRoomId, threadId, userId, draftHost, draftText); }, 300); - if (!draftText.length) { + if (!draftText.trim().length) { setIsDraftRestored(false); } } - if (e !== null) { handleNewLine(e, false); searchMentionUser(parsedMessage); @@ -675,6 +699,13 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { messageRef.current.value = ''; setDisableButton(true); setEditMessage({}); + } else if (isDraftRestored) { + e.preventDefault(); + messageRef.current.value = ''; + setDisableButton(true); + clearTimeout(draftTimerRef.current); + deleteDraftMessage(currentRoomId, threadId, userId, draftHost); + setIsDraftRestored(false); } break; @@ -777,7 +808,11 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { {isDraftRestored && !(editMessage.msg || editMessage.attachments) && !isChannelReadOnly ? ( - + ) : null}