diff --git a/packages/api/src/EmbeddedChatApi.ts b/packages/api/src/EmbeddedChatApi.ts index 6f2408414e..5bdf316ab7 100644 --- a/packages/api/src/EmbeddedChatApi.ts +++ b/packages/api/src/EmbeddedChatApi.ts @@ -14,8 +14,14 @@ export default class EmbeddedChatApi { rid: string; sdk: DDPSDK; onMessageCallbacks: ((message: any) => void)[]; - onMessageDeleteCallbacks: ((messageId: string) => void)[]; + onMessageDeleteCallbacks: ((messageId: string, threadId?: string) => void)[]; + onThreadMessageCallbacks: ((message: any) => void)[]; + onThreadMessageDeleteCallbacks: (( + messageId: string, + threadId?: string + ) => void)[]; onTypingStatusCallbacks: ((users: string[]) => void)[]; + onActionTriggeredCallbacks: ((data: any) => void)[]; onUiInteractionCallbacks: ((data: any) => void)[]; typingUsers: string[]; @@ -37,7 +43,10 @@ export default class EmbeddedChatApi { }); this.onMessageCallbacks = []; this.onMessageDeleteCallbacks = []; + this.onThreadMessageCallbacks = []; + this.onThreadMessageDeleteCallbacks = []; this.onTypingStatusCallbacks = []; + this.typingUsers = []; this.onActionTriggeredCallbacks = []; this.onUiInteractionCallbacks = []; @@ -186,7 +195,7 @@ export default class EmbeddedChatApi { await this.updateUserUsername(data.userId, data.me.name); } return { status: "success", me: data.me }; - } catch (error) { + } catch (error: any) { if (error instanceof ApiError && error.response?.status === 401) { const authErrorRes = await error.response.json(); return { error: authErrorRes?.error }; @@ -235,8 +244,8 @@ export default class EmbeddedChatApi { } /** - * All subscriptions are implemented here. - * TODO: Add logic to call thread message event listeners. To be done after thread implementation + * Initializes all realtime subscriptions (room messages, thread messages, + * typing notifications, delete events, and user interactions). */ async connect(isChannelPrivate = false) { // Guard against concurrent connect() calls (e.g. React StrictMode double-invoke) @@ -285,7 +294,7 @@ export default class EmbeddedChatApi { await this.joinRoom(isChannelPrivate); } - // Subscribe to room messages + // Subscribe to room messages and thread replies const roomMsgSub = this.sdk.stream( "room-messages", [this.rid, false], @@ -293,6 +302,11 @@ export default class EmbeddedChatApi { const message = this._normalizeMessage(data); if (message) { this.onMessageCallbacks.forEach((callback) => callback(message)); + if (message.tmid) { + this.onThreadMessageCallbacks.forEach((callback) => + callback(message) + ); + } } } ); @@ -321,11 +335,17 @@ export default class EmbeddedChatApi { "notify-room", [`${this.rid}/deleteMessage`, false], (...args: any[]) => { - const messageId = args[0]?._id; + const messageData = args[0]; + const messageId = messageData?._id; if (messageId) { this.onMessageDeleteCallbacks.forEach((callback) => - callback(messageId) + callback(messageId, messageData?.tmid) ); + if (messageData?.tmid) { + this.onThreadMessageDeleteCallbacks.forEach((callback) => + callback(messageId, messageData.tmid) + ); + } } } ); @@ -379,7 +399,9 @@ export default class EmbeddedChatApi { ); } - async addMessageDeleteListener(callback: (messageId: string) => void) { + async addMessageDeleteListener( + callback: (messageId: string, threadId?: string) => void + ) { const idx = this.onMessageDeleteCallbacks.findIndex((c) => c === callback); if (idx !== -1) { this.onMessageDeleteCallbacks[idx] = callback; @@ -388,12 +410,49 @@ export default class EmbeddedChatApi { } } - async removeMessageDeleteListener(callback: (messageId: string) => void) { + async removeMessageDeleteListener( + callback: (messageId: string, threadId?: string) => void + ) { this.onMessageDeleteCallbacks = this.onMessageDeleteCallbacks.filter( (c) => c !== callback ); } + async addThreadMessageListener(callback: (message: any) => void) { + const idx = this.onThreadMessageCallbacks.findIndex((c) => c === callback); + if (idx !== -1) { + this.onThreadMessageCallbacks[idx] = callback; + } else { + this.onThreadMessageCallbacks.push(callback); + } + } + + async removeThreadMessageListener(callback: (message: any) => void) { + this.onThreadMessageCallbacks = this.onThreadMessageCallbacks.filter( + (c) => c !== callback + ); + } + + async addThreadMessageDeleteListener( + callback: (messageId: string, threadId?: string) => void + ) { + const idx = this.onThreadMessageDeleteCallbacks.findIndex( + (c) => c === callback + ); + if (idx !== -1) { + this.onThreadMessageDeleteCallbacks[idx] = callback; + } else { + this.onThreadMessageDeleteCallbacks.push(callback); + } + } + + async removeThreadMessageDeleteListener( + callback: (messageId: string, threadId?: string) => void + ) { + this.onThreadMessageDeleteCallbacks = + this.onThreadMessageDeleteCallbacks.filter((c) => c !== callback); + } + async addTypingStatusListener(callback: (users: string[]) => void) { const idx = this.onTypingStatusCallbacks.findIndex((c) => c === callback); if (idx !== -1) { diff --git a/packages/react/package.json b/packages/react/package.json index 8b6a05c941..f1db1de230 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -16,6 +16,7 @@ "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:lint": "eslint src/**/*.js", + "test": "node --test src/store/messageStore.test.mjs", "format": "prettier --write 'src/' ", "format:check": "prettier --check 'src/' ", "lint": "eslint 'src/**/*.js'", diff --git a/packages/react/src/store/messageStore.js b/packages/react/src/store/messageStore.js index 4f84f8c1f8..47897f9584 100644 --- a/packages/react/src/store/messageStore.js +++ b/packages/react/src/store/messageStore.js @@ -36,55 +36,154 @@ const useMessageStore = create((set, get) => ({ }), upsertMessage: (message, enableThreads = false) => { if (message.tmid && enableThreads) { - if (get().threadMainMessage?._id === message.tmid) { - set((state) => ({ - threadMessages: upsertMessage(state.threadMessages, message), - })); - } + const parentId = message.tmid; + const isOpenThread = get().threadMainMessage?._id === parentId; + const isExistingInThread = get().threadMessages.some( + (m) => m._id === message._id + ); + + set((state) => { + const nextState = {}; + + if (isOpenThread) { + nextState.threadMessages = upsertMessage( + state.threadMessages, + message + ); + if (state.threadMainMessage) { + nextState.threadMainMessage = { + ...state.threadMainMessage, + tcount: + (state.threadMainMessage.tcount || 0) + + (isExistingInThread ? 0 : 1), + tlm: message.ts || new Date().toISOString(), + }; + } + } + + const parentIdx = state.messages.findIndex((m) => m._id === parentId); + if (parentIdx !== -1) { + const parent = state.messages[parentIdx]; + const newReplies = parent.replies + ? message.u?._id && !parent.replies.includes(message.u._id) + ? [...parent.replies, message.u._id] + : parent.replies + : message.u?._id + ? [message.u._id] + : []; + + const updatedParent = { + ...parent, + tcount: (parent.tcount || 0) + (isExistingInThread ? 0 : 1), + tlm: message.ts || new Date().toISOString(), + replies: newReplies, + }; + + const newMessages = cloneArray(state.messages); + newMessages[parentIdx] = updatedParent; + nextState.messages = newMessages; + } + + return nextState; + }); } else { - set((state) => ({ - messages: upsertMessage(state.messages, message), - })); + set((state) => { + const isMainThreadMessage = + state.threadMainMessage?._id === message._id; + return { + messages: upsertMessage(state.messages, message), + ...(isMainThreadMessage + ? { + threadMainMessage: { + ...state.threadMainMessage, + ...message, + }, + } + : {}), + }; + }); } }, - removeMessage: (messageId) => { + removeMessage: (messageId, parentId) => { const threadMessage = get().threadMessages.find((m) => m._id === messageId); const message = get().messages.find((m) => m._id === messageId); - if (threadMessage) { + const effectiveParentId = parentId || threadMessage?.tmid; + + if (effectiveParentId) { return set((state) => ({ - deletedMessage: threadMessage, + deletedMessage: threadMessage || message || { _id: messageId }, threadMessages: cloneArray(state.threadMessages).filter( (m) => m._id !== messageId ), + threadMainMessage: + state.threadMainMessage?._id === effectiveParentId + ? { + ...state.threadMainMessage, + tcount: Math.max(0, (state.threadMainMessage.tcount || 1) - 1), + } + : state.threadMainMessage, + messages: cloneArray(state.messages) + .filter((m) => m._id !== messageId) + .map((m) => { + if (m._id !== effectiveParentId) return m; + return { + ...m, + tcount: Math.max(0, (m.tcount || 1) - 1), + }; + }), })); } - if (message) { + + if (message || get().threadMainMessage?._id === messageId) { + const shouldCloseThread = get().threadMainMessage?._id === messageId; return set((state) => ({ - deletedMessage: message, + deletedMessage: message || { _id: messageId }, messages: cloneArray(state.messages).filter((m) => m._id !== messageId), + ...(shouldCloseThread + ? { + isThreadOpen: false, + threadMainMessage: null, + threadMessages: [], + } + : {}), })); } }, replaceMessage: (oldMessageId, newMessage) => { - const threadMessage = get().threadMessages.find( - (m) => m._id === oldMessageId - ); - const message = get().messages.find((m) => m._id === oldMessageId); - if (threadMessage) { - return set((state) => ({ - threadMessages: cloneArray(state.threadMessages).map((m) => - m._id === oldMessageId ? newMessage : m - ), - })); - } - if (message) { - return set((state) => ({ - messages: cloneArray(state.messages).map((m) => - m._id === oldMessageId ? newMessage : m - ), - })); - } + set((state) => { + const isThreadMsg = state.threadMessages.some( + (m) => m._id === oldMessageId + ); + const isMainMsg = state.messages.some((m) => m._id === oldMessageId); + const isMainThreadMsg = state.threadMainMessage?._id === oldMessageId; + + return { + ...(isThreadMsg + ? { + threadMessages: cloneArray(state.threadMessages).map((m) => + m._id === oldMessageId ? newMessage : m + ), + } + : {}), + ...(isMainMsg + ? { + messages: cloneArray(state.messages).map((m) => + m._id === oldMessageId ? newMessage : m + ), + } + : {}), + ...(isMainThreadMsg + ? { + threadMainMessage: { + ...state.threadMainMessage, + ...newMessage, + }, + } + : {}), + }; + }); }, + setEditMessage: (editMessage) => set(() => ({ editMessage })), setMessagesOffset: (newOffset) => set(() => ({ messagesOffset: newOffset })), editMessagePermissions: {}, diff --git a/packages/react/src/store/messageStore.test.mjs b/packages/react/src/store/messageStore.test.mjs new file mode 100644 index 0000000000..fe9b4fe2be --- /dev/null +++ b/packages/react/src/store/messageStore.test.mjs @@ -0,0 +1,262 @@ +import { test, describe, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { register } from 'node:module'; + +// Register ESM extension resolver to support extensionless imports in node:test +register(new URL('../../tools/esm-resolver.mjs', import.meta.url)); + +const { default: useMessageStore } = await import('./messageStore.js'); + +describe('messageStore - Real-time Thread Synchronization', () => { + beforeEach(() => { + // Reset message store state before each test + useMessageStore.setState({ + messages: [], + threadMessages: [], + isThreadOpen: false, + threadMainMessage: null, + deletedMessage: {}, + }); + }); + + test('upsertMessage increments parent tcount, updates tlm, and records replies when a thread message arrives', () => { + const parent = { + _id: 'parent-1', + msg: 'Main discussion topic', + u: { _id: 'user-1', username: 'alice' }, + ts: '2026-10-27T10:00:00.000Z', + }; + useMessageStore.getState().setMessages([parent]); + + const threadReply = { + _id: 'reply-1', + tmid: 'parent-1', + msg: 'First thread reply', + u: { _id: 'user-2', username: 'bob' }, + ts: '2026-10-27T10:05:00.000Z', + }; + + useMessageStore.getState().upsertMessage(threadReply, true); + + const state = useMessageStore.getState(); + const updatedParent = state.messages.find((m) => m._id === 'parent-1'); + + assert.ok(updatedParent, 'Parent message must remain in messages list'); + assert.equal( + updatedParent.tcount, + 1, + 'Parent tcount must be incremented to 1' + ); + assert.equal( + updatedParent.tlm, + '2026-10-27T10:05:00.000Z', + 'Parent tlm must be updated to reply timestamp' + ); + assert.deepEqual( + updatedParent.replies, + ['user-2'], + 'Parent replies array must include the reply author' + ); + }); + + test('upsertMessage updates threadMessages and threadMainMessage when the thread is actively open', () => { + const parent = { + _id: 'parent-1', + msg: 'Main thread root', + u: { _id: 'user-1', username: 'alice' }, + ts: '2026-10-27T10:00:00.000Z', + tcount: 0, + }; + useMessageStore.setState({ + messages: [parent], + isThreadOpen: true, + threadMainMessage: parent, + threadMessages: [], + }); + + const threadReply = { + _id: 'reply-1', + tmid: 'parent-1', + msg: 'Active thread response', + u: { _id: 'user-2', username: 'bob' }, + ts: '2026-10-27T10:02:00.000Z', + }; + + useMessageStore.getState().upsertMessage(threadReply, true); + + const state = useMessageStore.getState(); + assert.equal( + state.threadMessages.length, + 1, + 'threadMessages should contain the new reply' + ); + assert.equal(state.threadMessages[0]._id, 'reply-1'); + assert.equal( + state.threadMainMessage.tcount, + 1, + 'threadMainMessage tcount must be updated' + ); + assert.equal(state.threadMainMessage.tlm, '2026-10-27T10:02:00.000Z'); + }); + + test('upsertMessage does not duplicate tcount when updating an existing thread reply', () => { + const parent = { + _id: 'parent-1', + msg: 'Parent msg', + tcount: 1, + replies: ['user-2'], + ts: '2026-10-27T10:00:00.000Z', + }; + const existingReply = { + _id: 'reply-1', + tmid: 'parent-1', + msg: 'Original reply text', + u: { _id: 'user-2', username: 'bob' }, + ts: '2026-10-27T10:02:00.000Z', + }; + + useMessageStore.setState({ + messages: [parent], + isThreadOpen: true, + threadMainMessage: parent, + threadMessages: [existingReply], + }); + + const editedReply = { + ...existingReply, + msg: 'Edited reply text', + ts: '2026-10-27T10:03:00.000Z', + }; + + useMessageStore.getState().upsertMessage(editedReply, true); + + const state = useMessageStore.getState(); + const updatedParent = state.messages.find((m) => m._id === 'parent-1'); + assert.equal( + updatedParent.tcount, + 1, + 'tcount should not increment when editing an existing reply' + ); + assert.equal( + state.threadMainMessage.tcount, + 1, + 'threadMainMessage tcount should not increment on edit' + ); + }); + + test('removeMessage decrements parent tcount when a thread message is removed', () => { + const parent = { + _id: 'parent-1', + msg: 'Parent msg', + tcount: 2, + ts: '2026-10-27T10:00:00.000Z', + }; + const threadReply = { + _id: 'reply-1', + tmid: 'parent-1', + msg: 'Reply to be deleted', + ts: '2026-10-27T10:02:00.000Z', + }; + + useMessageStore.setState({ + messages: [parent], + isThreadOpen: true, + threadMainMessage: parent, + threadMessages: [threadReply], + }); + + // Delete thread reply by id with parentId + useMessageStore.getState().removeMessage('reply-1', 'parent-1'); + + const state = useMessageStore.getState(); + assert.equal( + state.threadMessages.length, + 0, + 'Deleted reply should be removed from threadMessages' + ); + assert.equal( + state.threadMainMessage.tcount, + 1, + 'threadMainMessage tcount should decrement' + ); + const updatedParent = state.messages.find((m) => m._id === 'parent-1'); + assert.equal( + updatedParent.tcount, + 1, + 'Parent in messages should decrement tcount' + ); + }); + + test('removeMessage closes active thread view when the parent message is deleted', () => { + const parent = { + _id: 'parent-1', + msg: 'Parent msg to be deleted', + ts: '2026-10-27T10:00:00.000Z', + }; + + useMessageStore.setState({ + messages: [parent], + isThreadOpen: true, + threadMainMessage: parent, + threadMessages: [ + { _id: 'reply-1', tmid: 'parent-1', msg: 'Orphaned reply' }, + ], + }); + + useMessageStore.getState().removeMessage('parent-1'); + + const state = useMessageStore.getState(); + assert.equal( + state.messages.length, + 0, + 'Parent must be removed from messages' + ); + assert.equal( + state.isThreadOpen, + false, + 'isThreadOpen must be closed when parent is deleted' + ); + assert.equal( + state.threadMainMessage, + null, + 'threadMainMessage must be reset to null' + ); + assert.equal( + state.threadMessages.length, + 0, + 'threadMessages must be cleared' + ); + }); + + test('replaceMessage updates message across messages and threadMainMessage', () => { + const parent = { + _id: 'parent-1', + msg: 'Original parent content', + ts: '2026-10-27T10:00:00.000Z', + }; + + useMessageStore.setState({ + messages: [parent], + isThreadOpen: true, + threadMainMessage: parent, + threadMessages: [], + }); + + const updatedParent = { + ...parent, + msg: 'Updated parent content via replaceMessage', + }; + + useMessageStore.getState().replaceMessage('parent-1', updatedParent); + + const state = useMessageStore.getState(); + assert.equal( + state.messages[0].msg, + 'Updated parent content via replaceMessage' + ); + assert.equal( + state.threadMainMessage.msg, + 'Updated parent content via replaceMessage' + ); + }); +}); diff --git a/packages/react/src/views/ChatBody/ChatBody.js b/packages/react/src/views/ChatBody/ChatBody.js index 76c5bfae6f..b4391e795f 100644 --- a/packages/react/src/views/ChatBody/ChatBody.js +++ b/packages/react/src/views/ChatBody/ChatBody.js @@ -134,7 +134,12 @@ const ChatBody = ({ (message) => { if (message.u.username !== username) { const isScrolledUp = messageListRef?.current?.scrollTop !== 0; - if (isScrolledUp && !('pinned' in message) && !('starred' in message)) { + if ( + isScrolledUp && + !message.tmid && + !('pinned' in message) && + !('starred' in message) + ) { setOtherUserMessage(true); // Track the first unread message (only set if not already tracking) if (!pendingFirstUnreadRef.current) { diff --git a/packages/react/tools/esm-resolver.mjs b/packages/react/tools/esm-resolver.mjs new file mode 100644 index 0000000000..f17bc7b2c8 --- /dev/null +++ b/packages/react/tools/esm-resolver.mjs @@ -0,0 +1,21 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +export async function resolve(specifier, context, nextResolve) { + try { + return await nextResolve(specifier, context); + } catch (err) { + if (err.code === 'ERR_MODULE_NOT_FOUND' && specifier.startsWith('.')) { + const parentDir = path.dirname(fileURLToPath(context.parentURL)); + const target = path.resolve(parentDir, specifier); + if (fs.existsSync(`${target}.js`)) { + return { + url: pathToFileURL(`${target}.js`).href, + shortCircuit: true, + }; + } + } + throw err; + } +}