diff --git a/packages/api/src/EmbeddedChatApi.ts b/packages/api/src/EmbeddedChatApi.ts index 72e25a046..40be1f8e7 100644 --- a/packages/api/src/EmbeddedChatApi.ts +++ b/packages/api/src/EmbeddedChatApi.ts @@ -7,8 +7,6 @@ import { ApiError, } from "@embeddedchat/auth"; -// mutliple typing status can come at the same time they should be processed in order. -let typingHandlerLock = 0; export default class EmbeddedChatApi { host: string; rid: string; @@ -358,13 +356,7 @@ export default class EmbeddedChatApi { typingUser: string; isTyping: boolean; }) { - // don't wait for more than 2 seconds. Though in practical, the waiting time is insignificant. - setTimeout(() => { - typingHandlerLock = 0; - }, 2000); - // eslint-disable-next-line no-empty - while (typingHandlerLock) {} - typingHandlerLock = 1; + // No lock needed — JS is single-threaded, so array operations are already atomic. // move user to front if typing else remove it. const idx = this.typingUsers.indexOf(typingUser); if (idx !== -1) { @@ -373,7 +365,6 @@ export default class EmbeddedChatApi { if (isTyping) { this.typingUsers.unshift(typingUser); } - typingHandlerLock = 0; const newTypingStatus = cloneArray(this.typingUsers); this.onTypingStatusCallbacks.forEach((callback) => callback(newTypingStatus) @@ -1065,17 +1056,21 @@ export default class EmbeddedChatApi { "description", fileDescription.length !== 0 ? fileDescription : "" ); - const response = fetch(`${this.host}/api/v1/rooms.upload/${this.rid}`, { + const response = await fetch(`${this.host}/api/v1/rooms.upload/${this.rid}`, { method: "POST", body: form, headers: { "X-Auth-Token": authToken, "X-User-Id": userId, }, - }).then((r) => r.json()); - return response; + }); + if (!response.ok) { + throw new Error(`Upload failed: ${response.status} ${response.statusText}`); + } + return await response.json(); } catch (err) { - console.log(err); + console.error(err); + throw err; } } @@ -1243,7 +1238,10 @@ export default class EmbeddedChatApi { }, } ); - const data = response.json(); + if (!response.ok) { + throw new Error(`getUserStatus failed: ${response.status} ${response.statusText}`); + } + const data = await response.json(); return data; } @@ -1260,7 +1258,10 @@ export default class EmbeddedChatApi { }, } ); - const data = response.json(); + if (!response.ok) { + throw new Error(`userInfo failed: ${response.status} ${response.statusText}`); + } + const data = await response.json(); return data; } @@ -1277,7 +1278,10 @@ export default class EmbeddedChatApi { }, } ); - const data = response.json(); + if (!response.ok) { + throw new Error(`userData failed: ${response.status} ${response.statusText}`); + } + const data = await response.json(); return data; } } diff --git a/packages/react/babel.config.js b/packages/react/babel.config.js index 5ccad93f9..a09c9448a 100644 --- a/packages/react/babel.config.js +++ b/packages/react/babel.config.js @@ -1,14 +1,18 @@ +const isTest = process.env.NODE_ENV === 'test'; + module.exports = { presets: [ [ '@babel/preset-env', - { - modules: false, - bugfixes: true, - targets: { browsers: '> 0.25%, ie 11, not op_mini all, not dead' }, - }, + isTest + ? { targets: { node: 'current' } } + : { + modules: false, + bugfixes: true, + targets: { browsers: '> 0.25%, ie 11, not op_mini all, not dead' }, + }, ], '@babel/preset-react', - '@emotion/babel-preset-css-prop', + ...(isTest ? [] : ['@emotion/babel-preset-css-prop']), ], }; diff --git a/packages/react/jest.config.js b/packages/react/jest.config.js new file mode 100644 index 000000000..609860857 --- /dev/null +++ b/packages/react/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + testEnvironment: 'jsdom', + // Allow Jest to transform ESM packages that ship /dist/esm/ builds + transformIgnorePatterns: [ + '/node_modules/(?!(react-syntax-highlighter)/)', + ], + moduleNameMapper: { + // Silence CSS/asset imports that aren't relevant to unit tests + '\\.(css|scss|sass)$': 'identity-obj-proxy', + }, +}; diff --git a/packages/react/src/hooks/__tests__/useChatInputState.test.js b/packages/react/src/hooks/__tests__/useChatInputState.test.js new file mode 100644 index 000000000..406c2c65d --- /dev/null +++ b/packages/react/src/hooks/__tests__/useChatInputState.test.js @@ -0,0 +1,190 @@ +import React from 'react'; +import { render, act } from '@testing-library/react'; +import useChatInputState from '../useChatInputState'; + +// --------------------------------------------------------------------------- +// Helper: renders the hook inside a minimal component and returns a live ref +// to the hook's return value. Works with @testing-library/react v12 which +// does not expose renderHook. +// +// IMPORTANT: always read `result.current` AFTER act() — never destructure +// `current` up front, because re-renders reassign `result.current` to the +// new hook return value while a destructured copy keeps pointing to the old +// object. +// --------------------------------------------------------------------------- +function renderHookShim() { + const result = { current: null }; + + const TestComponent = () => { + result.current = useChatInputState(); + return null; + }; + + render(); + return result; +} + +// --------------------------------------------------------------------------- +// 1. Initial state +// --------------------------------------------------------------------------- +describe('useChatInputState – initial state', () => { + it('starts with empty text and zero cursor position', () => { + const result = renderHookShim(); + expect(result.current.inputState.text).toBe(''); + expect(result.current.inputState.cursorPosition).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// 2. setText +// --------------------------------------------------------------------------- +describe('useChatInputState – setText', () => { + it('updates the text field', () => { + const result = renderHookShim(); + act(() => result.current.setText('hello world')); + expect(result.current.inputState.text).toBe('hello world'); + }); + + it('does not change cursor position when only text is set', () => { + const result = renderHookShim(); + act(() => result.current.setText('some text')); + expect(result.current.inputState.cursorPosition).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// 3. setCursorPosition +// --------------------------------------------------------------------------- +describe('useChatInputState – setCursorPosition', () => { + it('updates cursor position', () => { + const result = renderHookShim(); + act(() => result.current.setCursorPosition(5)); + expect(result.current.inputState.cursorPosition).toBe(5); + }); + + it('does not change text when only cursor is updated', () => { + const result = renderHookShim(); + act(() => result.current.setText('hello')); + act(() => result.current.setCursorPosition(3)); + expect(result.current.inputState.text).toBe('hello'); + expect(result.current.inputState.cursorPosition).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// 4. clearInput +// --------------------------------------------------------------------------- +describe('useChatInputState – clearInput', () => { + it('resets text and cursorPosition to initial values', () => { + const result = renderHookShim(); + act(() => result.current.setText('typing something')); + act(() => result.current.setCursorPosition(8)); + act(() => result.current.clearInput()); + expect(result.current.inputState.text).toBe(''); + expect(result.current.inputState.cursorPosition).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// 5. getFinalMarkdown – the core bug fix +// +// The old code mutated `quotedMessages` inside Promise.all.map() and then +// joined the cumulative array, producing duplicated link prefixes for every +// quote after the first. These tests verify the new behaviour is correct. +// --------------------------------------------------------------------------- +describe('useChatInputState – getFinalMarkdown', () => { + const makeLinkFn = (map) => (id) => Promise.resolve(map[id]); + + it('returns plain text when there are no quotes', async () => { + const hookRef = renderHookShim(); + const result = await hookRef.current.getFinalMarkdown('hello', [], makeLinkFn({})); + expect(result).toBe('hello'); + }); + + it('returns plain text when quotes array is null/undefined', async () => { + const hookRef = renderHookShim(); + const result = await hookRef.current.getFinalMarkdown( + 'hello', + null, + makeLinkFn({}) + ); + expect(result).toBe('hello'); + }); + + it('prepends a single quote link separated by a newline', async () => { + const hookRef = renderHookShim(); + const quotes = [{ _id: 'msg1', msg: 'original message', attachments: undefined }]; + const linkMap = { msg1: 'https://host/channel/general/?msg=msg1' }; + + const result = await hookRef.current.getFinalMarkdown( + 'my reply', + quotes, + makeLinkFn(linkMap) + ); + + expect(result).toBe( + '[ ](https://host/channel/general/?msg=msg1)\nmy reply' + ); + }); + + it('prepends multiple quote links WITHOUT duplication (old bug regression)', async () => { + // OLD behaviour: link1 was emitted twice → "[ ](link1)[ ](link1)[ ](link2)\ntext" + // NEW behaviour: each link appears exactly once → "[ ](link1)[ ](link2)\ntext" + const hookRef = renderHookShim(); + const quotes = [ + { _id: 'msg1', msg: 'first quoted', attachments: undefined }, + { _id: 'msg2', msg: 'second quoted', attachments: undefined }, + ]; + const linkMap = { + msg1: 'https://host/channel/general/?msg=msg1', + msg2: 'https://host/channel/general/?msg=msg2', + }; + + const result = await hookRef.current.getFinalMarkdown( + 'my reply', + quotes, + makeLinkFn(linkMap) + ); + + expect(result).toBe( + '[ ](https://host/channel/general/?msg=msg1)' + + '[ ](https://host/channel/general/?msg=msg2)' + + '\nmy reply' + ); + // Ensure first link does NOT appear twice (the old bug) + const occurrences = (result.match(/msg1/g) || []).length; + expect(occurrences).toBe(1); + }); + + it('skips quotes that have neither msg nor attachments', async () => { + const hookRef = renderHookShim(); + const quotes = [ + { _id: 'msg1', msg: undefined, attachments: undefined }, + { _id: 'msg2', msg: 'valid message', attachments: undefined }, + ]; + const linkMap = { msg2: 'https://host/channel/general/?msg=msg2' }; + + const result = await hookRef.current.getFinalMarkdown( + 'reply', + quotes, + makeLinkFn(linkMap) + ); + + // Only msg2 link should appear; msg1 had no content so it's skipped + expect(result).toBe('[ ](https://host/channel/general/?msg=msg2)\nreply'); + expect(result).not.toContain('msg1'); + }); + + it('returns plain text if all quotes have no msg or attachments', async () => { + const hookRef = renderHookShim(); + const quotes = [{ _id: 'msg1', msg: undefined, attachments: undefined }]; + + const result = await hookRef.current.getFinalMarkdown( + 'just text', + quotes, + makeLinkFn({}) + ); + + expect(result).toBe('just text'); + }); +}); diff --git a/packages/react/src/hooks/useChatInputState.js b/packages/react/src/hooks/useChatInputState.js new file mode 100644 index 000000000..2110ec534 --- /dev/null +++ b/packages/react/src/hooks/useChatInputState.js @@ -0,0 +1,91 @@ +import { useReducer, useCallback } from 'react'; + +const initialState = { + text: '', + cursorPosition: 0, +}; + +function reducer(state, action) { + switch (action.type) { + case 'SET_TEXT': + return { ...state, text: action.payload }; + case 'SET_CURSOR': + return { ...state, cursorPosition: action.payload }; + case 'CLEAR': + return initialState; + default: + return state; + } +} + +/** + * useChatInputState + * + * Manages the ChatInput's text and cursor position as structured state + * instead of reading them directly from the DOM ref on every access. + * + * Key benefit: quote links are assembled at send-time via getFinalMarkdown(), + * so they are never injected into the textarea value — preventing accidental + * corruption while the user edits the message. + */ +const useChatInputState = () => { + const [inputState, dispatch] = useReducer(reducer, initialState); + + const setText = useCallback( + (text) => dispatch({ type: 'SET_TEXT', payload: text }), + [] + ); + + const setCursorPosition = useCallback( + (pos) => dispatch({ type: 'SET_CURSOR', payload: pos }), + [] + ); + + const clearInput = useCallback(() => dispatch({ type: 'CLEAR' }), []); + + /** + * Builds the final markdown string to send. + * + * Quote links are resolved here, at send-time only, so the textarea always + * contains just the user's plain text. This avoids the two bugs present in + * the previous string-manipulation approach: + * 1. Accumulated duplicates from mutating `quotedMessages` inside .map() + * and then joining the cumulative array values. + * 2. Hidden markdown links being silently broken by cursor movement or + * editing inside the textarea. + * + * @param {string} text - The trimmed message text from the textarea + * @param {Array} quotes - Quote objects from the message store + * @param {Function} getMessageLink - Async fn: (id) => permalink string + * @returns {Promise} Final markdown string ready to send + */ + const getFinalMarkdown = useCallback( + async (text, quotes, getMessageLink) => { + if (!quotes || quotes.length === 0) return text; + + const quoteLinks = await Promise.all( + quotes.map(async ({ _id, msg, attachments }) => { + if (msg || attachments) { + const link = await getMessageLink(_id); + return `[ ](${link})`; + } + return ''; + }) + ); + + const quotePart = quoteLinks.filter(Boolean).join(''); + return quotePart ? `${quotePart}\n${text}` : text; + }, + [] + ); + + return { + inputState, + setText, + setCursorPosition, + clearInput, + getFinalMarkdown, + }; +}; + +export default useChatInputState; diff --git a/packages/react/src/views/AttachmentPreview/AttachmentPreview.js b/packages/react/src/views/AttachmentPreview/AttachmentPreview.js index 01123dafb..03d2be53c 100644 --- a/packages/react/src/views/AttachmentPreview/AttachmentPreview.js +++ b/packages/react/src/views/AttachmentPreview/AttachmentPreview.js @@ -54,15 +54,18 @@ const AttachmentPreview = () => { const submit = async () => { setIsPending(true); - await RCInstance.sendAttachment( - data, - fileName, - messageRef.current.value, - ECOptions?.enableThreads ? threadId : undefined - ); - toggle(); - setData(null); - if (isPending) { + try { + await RCInstance.sendAttachment( + data, + fileName, + messageRef.current.value, + ECOptions?.enableThreads ? threadId : undefined + ); + toggle(); + setData(null); + } catch (err) { + console.error('Attachment upload failed:', err); + } finally { setIsPending(false); } }; diff --git a/packages/react/src/views/ChatInput/ChatInput.js b/packages/react/src/views/ChatInput/ChatInput.js index e753b689a..638ba6fec 100644 --- a/packages/react/src/views/ChatInput/ChatInput.js +++ b/packages/react/src/views/ChatInput/ChatInput.js @@ -32,6 +32,7 @@ import QuoteMessage from '../QuoteMessage/QuoteMessage'; import { getChatInputStyles } from './ChatInput.styles'; import useShowCommands from '../../hooks/useShowCommands'; import useSearchMentionUser from '../../hooks/useSearchMentionUser'; +import useChatInputState from '../../hooks/useChatInputState'; import formatSelection from '../../lib/formatSelection'; import { parseEmoji } from '../../lib/emoji'; @@ -127,6 +128,9 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { const userInfo = { _id: userId, username, name }; + const { setText, setCursorPosition, clearInput, getFinalMarkdown } = + useChatInputState(); + const dispatchToastMessage = useToastBarDispatch(); const showCommands = useShowCommands( commands, @@ -161,14 +165,18 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { useEffect(() => { if (editMessage.attachments) { - messageRef.current.value = + const editText = editMessage.attachments[0]?.description || editMessage.msg; + messageRef.current.value = editText; + setText(editText); messageRef.current.focus(); } else if (editMessage.msg) { messageRef.current.value = editMessage.msg; + setText(editMessage.msg); messageRef.current.focus(); } else { messageRef.current.value = ''; + clearInput(); } }, [editMessage]); @@ -179,6 +187,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { deletedMessage._id === editMessage._id ) { messageRef.current.value = ''; + clearInput(); setDisableButton(true); setEditMessage({}); } @@ -213,6 +222,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { const textToAttach = () => { const message = messageRef.current.value.trim(); messageRef.current.value = ''; + clearInput(); setEditMessage({}); setIsMsgLong(false); const messageBlob = new Blob([message], { type: 'text/plain' }); @@ -285,37 +295,17 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { const handleSendNewMessage = async (message) => { messageRef.current.value = ''; setDisableButton(true); - - let pendingMessage = ''; - let quotedMessages = ''; - - if (quoteMessage.length > 0) { - // for (const quote of quoteMessage) { - // const { msg, attachments, _id } = quote; - // if (msg || attachments) { - // const msgLink = await getMessageLink(_id); - // quotedMessages += `[ ](${msgLink})`; - // } - // } - - const quoteArray = await Promise.all( - quoteMessage.map(async (quote) => { - const { msg, attachments, _id } = quote; - if (msg || attachments) { - const msgLink = await getMessageLink(_id); - quotedMessages += `[ ](${msgLink})`; - } - return quotedMessages; - }) - ); - quotedMessages = quoteArray.join(''); - pendingMessage = createPendingMessage( - `${quotedMessages}\n${message}`, - userInfo - ); - } else { - pendingMessage = createPendingMessage(message, userInfo); - } + clearInput(); + + // getFinalMarkdown resolves quote links at send-time only, keeping the + // textarea text clean and avoiding the previous accumulation bug where + // mutating quotedMessages inside .map() produced duplicate link prefixes. + const finalMsg = await getFinalMarkdown( + message, + quoteMessage, + getMessageLink + ); + const pendingMessage = createPendingMessage(finalMsg, userInfo); if (ECOptions.enableThreads && threadId) { pendingMessage.tmid = threadId; @@ -339,6 +329,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { const handleEditMessage = async (message) => { messageRef.current.value = ''; + clearInput(); setDisableButton(true); const editMessageId = editMessage._id; setEditMessage({}); @@ -363,6 +354,7 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { if (commands.find((c) => c.command === command.replace('/', ''))) { messageRef.current.value = ''; + clearInput(); setDisableButton(true); setEditMessage({}); await execCommand(command.replace('/', ''), params); @@ -416,7 +408,10 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { const onTextChange = (e, val) => { sendTypingStart(); const message = val || e.target.value; - messageRef.current.value = parseEmoji(message); + const parsed = parseEmoji(message); + messageRef.current.value = parsed; + setText(parsed); + if (e?.target) setCursorPosition(e.target.selectionStart ?? 0); setDisableButton(!messageRef.current.value.length); if (e !== null) { handleNewLine(e, false);