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
30 changes: 22 additions & 8 deletions packages/react/src/lib/draftMessage.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,44 @@
const getDraftKey = (roomId, threadId, userId, host) => {
export 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) {
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
}
}
135 changes: 135 additions & 0 deletions packages/react/src/lib/draftMessage.test.js
Original file line number Diff line number Diff line change
@@ -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
);
});
99 changes: 67 additions & 32 deletions packages/react/src/views/ChatInput/ChatInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,23 +35,22 @@ 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');
const { RCInstance, ECOptions } = useRCContext();
const aiAdapter = ECOptions?.aiAdapter ?? null;
const { theme } = useTheme();
const styles = getChatInputStyles(theme);
const draftHost = RCInstance.getHost();

const inputRef = useRef(null);
const typingRef = useRef();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 = '';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -777,7 +808,11 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => {
{isDraftRestored &&
!(editMessage.msg || editMessage.attachments) &&
!isChannelReadOnly ? (
<ChannelState status="Draft" iconName="edit" />
<ChannelState
status="Draft"
iconName="edit"
instructions="esc or clear to discard"
/>
) : null}
<Box
css={css`
Expand Down