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
56 changes: 56 additions & 0 deletions packages/api/src/EmbeddedChatApi.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
100 changes: 50 additions & 50 deletions packages/api/src/EmbeddedChatApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,6 +19,7 @@ export default class EmbeddedChatApi {
onUiInteractionCallbacks: ((data: any) => void)[];
typingUsers: string[];
auth: RocketChatAuth;
typingChain: Promise<void> = Promise.resolve();

constructor(
host: string,
Expand Down Expand Up @@ -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`, {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -1280,4 +1280,4 @@ export default class EmbeddedChatApi {
const data = response.json();
return data;
}
}
}