diff --git a/packages/react/package.json b/packages/react/package.json index 8b6a05c94..52ee381ba 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -15,6 +15,7 @@ "prebuild": "rimraf dist", "build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 rollup -c --context=window --environment NODE_ENV:production", "watch": "cross-env NODE_OPTIONS=--max-old-space-size=4096 rollup -c --watch --context=window", + "test": "node --test src/lib/*.test.mjs", "test:lint": "eslint src/**/*.js", "format": "prettier --write 'src/' ", "format:check": "prettier --check 'src/' ", diff --git a/packages/react/src/lib/draftMessage.js b/packages/react/src/lib/draftMessage.js index 24a5bf2ad..36313fb43 100644 --- a/packages/react/src/lib/draftMessage.js +++ b/packages/react/src/lib/draftMessage.js @@ -1,3 +1,5 @@ +const inMemoryDrafts = new Map(); + const getDraftKey = (roomId, threadId, userId, host) => { const scope = `${host || ''}_${userId || ''}`; return threadId @@ -5,26 +7,82 @@ const getDraftKey = (roomId, threadId, userId, host) => { : `ec_draft_${scope}_${roomId}`; }; -export function getDraftMessage(roomId, threadId, userId, host) { - if (typeof localStorage !== 'undefined' && roomId) { - return localStorage.getItem(getDraftKey(roomId, threadId, userId, host)); +const getStorage = () => { + try { + if (typeof window !== 'undefined' && window.localStorage) { + return window.localStorage; + } + } catch (e) { + // SecurityError / access denied in cross-origin iframes } return null; +}; + +export function getDraftMessage(roomId, threadId, userId, host) { + if (!roomId) { + return null; + } + const key = getDraftKey(roomId, threadId, userId, host); + const storage = getStorage(); + if (storage) { + try { + const draft = storage.getItem(key); + if (draft !== null) { + return draft; + } + } catch (e) { + // Storage access blocked or threw + } + } + return inMemoryDrafts.get(key) || null; } export function saveDraftMessage(roomId, threadId, userId, host, message) { - if (typeof localStorage === 'undefined' || !roomId) { + if (!roomId) { return; } - if (message && message.trim().length) { - localStorage.setItem(getDraftKey(roomId, threadId, userId, host), message); + const key = getDraftKey(roomId, threadId, userId, host); + const hasText = message && message.trim().length > 0; + + if (hasText) { + inMemoryDrafts.set(key, message); + const storage = getStorage(); + if (storage) { + try { + storage.setItem(key, message); + } catch (e) { + // QuotaExceededError or restricted access; inMemoryDrafts preserves state + } + } } else { - localStorage.removeItem(getDraftKey(roomId, threadId, userId, host)); + inMemoryDrafts.delete(key); + const storage = getStorage(); + if (storage) { + try { + storage.removeItem(key); + } catch (e) { + // Ignore removal error + } + } } } export function deleteDraftMessage(roomId, threadId, userId, host) { - if (typeof localStorage !== 'undefined' && roomId) { - localStorage.removeItem(getDraftKey(roomId, threadId, userId, host)); + if (!roomId) { + return; } + const key = getDraftKey(roomId, threadId, userId, host); + inMemoryDrafts.delete(key); + const storage = getStorage(); + if (storage) { + try { + storage.removeItem(key); + } catch (e) { + // Ignore removal error + } + } +} + +export function clearInMemoryDrafts() { + inMemoryDrafts.clear(); } diff --git a/packages/react/src/lib/draftMessage.test.mjs b/packages/react/src/lib/draftMessage.test.mjs new file mode 100644 index 000000000..1f80fa1b7 --- /dev/null +++ b/packages/react/src/lib/draftMessage.test.mjs @@ -0,0 +1,211 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + getDraftMessage, + saveDraftMessage, + deleteDraftMessage, + clearInMemoryDrafts, +} from './draftMessage.js'; + +test('draftMessage - basic save, get, and delete with mocked storage', async (t) => { + const store = new Map(); + global.window = { + localStorage: { + getItem: (key) => store.get(key) ?? null, + setItem: (key, val) => store.set(key, String(val)), + removeItem: (key) => store.delete(key), + }, + }; + + t.afterEach(() => { + store.clear(); + clearInMemoryDrafts(); + }); + + saveDraftMessage('room1', undefined, 'user1', 'host1', 'Hello world'); + assert.equal( + getDraftMessage('room1', undefined, 'user1', 'host1'), + 'Hello world' + ); + + // Verify deletion + deleteDraftMessage('room1', undefined, 'user1', 'host1'); + assert.equal(getDraftMessage('room1', undefined, 'user1', 'host1'), null); +}); + +test('draftMessage - scopes drafts across rooms, threads, users, and hosts', async (t) => { + const store = new Map(); + global.window = { + localStorage: { + getItem: (key) => store.get(key) ?? null, + setItem: (key, val) => store.set(key, String(val)), + removeItem: (key) => store.delete(key), + }, + }; + + t.afterEach(() => { + store.clear(); + clearInMemoryDrafts(); + }); + + saveDraftMessage('roomA', undefined, 'user1', 'host1', 'Draft in room A'); + saveDraftMessage('roomB', undefined, 'user1', 'host1', 'Draft in room B'); + saveDraftMessage('roomA', 'thread1', 'user1', 'host1', 'Draft in thread 1'); + saveDraftMessage('roomA', undefined, 'user2', 'host1', 'User 2 draft'); + saveDraftMessage('roomA', undefined, 'user1', 'host2', 'Host 2 draft'); + + assert.equal( + getDraftMessage('roomA', undefined, 'user1', 'host1'), + 'Draft in room A' + ); + assert.equal( + getDraftMessage('roomB', undefined, 'user1', 'host1'), + 'Draft in room B' + ); + assert.equal( + getDraftMessage('roomA', 'thread1', 'user1', 'host1'), + 'Draft in thread 1' + ); + assert.equal( + getDraftMessage('roomA', undefined, 'user2', 'host1'), + 'User 2 draft' + ); + assert.equal( + getDraftMessage('roomA', undefined, 'user1', 'host2'), + 'Host 2 draft' + ); +}); + +test('draftMessage - removes draft when saving empty or whitespace-only message', async (t) => { + const store = new Map(); + global.window = { + localStorage: { + getItem: (key) => store.get(key) ?? null, + setItem: (key, val) => store.set(key, String(val)), + removeItem: (key) => store.delete(key), + }, + }; + + t.afterEach(() => { + store.clear(); + clearInMemoryDrafts(); + }); + + saveDraftMessage('room1', undefined, 'user1', 'host1', 'Initial draft'); + assert.equal( + getDraftMessage('room1', undefined, 'user1', 'host1'), + 'Initial draft' + ); + + // Overwrite with empty string + saveDraftMessage('room1', undefined, 'user1', 'host1', ''); + assert.equal(getDraftMessage('room1', undefined, 'user1', 'host1'), null); + + // Overwrite with whitespace + saveDraftMessage('room1', undefined, 'user1', 'host1', 'Another draft'); + saveDraftMessage('room1', undefined, 'user1', 'host1', ' '); + assert.equal(getDraftMessage('room1', undefined, 'user1', 'host1'), null); +}); + +test('draftMessage - safely handles missing or falsy roomId', async () => { + assert.equal(getDraftMessage('', undefined, 'user1', 'host1'), null); + assert.equal(getDraftMessage(null, undefined, 'user1', 'host1'), null); + + // Does not throw + saveDraftMessage('', undefined, 'user1', 'host1', 'test'); + deleteDraftMessage('', undefined, 'user1', 'host1'); +}); + +test('draftMessage - fallback to in-memory store when localStorage throws SecurityError in iframe', async (t) => { + // Simulate sandboxed iframe where localStorage access throws SecurityError + Object.defineProperty(global, 'window', { + value: { + get localStorage() { + throw new Error( + 'SecurityError: Failed to read localStorage: Access is denied for this document.' + ); + }, + }, + writable: true, + configurable: true, + }); + + t.afterEach(() => { + clearInMemoryDrafts(); + }); + + // Saving should not throw + assert.doesNotThrow(() => { + saveDraftMessage( + 'room-iframe', + undefined, + 'user1', + 'host1', + 'Iframe draft message' + ); + }); + + // Retrieving should not throw and should return the in-memory fallback draft + assert.equal( + getDraftMessage('room-iframe', undefined, 'user1', 'host1'), + 'Iframe draft message' + ); + + // Deleting should not throw and should clear in-memory draft + assert.doesNotThrow(() => { + deleteDraftMessage('room-iframe', undefined, 'user1', 'host1'); + }); + assert.equal( + getDraftMessage('room-iframe', undefined, 'user1', 'host1'), + null + ); +}); + +test('draftMessage - fallback to in-memory store when setItem throws QuotaExceededError', async (t) => { + global.window = { + localStorage: { + getItem: () => null, + setItem: () => { + throw new Error('QuotaExceededError: DOMException quota exceeded'); + }, + removeItem: () => {}, + }, + }; + + t.afterEach(() => { + clearInMemoryDrafts(); + }); + + // Saving should not throw despite QuotaExceededError + assert.doesNotThrow(() => { + saveDraftMessage( + 'room-quota', + undefined, + 'user1', + 'host1', + 'Saved despite quota' + ); + }); + + // In-memory fallback preserves the draft for this session + assert.equal( + getDraftMessage('room-quota', undefined, 'user1', 'host1'), + 'Saved despite quota' + ); +}); + +test('draftMessage - handles headless / SSR environment without window', async (t) => { + delete global.window; + + t.afterEach(() => { + clearInMemoryDrafts(); + }); + + assert.doesNotThrow(() => { + saveDraftMessage('room-ssr', undefined, 'user1', 'host1', 'SSR draft'); + }); + assert.equal( + getDraftMessage('room-ssr', undefined, 'user1', 'host1'), + 'SSR draft' + ); +}); diff --git a/packages/react/src/views/ChatInput/ChatInput.js b/packages/react/src/views/ChatInput/ChatInput.js index 309ca918f..acb056458 100644 --- a/packages/react/src/views/ChatInput/ChatInput.js +++ b/packages/react/src/views/ChatInput/ChatInput.js @@ -51,7 +51,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { const aiAdapter = ECOptions?.aiAdapter ?? null; const { theme } = useTheme(); const styles = getChatInputStyles(theme); - const draftHost = RCInstance.getHost(); + const draftHost = RCInstance?.getHost?.() || ''; const inputRef = useRef(null); const typingRef = useRef();