Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/' ",
Expand Down
76 changes: 67 additions & 9 deletions packages/react/src/lib/draftMessage.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,88 @@
const inMemoryDrafts = new Map();

const getDraftKey = (roomId, threadId, userId, host) => {
const scope = `${host || ''}_${userId || ''}`;
return threadId
? `ec_draft_${scope}_${roomId}_${threadId}`
: `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) {
Comment thread
Diwaspant132 marked this conversation as resolved.
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();
}
211 changes: 211 additions & 0 deletions packages/react/src/lib/draftMessage.test.mjs
Original file line number Diff line number Diff line change
@@ -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'
);
});
2 changes: 1 addition & 1 deletion packages/react/src/views/ChatInput/ChatInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down