diff --git a/packages/api/src/EmbeddedChatApi.test.ts b/packages/api/src/EmbeddedChatApi.test.ts new file mode 100644 index 000000000..05b6a2cfe --- /dev/null +++ b/packages/api/src/EmbeddedChatApi.test.ts @@ -0,0 +1,56 @@ +import { describe, test, expect, jest, beforeEach } from '@jest/globals'; +import EmbeddedChatApi from './EmbeddedChatApi'; + +// Mocks for external dependencies to isolate the unit test +jest.mock('@rocket.chat/sdk', () => ({ + Rocketchat: class MockRocketChat { + connect() { } + resume() { } + subscribeRoom() { } + onMessage() { } + onStreamData() { } + subscribeNotifyUser() { } + unsubscribeAll() { } + disconnect() { } + } +})); + +jest.mock('@embeddedchat/auth', () => ({ + RocketChatAuth: class MockAuth { + constructor() { } + }, + IRocketChatAuthOptions: {} +})); + +describe('EmbeddedChatApi Typing Handler', () => { + let api: any; + + beforeEach(() => { + api = new EmbeddedChatApi('http://localhost:3000', 'GENERAL', {} as any); + }); + + test('FIFO Async Queue: updates typing status sequentially without blocking', async () => { + const statuses: string[][] = []; + api.addTypingStatusListener((users: string[]) => { + statuses.push([...users]); + }); + + // Simulate rapid firing of events + api.handleTypingEvent({ typingUser: 'user1', isTyping: true }); + api.handleTypingEvent({ typingUser: 'user2', isTyping: true }); + api.handleTypingEvent({ typingUser: 'user1', isTyping: false }); + + // Deterministic wait for the queue to drain + await api.typingChain; + + // Verify sequential updates (FIFO) order + expect(statuses).toEqual([ + ['user1'], // user1 starts + ['user2', 'user1'], // user2 starts (unshifted to front) + ['user2'], // user1 stops + ]); + + // Verify final state + expect(api.typingUsers).toEqual(['user2']); + }); +}); diff --git a/packages/api/src/EmbeddedChatApi.ts b/packages/api/src/EmbeddedChatApi.ts index 72e25a046..1a7c707a2 100644 --- a/packages/api/src/EmbeddedChatApi.ts +++ b/packages/api/src/EmbeddedChatApi.ts @@ -7,8 +7,7 @@ import { ApiError, } from "@embeddedchat/auth"; -// mutliple typing status can come at the same time they should be processed in order. -let typingHandlerLock = 0; +// multiple typing status can come at the same time they should be processed in order. export default class EmbeddedChatApi { host: string; rid: string; @@ -20,6 +19,7 @@ export default class EmbeddedChatApi { onUiInteractionCallbacks: ((data: any) => void)[]; typingUsers: string[]; auth: RocketChatAuth; + typingChain: Promise = Promise.resolve(); constructor( host: string, @@ -73,21 +73,21 @@ export default class EmbeddedChatApi { const payload = acsCode ? JSON.stringify({ - serviceName: "google", - accessToken: tokens.access_token, - idToken: tokens.id_token, - expiresIn: 3600, - totp: { - code: acsPayload, - }, - }) + serviceName: "google", + accessToken: tokens.access_token, + idToken: tokens.id_token, + expiresIn: 3600, + totp: { + code: acsPayload, + }, + }) : JSON.stringify({ - serviceName: "google", - accessToken: tokens.access_token, - idToken: tokens.id_token, - expiresIn: 3600, - scope: "profile", - }); + serviceName: "google", + accessToken: tokens.access_token, + idToken: tokens.id_token, + expiresIn: 3600, + scope: "profile", + }); try { const req = await fetch(`${this.host}/api/v1/login`, { @@ -358,26 +358,26 @@ 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; - // move user to front if typing else remove it. - const idx = this.typingUsers.indexOf(typingUser); - if (idx !== -1) { - this.typingUsers.splice(idx, 1); - } - if (isTyping) { - this.typingUsers.unshift(typingUser); - } - typingHandlerLock = 0; - const newTypingStatus = cloneArray(this.typingUsers); - this.onTypingStatusCallbacks.forEach((callback) => - callback(newTypingStatus) - ); + // Implemented a FIFO async queue to process typing events sequentially without blocking + this.typingChain = this.typingChain.then(() => { + try { + // move user to front if typing else remove it. + const idx = this.typingUsers.indexOf(typingUser); + if (idx !== -1) { + this.typingUsers.splice(idx, 1); + } + if (isTyping) { + this.typingUsers.unshift(typingUser); + } + const newTypingStatus = cloneArray(this.typingUsers); + this.onTypingStatusCallbacks.forEach((callback) => + callback(newTypingStatus) + ); + } catch (error) { + // Suppress errors to keep the queue alive; relying on internal state consistency + } + return; + }); } async getRCAppInfo() { @@ -561,9 +561,9 @@ export default class EmbeddedChatApi { query?: object | undefined; field?: object | undefined; } = { - query: undefined, - field: undefined, - }, + query: undefined, + field: undefined, + }, isChannelPrivate = false ) { const roomType = isChannelPrivate ? "groups" : "channels"; @@ -600,10 +600,10 @@ export default class EmbeddedChatApi { field?: object | undefined; offset?: number; } = { - query: undefined, - field: undefined, - offset: 50, - }, + query: undefined, + field: undefined, + offset: 50, + }, isChannelPrivate = false ) { const roomType = isChannelPrivate ? "groups" : "channels"; @@ -751,13 +751,13 @@ export default class EmbeddedChatApi { const messageObj = typeof message === "string" ? { - rid: this.rid, - msg: message, - } + rid: this.rid, + msg: message, + } : { - ...message, - rid: this.rid, - }; + ...message, + rid: this.rid, + }; if (threadId) { messageObj.tmid = threadId; } @@ -1280,4 +1280,4 @@ export default class EmbeddedChatApi { const data = response.json(); return data; } -} +} \ No newline at end of file