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}