From b450f818ebeecd2ebedeb78652c7d1175ad573a2 Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Wed, 20 Aug 2025 15:28:01 -0300 Subject: [PATCH 001/197] regression(desktop-app): Removed user presence package dependency (#36740) --- .../client/definitions/IRocketChatDesktop.ts | 51 ++++++++++++++++--- apps/meteor/client/lib/userPresence.ts | 33 ++++++++++-- apps/meteor/client/main.ts | 1 + .../client/providers/UserPresenceProvider.tsx | 2 +- .../meteor/client/startup/fakeUserPresence.ts | 18 +++++++ .../hooks/useVideoConfOpenCall.spec.tsx | 2 +- 6 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 apps/meteor/client/startup/fakeUserPresence.ts diff --git a/apps/meteor/client/definitions/IRocketChatDesktop.ts b/apps/meteor/client/definitions/IRocketChatDesktop.ts index a0707e9ba6d75..b91a23ac1124f 100644 --- a/apps/meteor/client/definitions/IRocketChatDesktop.ts +++ b/apps/meteor/client/definitions/IRocketChatDesktop.ts @@ -1,10 +1,49 @@ +type ServerInfo = { + version: string; +}; + +type Badge = '•' | number; + +type ThemeAppearance = 'dark' | 'light' | 'auto' | 'high-contrast' | undefined; + +type VideoCallWindowOptions = { + providerName?: string | undefined; +}; + type OutlookEventsResponse = { status: 'success' | 'canceled' }; export interface IRocketChatDesktop { - openInternalVideoChatWindow?: (url: string, options: { providerName: string | undefined }) => void; - getOutlookEvents?: (date: Date) => Promise; - setOutlookExchangeUrl?: (url: string, userId: string) => Promise; - hasOutlookCredentials?: () => Promise; - clearOutlookCredentials?: () => Promise | void; - openDocumentViewer?: (url: string, format: string, options: any) => void; + onReady: (cb: (serverInfo: ServerInfo) => void) => void; + setServerInfo: (serverInfo: ServerInfo) => void; + setUrlResolver: (getAbsoluteUrl: (relativePath?: string) => string) => void; + setBadge: (badge: Badge) => void; + setFavicon: (faviconUrl: string) => void; + setBackground: (imageUrl: string) => void; + setSidebarCustomTheme: (customTheme: string) => void; + setTitle: (title: string) => void; + setUserLoggedIn: (userLoggedIn: boolean) => void; + setUserPresenceDetection: (options: { + isAutoAwayEnabled: boolean; + idleThreshold: number | null; + setUserOnline: (online: boolean) => void; + }) => void; + setUserThemeAppearance: (themeAppearance: ThemeAppearance) => void; + createNotification: ( + options: NotificationOptions & { + canReply?: boolean; + title: string; + onEvent: (eventDescriptor: { type: string; detail: unknown }) => void; + }, + ) => Promise; + destroyNotification: (id: unknown) => void; + getInternalVideoChatWindowEnabled: () => boolean; + openInternalVideoChatWindow: (url: string, options: VideoCallWindowOptions) => void; + setGitCommitHash: (gitCommitHash: string) => void; + writeTextToClipboard: (text: string) => void; + getOutlookEvents: (date: Date) => Promise; + setOutlookExchangeUrl: (url: string, userId: string) => void; + hasOutlookCredentials: () => Promise; + clearOutlookCredentials: () => void; + setUserToken: (token: string, userId: string) => void; + openDocumentViewer: (url: string, format: string, options: any) => void; } diff --git a/apps/meteor/client/lib/userPresence.ts b/apps/meteor/client/lib/userPresence.ts index 31ed7570f320c..835c3230781e7 100644 --- a/apps/meteor/client/lib/userPresence.ts +++ b/apps/meteor/client/lib/userPresence.ts @@ -25,7 +25,7 @@ export class UserPresence { private storeUser: (doc: IUser) => void = () => undefined; - private startTimer() { + startTimer() { this.stopTimer(); if (!this.awayTime) return; @@ -70,15 +70,42 @@ export class UserPresence { const isLoggingIn = useIsLoggingIn(); const enableAutoAway = useUserPreference('enableAutoAway'); const idleTimeLimit = useUserPreference('idleTimeLimit') ?? 300; + const { RocketChatDesktop } = window; this.user = user; this.connected = connected; - this.awayTime = enableAutoAway ? idleTimeLimit * 1000 : undefined; + this.awayTime = enableAutoAway && !RocketChatDesktop ? idleTimeLimit * 1000 : undefined; this.goOnline = useMethod('UserPresence:online'); this.goAway = useMethod('UserPresence:away'); this.storeUser = Users.use((state) => state.store); useEffect(() => { + if (!RocketChatDesktop) return; + + RocketChatDesktop.setUserPresenceDetection({ + isAutoAwayEnabled: enableAutoAway ?? false, + idleThreshold: idleTimeLimit, + setUserOnline: (online) => { + if (!online) { + this.goAway(); + return; + } + this.goOnline(); + }, + }); + + return () => { + RocketChatDesktop.setUserPresenceDetection({ + isAutoAwayEnabled: false, + idleThreshold: null, + setUserOnline: () => undefined, + }); + }; + }, [RocketChatDesktop, enableAutoAway, idleTimeLimit]); + + useEffect(() => { + if (RocketChatDesktop) return; + const documentEvents = ['mousemove', 'mousedown', 'touchend', 'keydown'] as const; documentEvents.forEach((key) => document.addEventListener(key, this.setOnline)); window.addEventListener('focus', this.setOnline); @@ -87,7 +114,7 @@ export class UserPresence { documentEvents.forEach((key) => document.removeEventListener(key, this.setOnline)); window.removeEventListener('focus', this.setOnline); }; - }, []); + }, [RocketChatDesktop]); useEffect(() => { if (!user || !connected || isLoggingIn) return; diff --git a/apps/meteor/client/main.ts b/apps/meteor/client/main.ts index 087ae21dedf09..2d1b6f396dd84 100644 --- a/apps/meteor/client/main.ts +++ b/apps/meteor/client/main.ts @@ -1,5 +1,6 @@ import './serviceWorker'; import './startup/accounts'; +import './startup/fakeUserPresence'; import('@rocket.chat/fuselage-polyfills') .then(() => import('./meteorOverrides')) diff --git a/apps/meteor/client/providers/UserPresenceProvider.tsx b/apps/meteor/client/providers/UserPresenceProvider.tsx index 8cf1322c9ae54..8c2ca06c9913d 100644 --- a/apps/meteor/client/providers/UserPresenceProvider.tsx +++ b/apps/meteor/client/providers/UserPresenceProvider.tsx @@ -6,7 +6,7 @@ import { useMemo, useEffect } from 'react'; import { Presence } from '../lib/presence'; import { UserPresence } from '../lib/userPresence'; -const userPresence = new UserPresence(); +export const userPresence = new UserPresence(); type UserPresenceProviderProps = { children?: ReactNode; diff --git a/apps/meteor/client/startup/fakeUserPresence.ts b/apps/meteor/client/startup/fakeUserPresence.ts new file mode 100644 index 0000000000000..3bda53c5055fd --- /dev/null +++ b/apps/meteor/client/startup/fakeUserPresence.ts @@ -0,0 +1,18 @@ +// backport of rocketchat:user-presence for the desktop app + +if (window.RocketChatDesktop) { + const fakeUserPresenceModule = { + UserPresence: { + awayTime: undefined, + start: () => undefined, + }, + }; + + window.require = ((fn) => + Object.assign((id: string) => { + if (id === 'meteor/rocketchat:user-presence') { + return fakeUserPresenceModule; + } + return fn(id); + }, fn))(window.require); +} diff --git a/apps/meteor/client/views/room/contextualBar/VideoConference/hooks/useVideoConfOpenCall.spec.tsx b/apps/meteor/client/views/room/contextualBar/VideoConference/hooks/useVideoConfOpenCall.spec.tsx index bf51e9bab4cc0..74f3a6f3f565d 100644 --- a/apps/meteor/client/views/room/contextualBar/VideoConference/hooks/useVideoConfOpenCall.spec.tsx +++ b/apps/meteor/client/views/room/contextualBar/VideoConference/hooks/useVideoConfOpenCall.spec.tsx @@ -8,7 +8,7 @@ describe('with window.RocketChatDesktop set', () => { beforeEach(() => { window.RocketChatDesktop = { openInternalVideoChatWindow: jest.fn(), - }; + } as any; }); afterAll(() => { From d76a5578ed0e14fae2a041c0e09d565b28630d76 Mon Sep 17 00:00:00 2001 From: Tiago Evangelista Pinto Date: Wed, 20 Aug 2025 19:04:19 -0300 Subject: [PATCH 002/197] fix: Deprecated filter by query in `emoji-custom.all` api call (#36723) --- .changeset/clean-feet-worry.md | 9 ++++ apps/meteor/app/api/server/v1/emoji-custom.ts | 11 +++- .../views/admin/customEmoji/CustomEmoji.tsx | 3 +- apps/meteor/tests/e2e/emojis.spec.ts | 53 ++++++++++++++++++- .../tests/e2e/page-objects/admin-emojis.ts | 31 +++++++++++ .../fragments/admin-flextab-emoji.ts | 21 ++++++++ apps/meteor/tests/e2e/page-objects/index.ts | 1 + .../tests/end-to-end/api/emoji-custom.ts | 16 ++++++ .../gazzodown/src/emoji/EmojiRenderer.tsx | 2 +- packages/rest-typings/src/v1/emojiCustom.ts | 2 +- 10 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 .changeset/clean-feet-worry.md create mode 100644 apps/meteor/tests/e2e/page-objects/admin-emojis.ts create mode 100644 apps/meteor/tests/e2e/page-objects/fragments/admin-flextab-emoji.ts diff --git a/.changeset/clean-feet-worry.md b/.changeset/clean-feet-worry.md new file mode 100644 index 0000000000000..107aa31a1ace9 --- /dev/null +++ b/.changeset/clean-feet-worry.md @@ -0,0 +1,9 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/gazzodown": patch +"@rocket.chat/rest-typings": minor +--- + +Fixes search by name in custom emojis list, by adding a correct parameter to the endpoint `emoji-custom.all` + +Now the endpoint `emoji-custom.all` accepts a `name` as parameter, so the filter should work on emojis page withouth the necessity of set `ALLOW_UNSAFE_QUERY_AND_FIELDS_API_PARAMS` env var diff --git a/apps/meteor/app/api/server/v1/emoji-custom.ts b/apps/meteor/app/api/server/v1/emoji-custom.ts index a2f886afb1c11..fa84d3cc6b995 100644 --- a/apps/meteor/app/api/server/v1/emoji-custom.ts +++ b/apps/meteor/app/api/server/v1/emoji-custom.ts @@ -2,6 +2,7 @@ import { Media } from '@rocket.chat/core-services'; import type { IEmojiCustom } from '@rocket.chat/core-typings'; import { EmojiCustom } from '@rocket.chat/models'; import { isEmojiCustomList } from '@rocket.chat/rest-typings'; +import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Meteor } from 'meteor/meteor'; import { SystemLogger } from '../../../../server/lib/logger/system'; @@ -79,10 +80,18 @@ API.v1.addRoute( async get() { const { offset, count } = await getPaginationItems(this.queryParams); const { sort, query } = await this.parseJsonQuery(); + const { name } = this.queryParams; return API.v1.success( await findEmojisCustom({ - query, + query: name + ? { + name: { + $regex: escapeRegExp(name), + $options: 'i', + }, + } + : query, pagination: { offset, count, diff --git a/apps/meteor/client/views/admin/customEmoji/CustomEmoji.tsx b/apps/meteor/client/views/admin/customEmoji/CustomEmoji.tsx index 66829705f5b74..e0476e26ff47d 100644 --- a/apps/meteor/client/views/admin/customEmoji/CustomEmoji.tsx +++ b/apps/meteor/client/views/admin/customEmoji/CustomEmoji.tsx @@ -1,6 +1,5 @@ import { Box, Pagination, States, StatesActions, StatesAction, StatesIcon, StatesTitle } from '@rocket.chat/fuselage'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; -import { escapeRegExp } from '@rocket.chat/string-helpers'; import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; import type { MutableRefObject } from 'react'; @@ -35,7 +34,7 @@ const CustomEmoji = ({ onClick, reload }: CustomEmojiProps) => { const query = useDebouncedValue( useMemo( () => ({ - query: JSON.stringify({ name: { $regex: escapeRegExp(text), $options: 'i' } }), + name: text, sort: `{ "${sortBy}": ${sortDirection === 'asc' ? 1 : -1} }`, count: itemsPerPage, offset: current, diff --git a/apps/meteor/tests/e2e/emojis.spec.ts b/apps/meteor/tests/e2e/emojis.spec.ts index c19d2f6b13b37..a938b6ca069c7 100644 --- a/apps/meteor/tests/e2e/emojis.spec.ts +++ b/apps/meteor/tests/e2e/emojis.spec.ts @@ -1,5 +1,5 @@ import { Users } from './fixtures/userStates'; -import { HomeChannel } from './page-objects'; +import { HomeChannel, AdminEmoji } from './page-objects'; import { createTargetChannel } from './utils'; import { test, expect } from './utils/test'; @@ -7,6 +7,7 @@ test.use({ storageState: Users.admin.state }); test.describe.serial('emoji', () => { let poHomeChannel: HomeChannel; + let poAdminEmoji: AdminEmoji; let targetChannel: string; test.beforeAll(async ({ api }) => { @@ -57,4 +58,54 @@ test.describe.serial('emoji', () => { await poHomeChannel.content.sendMessage('® © ™ # *'); await expect(poHomeChannel.content.lastUserMessage).toContainText('® © ™ # *'); }); + + test('should add a custom emoji, send it, rename it, and check render', async ({ page }) => { + const emojiName = 'customemoji'; + const newEmojiName = 'renamedemoji'; + const emojiUrl = './tests/e2e/fixtures/files/test-image.jpeg'; + + poAdminEmoji = new AdminEmoji(page); + + await test.step('Add custom emoji', async () => { + await poHomeChannel.sidenav.openAdministrationByLabel('Workspace'); + await page.locator('role=link[name="Emoji"]').click(); + await poAdminEmoji.newButton.click(); + await poAdminEmoji.addEmoji.nameInput.fill(emojiName); + + const [fileChooser] = await Promise.all([page.waitForEvent('filechooser'), page.locator('role=button[name="Custom Emoji"]').click()]); + await fileChooser.setFiles(emojiUrl); + + await poAdminEmoji.addEmoji.btnSave.click(); + await poAdminEmoji.closeAdminButton.click(); + + await poHomeChannel.sidenav.openChat(targetChannel); + + await poHomeChannel.content.sendMessage(`:${emojiName}:`); + await page.keyboard.press('Enter'); + await expect(poHomeChannel.content.lastUserMessage.getByTitle(`:${emojiName}:`)).toBeVisible(); + }); + + await test.step('Rename custom emoji', async () => { + await poHomeChannel.sidenav.openAdministrationByLabel('Workspace'); + await page.locator('role=link[name="Emoji"]').click(); + await poAdminEmoji.findEmojiByName(emojiName); + await poAdminEmoji.addEmoji.nameInput.fill(newEmojiName); + + await poAdminEmoji.addEmoji.btnSave.click(); + await poAdminEmoji.closeAdminButton.click(); + + await poHomeChannel.sidenav.openChat(targetChannel); + + await poHomeChannel.content.sendMessage(`:${newEmojiName}:`); + await page.keyboard.press('Enter'); + await expect(poHomeChannel.content.lastUserMessage.getByTitle(`:${newEmojiName}:`)).toBeVisible(); + }); + + await test.step('Delete custom emoji', async () => { + await poHomeChannel.sidenav.openAdministrationByLabel('Workspace'); + await page.locator('role=link[name="Emoji"]').click(); + await poAdminEmoji.findEmojiByName(newEmojiName); + await poAdminEmoji.addEmoji.btnDelete.click(); + }); + }); }); diff --git a/apps/meteor/tests/e2e/page-objects/admin-emojis.ts b/apps/meteor/tests/e2e/page-objects/admin-emojis.ts new file mode 100644 index 0000000000000..b489a611436b6 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/admin-emojis.ts @@ -0,0 +1,31 @@ +import type { Locator, Page } from '@playwright/test'; + +import { AdminFlextabEmoji } from './fragments/admin-flextab-emoji'; + +export class AdminEmoji { + private readonly page: Page; + + readonly addEmoji: AdminFlextabEmoji; + + constructor(page: Page) { + this.page = page; + this.addEmoji = new AdminFlextabEmoji(page); + } + + get newButton(): Locator { + return this.page.locator('role=button[name="New"]'); + } + + get closeAdminButton(): Locator { + return this.page.getByRole('navigation').getByRole('button', { name: 'Close' }); + } + + get searchInput(): Locator { + return this.page.locator('role=textbox[name="Search"]'); + } + + async findEmojiByName(emojiName: string) { + await this.searchInput.fill(emojiName); + await this.page.locator(`role=link[name=${emojiName}]`).click(); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/fragments/admin-flextab-emoji.ts b/apps/meteor/tests/e2e/page-objects/fragments/admin-flextab-emoji.ts new file mode 100644 index 0000000000000..e28508729e582 --- /dev/null +++ b/apps/meteor/tests/e2e/page-objects/fragments/admin-flextab-emoji.ts @@ -0,0 +1,21 @@ +import type { Locator, Page } from '@playwright/test'; + +export class AdminFlextabEmoji { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + get nameInput(): Locator { + return this.page.locator('role=textbox[name="Name"]'); + } + + get btnSave(): Locator { + return this.page.locator('role=button[name="Save"]'); + } + + get btnDelete(): Locator { + return this.page.locator('role=button[name="Delete"]'); + } +} diff --git a/apps/meteor/tests/e2e/page-objects/index.ts b/apps/meteor/tests/e2e/page-objects/index.ts index e503d367e8758..80914a7b75d2e 100644 --- a/apps/meteor/tests/e2e/page-objects/index.ts +++ b/apps/meteor/tests/e2e/page-objects/index.ts @@ -1,5 +1,6 @@ export * from './account-profile'; export * from './admin-email-inboxes'; +export * from './admin-emojis'; export * from './admin'; export * from './auth'; export * from './home-channel'; diff --git a/apps/meteor/tests/end-to-end/api/emoji-custom.ts b/apps/meteor/tests/end-to-end/api/emoji-custom.ts index 07285b928f8fe..744480ea6f716 100644 --- a/apps/meteor/tests/end-to-end/api/emoji-custom.ts +++ b/apps/meteor/tests/end-to-end/api/emoji-custom.ts @@ -321,6 +321,22 @@ describe('[EmojiCustom]', () => { }) .end(done); }); + it('should return only filtered by name emojis', (done) => { + void request + .get(api('emoji-custom.all')) + .set(credentials) + .query({ + name: `${customEmojiName}-without-aliases`, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('emojis').and.to.be.an('array').and.to.have.lengthOf(1); + expect(res.body).to.have.property('total'); + expect(res.body).to.have.property('offset'); + expect(res.body).to.have.property('count'); + }) + .end(done); + }); }); describe('Accessing custom emojis', () => { diff --git a/packages/gazzodown/src/emoji/EmojiRenderer.tsx b/packages/gazzodown/src/emoji/EmojiRenderer.tsx index 84116361157c9..2021442d1006e 100644 --- a/packages/gazzodown/src/emoji/EmojiRenderer.tsx +++ b/packages/gazzodown/src/emoji/EmojiRenderer.tsx @@ -37,7 +37,7 @@ const EmojiRenderer = ({ big = false, preview = false, ...emoji }: EmojiProps): )} )) ?? ( - + {sanitizedFallback} )} diff --git a/packages/rest-typings/src/v1/emojiCustom.ts b/packages/rest-typings/src/v1/emojiCustom.ts index 1ffb41f671a88..ad67bbf7d0e9b 100644 --- a/packages/rest-typings/src/v1/emojiCustom.ts +++ b/packages/rest-typings/src/v1/emojiCustom.ts @@ -52,7 +52,7 @@ export const isEmojiCustomList = ajv.compile(emojiCustomListSch export type EmojiCustomEndpoints = { '/v1/emoji-custom.all': { - GET: (params: PaginatedRequest<{ query: string }, 'name'>) => PaginatedResult<{ + GET: (params: PaginatedRequest<{ name?: string }, 'name'>) => PaginatedResult<{ emojis: IEmojiCustom[]; }>; }; From 34db122451e5bf2d31429f24ed15506628573d31 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 20 Aug 2025 22:45:28 -0300 Subject: [PATCH 003/197] chore: move registerGuest to omni-core (#36724) --- .../app/apps/server/bridges/livechat.ts | 12 +- .../app/livechat/imports/server/rest/sms.ts | 4 +- .../app/livechat/server/api/v1/message.ts | 9 +- .../app/livechat/server/api/v1/visitor.ts | 5 +- .../app/livechat/server/lib/Visitors.ts | 127 +-- apps/meteor/app/livechat/server/lib/guests.ts | 46 +- apps/meteor/app/livechat/server/startup.ts | 47 +- .../EmailInbox/EmailInbox_Incoming.ts | 17 +- ee/packages/omni-core-ee/package.json | 1 + ee/packages/omnichannel-services/package.json | 1 + packages/freeswitch/package.json | 1 + packages/omni-core/package.json | 5 + packages/omni-core/src/index.ts | 1 + packages/omni-core/src/visitor/create.spec.ts | 768 ++++++++++++++++++ packages/omni-core/src/visitor/create.ts | 116 +++ packages/storybook-config/package.json | 1 - packages/tools/src/index.ts | 1 + packages/tools/src/validateEmail.ts | 13 + yarn.lock | 3 +- 19 files changed, 985 insertions(+), 193 deletions(-) create mode 100644 packages/omni-core/src/visitor/create.spec.ts create mode 100644 packages/omni-core/src/visitor/create.ts create mode 100644 packages/tools/src/validateEmail.ts diff --git a/apps/meteor/app/apps/server/bridges/livechat.ts b/apps/meteor/app/apps/server/bridges/livechat.ts index 6c1152e678b3d..0347d8e77f2fc 100644 --- a/apps/meteor/app/apps/server/bridges/livechat.ts +++ b/apps/meteor/app/apps/server/bridges/livechat.ts @@ -7,13 +7,13 @@ import { LivechatBridge } from '@rocket.chat/apps-engine/server/bridges/Livechat import type { ILivechatDepartment, IOmnichannelRoom, SelectedAgent, IMessage, ILivechatVisitor } from '@rocket.chat/core-typings'; import { OmnichannelSourceType } from '@rocket.chat/core-typings'; import { LivechatVisitors, LivechatRooms, LivechatDepartment, Users } from '@rocket.chat/models'; +import { registerGuest } from '@rocket.chat/omni-core'; import { callbacks } from '../../../../lib/callbacks'; import { deasyncPromise } from '../../../../server/deasync/deasync'; import { closeRoom } from '../../../livechat/server/lib/closeRoom'; import { setCustomFields } from '../../../livechat/server/lib/custom-fields'; import { getRoomMessages } from '../../../livechat/server/lib/getRoomMessages'; -import { registerGuest } from '../../../livechat/server/lib/guests'; import type { ILivechatMessage } from '../../../livechat/server/lib/localTypes'; import { updateMessage, sendMessage } from '../../../livechat/server/lib/messages'; import { createRoom } from '../../../livechat/server/lib/rooms'; @@ -207,13 +207,14 @@ export class AppLivechatBridge extends LivechatBridge { name: visitor.name, token: visitor.token, email: '', - connectionData: undefined, id: visitor.id, ...(visitor.phone?.length && { phone: { number: visitor.phone[0].phoneNumber } }), ...(visitor.visitorEmails?.length && { email: visitor.visitorEmails[0].address }), }; - const livechatVisitor = await registerGuest(registerData); + const livechatVisitor = await registerGuest(registerData, { + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + }); if (!livechatVisitor) { throw new Error('Invalid visitor, cannot create'); @@ -231,13 +232,14 @@ export class AppLivechatBridge extends LivechatBridge { name: visitor.name, token: visitor.token, email: '', - connectionData: undefined, id: visitor.id, ...(visitor.phone?.length && { phone: { number: visitor.phone[0].phoneNumber } }), ...(visitor.visitorEmails?.length && { email: visitor.visitorEmails[0].address }), }; - const livechatVisitor = await registerGuest(registerData); + const livechatVisitor = await registerGuest(registerData, { + shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle'), + }); return this.orch.getConverters()?.get('visitors').convertVisitor(livechatVisitor); } diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.ts b/apps/meteor/app/livechat/imports/server/rest/sms.ts index d8546be970f4c..b271194c1f05c 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/sms.ts @@ -10,6 +10,7 @@ import type { import { OmnichannelSourceType } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; import { LivechatVisitors, LivechatRooms, LivechatDepartment } from '@rocket.chat/models'; +import { registerGuest } from '@rocket.chat/omni-core'; import { Random } from '@rocket.chat/random'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; import { Meteor } from 'meteor/meteor'; @@ -20,7 +21,6 @@ import { FileUpload } from '../../../../file-upload/server'; import { checkUrlForSsrf } from '../../../../lib/server/functions/checkUrlForSsrf'; import { settings } from '../../../../settings/server'; import { setCustomField } from '../../../server/api/lib/customFields'; -import { registerGuest } from '../../../server/lib/guests'; import type { ILivechatMessage } from '../../../server/lib/localTypes'; import { sendMessage } from '../../../server/lib/messages'; import { createRoom } from '../../../server/lib/rooms'; @@ -76,7 +76,7 @@ const defineVisitor = async (smsNumber: string, targetDepartment?: string) => { data.department = targetDepartment; } - const livechatVisitor = await registerGuest(data); + const livechatVisitor = await registerGuest(data, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') }); if (!livechatVisitor) { throw new Meteor.Error('error-invalid-visitor', 'Invalid visitor'); diff --git a/apps/meteor/app/livechat/server/api/v1/message.ts b/apps/meteor/app/livechat/server/api/v1/message.ts index 67c9a7e397c58..228ada8f464d1 100644 --- a/apps/meteor/app/livechat/server/api/v1/message.ts +++ b/apps/meteor/app/livechat/server/api/v1/message.ts @@ -1,5 +1,6 @@ import { OmnichannelSourceType } from '@rocket.chat/core-typings'; import { LivechatVisitors, LivechatRooms, Messages } from '@rocket.chat/models'; +import { registerGuest } from '@rocket.chat/omni-core'; import { Random } from '@rocket.chat/random'; import { isPOSTLivechatMessageParams, @@ -17,7 +18,6 @@ import { isWidget } from '../../../../api/server/helpers/isWidget'; import { loadMessageHistory } from '../../../../lib/server/functions/loadMessageHistory'; import { settings } from '../../../../settings/server'; import { normalizeMessageFileUpload } from '../../../../utils/server/functions/normalizeMessageFileUpload'; -import { registerGuest } from '../../lib/guests'; import { updateMessage, deleteMessage, sendMessage } from '../../lib/messages'; import { findGuest, findRoom, normalizeHttpHeaderData } from '../lib/livechat'; @@ -267,9 +267,12 @@ API.v1.addRoute( rid = Random.id(); const guest: typeof this.bodyParams.visitor & { connectionData?: unknown } = this.bodyParams.visitor; - guest.connectionData = normalizeHttpHeaderData(this.request.headers); - const visitor = await registerGuest(guest); + if (settings.get('Livechat_Allow_collect_and_store_HTTP_header_informations')) { + guest.connectionData = normalizeHttpHeaderData(this.request.headers); + } + + const visitor = await registerGuest(guest, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') }); if (!visitor) { throw new Error('error-livechat-visitor-registration'); } diff --git a/apps/meteor/app/livechat/server/api/v1/visitor.ts b/apps/meteor/app/livechat/server/api/v1/visitor.ts index 1bc28cdd8280b..2c17181fc3feb 100644 --- a/apps/meteor/app/livechat/server/api/v1/visitor.ts +++ b/apps/meteor/app/livechat/server/api/v1/visitor.ts @@ -1,5 +1,6 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { LivechatVisitors as VisitorsRaw, LivechatRooms } from '@rocket.chat/models'; +import { registerGuest } from '@rocket.chat/omni-core'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -7,7 +8,7 @@ import { callbacks } from '../../../../../lib/callbacks'; import { API } from '../../../../api/server'; import { settings } from '../../../../settings/server'; import { setMultipleVisitorCustomFields } from '../../lib/custom-fields'; -import { registerGuest, notifyGuestStatusChanged, removeContactsByVisitorId } from '../../lib/guests'; +import { notifyGuestStatusChanged, removeContactsByVisitorId } from '../../lib/guests'; import { livechatLogger } from '../../lib/logger'; import { saveRoomInfo } from '../../lib/rooms'; import { updateCallStatus } from '../../lib/utils'; @@ -59,7 +60,7 @@ API.v1.addRoute( connectionData: normalizeHttpHeaderData(this.request.headers), }; - const visitor = await registerGuest(guest); + const visitor = await registerGuest(guest, { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') }); if (!visitor) { throw new Meteor.Error('error-livechat-visitor-registration', 'Error registering visitor', { method: 'livechat/visitor', diff --git a/apps/meteor/app/livechat/server/lib/Visitors.ts b/apps/meteor/app/livechat/server/lib/Visitors.ts index 6ff046b3fc211..4bd81498b7824 100644 --- a/apps/meteor/app/livechat/server/lib/Visitors.ts +++ b/apps/meteor/app/livechat/server/lib/Visitors.ts @@ -1,25 +1,6 @@ -import { UserStatus } from '@rocket.chat/core-typings'; -import type { ILivechatContactVisitorAssociation, IOmnichannelSource, ILivechatVisitor } from '@rocket.chat/core-typings'; -import { Logger } from '@rocket.chat/logger'; -import { LivechatContacts, LivechatDepartment, LivechatVisitors, Users } from '@rocket.chat/models'; - -import { validateEmail } from './Helper'; -import { settings } from '../../../settings/server'; - -const logger = new Logger('Livechat - Visitor'); - -export type RegisterGuestType = Partial> & { - id?: string; - connectionData?: any; - email?: string; - phone?: { number: string }; -}; +import type { ILivechatContactVisitorAssociation, IOmnichannelSource } from '@rocket.chat/core-typings'; export const Visitors = { - isValidObject(obj: unknown): obj is Record { - return typeof obj === 'object' && obj !== null; - }, - makeVisitorAssociation(visitorId: string, roomInfo: IOmnichannelSource): ILivechatContactVisitorAssociation { return { visitorId, @@ -29,110 +10,4 @@ export const Visitors = { }, }; }, - - async registerGuest({ - id, - token, - name, - phone, - email, - department, - username, - connectionData, - status = UserStatus.ONLINE, - }: RegisterGuestType): Promise { - check(token, String); - check(id, Match.Maybe(String)); - - logger.debug(`New incoming conversation: id: ${id} | token: ${token}`); - - const visitorDataToUpdate: Partial & { userAgent?: string; ip?: string; host?: string } = { - token, - status, - ...(phone?.number && { phone: [{ phoneNumber: phone.number }] }), - ...(name && { name }), - }; - - if (email) { - const visitorEmail = email.trim().toLowerCase(); - validateEmail(visitorEmail); - visitorDataToUpdate.visitorEmails = [{ address: visitorEmail }]; - - const contact = await LivechatContacts.findContactByEmailAndContactManager(visitorEmail); - if (contact?.contactManager) { - const shouldConsiderIdleAgent = settings.get('Livechat_enabled_when_agent_idle'); - const agent = await Users.findOneOnlineAgentById(contact.contactManager, shouldConsiderIdleAgent, { - projection: { _id: 1, username: 1, name: 1, emails: 1 }, - }); - if (agent?.username && agent.name && agent.emails) { - visitorDataToUpdate.contactManager = { - _id: agent._id, - username: agent.username, - name: agent.name, - emails: agent.emails, - }; - logger.debug(`Assigning visitor ${token} to agent ${agent.username}`); - } - } - } - - const livechatVisitor = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } }); - - if (department && livechatVisitor?.department !== department) { - logger.debug(`Attempt to find a department with id/name ${department}`); - const dep = await LivechatDepartment.findOneByIdOrName(department, { projection: { _id: 1 } }); - if (!dep) { - logger.debug(`Invalid department provided: ${department}`); - throw new Meteor.Error('error-invalid-department', 'The provided department is invalid'); - } - logger.debug(`Assigning visitor ${token} to department ${dep._id}`); - visitorDataToUpdate.department = dep._id; - } - - visitorDataToUpdate.token = livechatVisitor?.token || token; - - let existingUser = null; - - if (livechatVisitor) { - logger.debug('Found matching user by token'); - visitorDataToUpdate._id = livechatVisitor._id; - } else if (phone?.number && (existingUser = await LivechatVisitors.findOneVisitorByPhone(phone.number))) { - logger.debug('Found matching user by phone number'); - visitorDataToUpdate._id = existingUser._id; - // Don't change token when matching by phone number, use current visitor token - visitorDataToUpdate.token = existingUser.token; - } else if (email && (existingUser = await LivechatVisitors.findOneGuestByEmailAddress(email))) { - logger.debug('Found matching user by email'); - visitorDataToUpdate._id = existingUser._id; - } else if (!livechatVisitor) { - logger.debug(`No matches found. Attempting to create new user with token ${token}`); - - visitorDataToUpdate._id = id || undefined; - visitorDataToUpdate.username = username || (await LivechatVisitors.getNextVisitorUsername()); - visitorDataToUpdate.status = status; - visitorDataToUpdate.ts = new Date(); - - if (settings.get('Livechat_Allow_collect_and_store_HTTP_header_informations') && this.isValidObject(connectionData)) { - logger.debug(`Saving connection data for visitor ${token}`); - const { httpHeaders, clientAddress } = connectionData; - if (this.isValidObject(httpHeaders)) { - visitorDataToUpdate.userAgent = httpHeaders['user-agent']; - visitorDataToUpdate.ip = httpHeaders['x-real-ip'] || httpHeaders['x-forwarded-for'] || clientAddress; - visitorDataToUpdate.host = httpHeaders?.host; - } - } - } - - const upsertedLivechatVisitor = await LivechatVisitors.updateOneByIdOrToken(visitorDataToUpdate, { - upsert: true, - returnDocument: 'after', - }); - - if (!upsertedLivechatVisitor) { - logger.debug(`No visitor found after upsert`); - return null; - } - - return upsertedLivechatVisitor; - }, }; diff --git a/apps/meteor/app/livechat/server/lib/guests.ts b/apps/meteor/app/livechat/server/lib/guests.ts index f4bee437099e5..454dc55d812e4 100644 --- a/apps/meteor/app/livechat/server/lib/guests.ts +++ b/apps/meteor/app/livechat/server/lib/guests.ts @@ -11,13 +11,9 @@ import { LivechatContacts, Users, } from '@rocket.chat/models'; -import { wrapExceptions } from '@rocket.chat/tools'; import UAParser from 'ua-parser-js'; -import { parseAgentCustomFields, validateEmail } from './Helper'; -import type { RegisterGuestType } from './Visitors'; -import { Visitors } from './Visitors'; -import { ContactMerger, type FieldAndValue } from './contacts/ContactMerger'; +import { parseAgentCustomFields } from './Helper'; import type { ICRMData } from './localTypes'; import { livechatLogger } from './logger'; import { trim } from '../../../../lib/utils/stringUtils'; @@ -102,46 +98,6 @@ export async function removeContactsByVisitorId({ _id }: { _id: string }) { } } -export async function registerGuest(newData: RegisterGuestType): Promise { - const visitor = await Visitors.registerGuest(newData); - if (!visitor) { - return null; - } - - const { name, phone, email, username } = newData; - - const validatedEmail = - email && - wrapExceptions(() => { - const trimmedEmail = email.trim().toLowerCase(); - validateEmail(trimmedEmail); - return trimmedEmail; - }).suppress(); - - const fields = [ - { type: 'name', value: name }, - { type: 'phone', value: phone?.number }, - { type: 'email', value: validatedEmail }, - { type: 'username', value: username || visitor.username }, - ].filter((field) => Boolean(field.value)) as FieldAndValue[]; - - if (!fields.length) { - return null; - } - - // If a visitor was updated who already had contacts, load up the contacts and update that information as well - const contacts = await LivechatContacts.findAllByVisitorId(visitor._id).toArray(); - for await (const contact of contacts) { - await ContactMerger.mergeFieldsIntoContact({ - fields, - contact, - conflictHandlingMode: contact.unknown ? 'overwrite' : 'conflict', - }); - } - - return visitor; -} - async function cleanGuestHistory(_id: string) { // This shouldn't be possible, but just in case if (!_id) { diff --git a/apps/meteor/app/livechat/server/startup.ts b/apps/meteor/app/livechat/server/startup.ts index 1df1245655bab..d6035c7ab03fb 100644 --- a/apps/meteor/app/livechat/server/startup.ts +++ b/apps/meteor/app/livechat/server/startup.ts @@ -1,7 +1,9 @@ import type { IUser } from '@rocket.chat/core-typings'; import { ILivechatAgentStatus, isOmnichannelRoom } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; -import { LivechatRooms } from '@rocket.chat/models'; +import { LivechatContacts, LivechatRooms } from '@rocket.chat/models'; +import { registerGuest } from '@rocket.chat/omni-core'; +import { validateEmail, wrapExceptions } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; @@ -18,9 +20,52 @@ import { hasPermissionAsync } from '../../authorization/server/functions/hasPerm import { notifyOnUserChange } from '../../lib/server/lib/notifyListener'; import { settings } from '../../settings/server'; import './roomAccessValidator.internalService'; +import { ContactMerger, type FieldAndValue } from './lib/contacts/ContactMerger'; const logger = new Logger('LivechatStartup'); +// TODO this patch is temporary because `ContactMerger` still a lot of dependencies, so it is not suitable to be moved to omni-core package +// TODO add tests covering the ContactMerger usage +registerGuest.patch(async (originalFn, newData, options) => { + const visitor = await originalFn(newData, options); + if (!visitor) { + return null; + } + + const { name, phone, email, username } = newData; + + const validatedEmail = + email && + wrapExceptions(() => { + const trimmedEmail = email.trim().toLowerCase(); + validateEmail(trimmedEmail); + return trimmedEmail; + }).suppress(); + + const fields = [ + { type: 'name', value: name }, + { type: 'phone', value: phone?.number }, + { type: 'email', value: validatedEmail }, + { type: 'username', value: username || visitor.username }, + ].filter((field) => Boolean(field.value)) as FieldAndValue[]; + + if (!fields.length) { + return null; + } + + // If a visitor was updated who already had contacts, load up the contacts and update that information as well + const contacts = await LivechatContacts.findAllByVisitorId(visitor._id).toArray(); + for await (const contact of contacts) { + await ContactMerger.mergeFieldsIntoContact({ + fields, + contact, + conflictHandlingMode: contact.unknown ? 'overwrite' : 'conflict', + }); + } + + return visitor; +}); + Meteor.startup(async () => { roomCoordinator.setRoomFind('l', async (id) => maybeMigrateLivechatRoom(await LivechatRooms.findOneById(id))); diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts index d7a6ce004fc62..f6cff68c3efea 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Incoming.ts @@ -7,6 +7,7 @@ import type { } from '@rocket.chat/core-typings'; import { OmnichannelSourceType } from '@rocket.chat/core-typings'; import { LivechatVisitors, LivechatRooms, Messages } from '@rocket.chat/models'; +import { registerGuest } from '@rocket.chat/omni-core'; import { Random } from '@rocket.chat/random'; import type { ParsedMail, Attachment } from 'mailparser'; import { stripHtml } from 'string-strip-html'; @@ -16,7 +17,6 @@ import { FileUpload } from '../../../app/file-upload/server'; import { notifyOnMessageChange } from '../../../app/lib/server/lib/notifyListener'; import { QueueManager } from '../../../app/livechat/server/lib/QueueManager'; import { setDepartmentForGuest } from '../../../app/livechat/server/lib/departmentsLib'; -import { registerGuest } from '../../../app/livechat/server/lib/guests'; import { sendMessage } from '../../../app/livechat/server/lib/messages'; import { settings } from '../../../app/settings/server'; import { i18n } from '../../lib/i18n'; @@ -41,12 +41,15 @@ async function getGuestByEmail(email: string, name: string, department = ''): Pr return guest; } - const livechatVisitor = await registerGuest({ - token: Random.id(), - name: name || email, - email, - department, - }); + const livechatVisitor = await registerGuest( + { + token: Random.id(), + name: name || email, + email, + department, + }, + { shouldConsiderIdleAgent: settings.get('Livechat_enabled_when_agent_idle') }, + ); if (!livechatVisitor) { throw new Error('Error getting guest'); diff --git a/ee/packages/omni-core-ee/package.json b/ee/packages/omni-core-ee/package.json index d3fb87b08bcc7..e93eb3e7eff9d 100644 --- a/ee/packages/omni-core-ee/package.json +++ b/ee/packages/omni-core-ee/package.json @@ -15,6 +15,7 @@ "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", "test": "jest", + "testunit": "jest", "build": "rm -rf dist && tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" }, diff --git a/ee/packages/omnichannel-services/package.json b/ee/packages/omnichannel-services/package.json index 39e81b1e64a49..04e27ce51e5cf 100644 --- a/ee/packages/omnichannel-services/package.json +++ b/ee/packages/omnichannel-services/package.json @@ -39,6 +39,7 @@ "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", "test": "jest", + "testunit": "jest", "build": "rm -rf dist && tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" }, diff --git a/packages/freeswitch/package.json b/packages/freeswitch/package.json index cdf276195a9cc..b6f89e92408ad 100644 --- a/packages/freeswitch/package.json +++ b/packages/freeswitch/package.json @@ -14,6 +14,7 @@ "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", "test": "jest", + "testunit": "jest", "build": "rm -rf dist && tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" }, diff --git a/packages/omni-core/package.json b/packages/omni-core/package.json index 2dbe7bd594772..9dbd412107b4f 100644 --- a/packages/omni-core/package.json +++ b/packages/omni-core/package.json @@ -5,6 +5,7 @@ "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", "@rocket.chat/jest-presets": "workspace:~", + "@rocket.chat/tools": "workspace:*", "@rocket.chat/tsconfig": "workspace:*", "@types/jest": "~30.0.0", "eslint": "~8.45.0", @@ -15,6 +16,7 @@ "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", "test": "jest", + "testunit": "jest", "build": "rm -rf dist && tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" }, @@ -23,6 +25,9 @@ "files": [ "/dist" ], + "volta": { + "extends": "../../package.json" + }, "dependencies": { "@rocket.chat/models": "workspace:^", "@rocket.chat/patch-injection": "workspace:^" diff --git a/packages/omni-core/src/index.ts b/packages/omni-core/src/index.ts index e7784bda38aca..c0a01acf69dd1 100644 --- a/packages/omni-core/src/index.ts +++ b/packages/omni-core/src/index.ts @@ -1 +1,2 @@ export * from './isDepartmentCreationAvailable'; +export * from './visitor/create'; diff --git a/packages/omni-core/src/visitor/create.spec.ts b/packages/omni-core/src/visitor/create.spec.ts new file mode 100644 index 0000000000000..2dee062386877 --- /dev/null +++ b/packages/omni-core/src/visitor/create.spec.ts @@ -0,0 +1,768 @@ +import { UserStatus } from '@rocket.chat/core-typings'; +import type { ILivechatContactsModel, ILivechatDepartmentModel, ILivechatVisitorsModel, IUsersModel } from '@rocket.chat/model-typings'; +import { registerModel } from '@rocket.chat/models'; +import { validateEmail } from '@rocket.chat/tools'; + +import { registerGuest } from './create'; + +// Mock the validateEmail function +jest.mock('@rocket.chat/tools', () => ({ + validateEmail: jest.fn(), +})); + +// Mock the Logger +jest.mock('@rocket.chat/logger', () => ({ + Logger: jest.fn().mockImplementation(() => ({ + debug: jest.fn(), + })), +})); + +const mockValidateEmail = validateEmail as jest.MockedFunction; + +describe('registerGuest', () => { + let updateOneByIdOrTokenSpy: jest.Mock; + let getVisitorByTokenSpy: jest.Mock; + let findOneVisitorByPhoneSpy: jest.Mock; + let findOneGuestByEmailAddressSpy: jest.Mock; + let getNextVisitorUsernameSpy: jest.Mock; + let findContactByEmailAndContactManagerSpy: jest.Mock; + let findOneOnlineAgentByIdSpy: jest.Mock; + let findOneByIdOrNameSpy: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockValidateEmail.mockImplementation(() => true); + + // Create spies that return reasonable defaults + updateOneByIdOrTokenSpy = jest.fn().mockResolvedValue({ _id: 'visitor-123', token: 'test-token' }); + getVisitorByTokenSpy = jest.fn().mockResolvedValue(null); + findOneVisitorByPhoneSpy = jest.fn().mockResolvedValue(null); + findOneGuestByEmailAddressSpy = jest.fn().mockResolvedValue(null); + getNextVisitorUsernameSpy = jest.fn().mockResolvedValue('guest-123'); + findContactByEmailAndContactManagerSpy = jest.fn().mockResolvedValue(null); + findOneOnlineAgentByIdSpy = jest.fn().mockResolvedValue(null); + findOneByIdOrNameSpy = jest.fn().mockResolvedValue(null); + + // Register the models with spy functions + registerModel('ILivechatVisitorsModel', { + getVisitorByToken: getVisitorByTokenSpy, + findOneVisitorByPhone: findOneVisitorByPhoneSpy, + findOneGuestByEmailAddress: findOneGuestByEmailAddressSpy, + getNextVisitorUsername: getNextVisitorUsernameSpy, + updateOneByIdOrToken: updateOneByIdOrTokenSpy, + } as unknown as ILivechatVisitorsModel); + + registerModel('ILivechatContactsModel', { + findContactByEmailAndContactManager: findContactByEmailAndContactManagerSpy, + } as unknown as ILivechatContactsModel); + + registerModel('IUsersModel', { + findOneOnlineAgentById: findOneOnlineAgentByIdSpy, + } as unknown as IUsersModel); + + registerModel('ILivechatDepartmentModel', { + findOneByIdOrName: findOneByIdOrNameSpy, + } as unknown as ILivechatDepartmentModel); + }); + + describe('validation', () => { + it('should throw error when token is not provided', async () => { + const guestData = {}; + + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).rejects.toThrow('error-invalid-token'); + }); + + it('should throw error when token is empty string', async () => { + const guestData = { + token: '', + }; + + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).rejects.toThrow('error-invalid-token'); + }); + }); + + describe('email validation and contact manager assignment', () => { + it('should validate email and assign contact manager when available', async () => { + const email = 'test@example.com'; + const token = 'test-token'; + const agentId = 'agent-123'; + + const mockAgent = { + _id: agentId, + username: 'agent.user', + name: 'Agent User', + emails: [{ address: 'agent@example.com' }], + }; + + const mockContact = { + contactManager: agentId, + }; + + findContactByEmailAndContactManagerSpy.mockResolvedValue(mockContact); + findOneOnlineAgentByIdSpy.mockResolvedValue(mockAgent); + + const guestData = { + token, + email, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + expect(mockValidateEmail).toHaveBeenCalledWith('test@example.com'); + expect(findContactByEmailAndContactManagerSpy).toHaveBeenCalledWith('test@example.com'); + expect(findOneOnlineAgentByIdSpy).toHaveBeenCalledWith(agentId, false, { projection: { _id: 1, username: 1, name: 1, emails: 1 } }); + + // Verify the data passed to updateOneByIdOrToken + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + status: UserStatus.ONLINE, + visitorEmails: [{ address: email }], + contactManager: { + _id: agentId, + username: 'agent.user', + name: 'Agent User', + emails: [{ address: 'agent@example.com' }], + }, + username: 'guest-123', + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should not assign contact manager when agent is not found', async () => { + const email = 'test@example.com'; + const token = 'test-token'; + + const mockContact = { + contactManager: 'agent-123', + }; + + findContactByEmailAndContactManagerSpy.mockResolvedValue(mockContact); + findOneOnlineAgentByIdSpy.mockResolvedValue(null); + + const guestData = { + token, + email, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + // Verify contact manager is not included in the data + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + status: UserStatus.ONLINE, + visitorEmails: [{ address: email }], + username: 'guest-123', + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + + // Ensure contactManager is not present + const callArgs = updateOneByIdOrTokenSpy.mock.calls[0][0]; + expect(callArgs.contactManager).toBeUndefined(); + }); + + it('should trim and lowercase email', async () => { + const email = ' TEST@EXAMPLE.COM '; + const token = 'test-token'; + + const guestData = { + token, + email, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + expect(mockValidateEmail).toHaveBeenCalledWith('test@example.com'); + expect(findContactByEmailAndContactManagerSpy).toHaveBeenCalledWith('test@example.com'); + + // Verify the trimmed and lowercase email is passed to updateOneByIdOrToken + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + visitorEmails: [{ address: 'test@example.com' }], + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + }); + + describe('department validation and assignment', () => { + it('should assign valid department', async () => { + const token = 'test-token'; + const department = 'sales'; + const departmentId = 'dept-123'; + + const mockDepartment = { + _id: departmentId, + }; + + findOneByIdOrNameSpy.mockResolvedValue(mockDepartment); + + const guestData = { + token, + department, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + expect(findOneByIdOrNameSpy).toHaveBeenCalledWith(department, { projection: { _id: 1 } }); + + // Verify the department ID is passed to updateOneByIdOrToken + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + status: UserStatus.ONLINE, + department: departmentId, + username: 'guest-123', + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should throw error for invalid department', async () => { + const token = 'test-token'; + const department = 'invalid-dept'; + + findOneByIdOrNameSpy.mockResolvedValue(null); + getVisitorByTokenSpy.mockResolvedValue({ department: 'different-dept' }); + + const guestData = { + token, + department, + }; + + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).rejects.toThrow('error-invalid-department'); + + // Verify updateOneByIdOrToken is not called when department validation fails + expect(updateOneByIdOrTokenSpy).not.toHaveBeenCalled(); + }); + + it('should not validate department if visitor already has the same department', async () => { + const token = 'test-token'; + const department = 'sales'; + + getVisitorByTokenSpy.mockResolvedValue({ + _id: 'visitor-123', + department, + token, + }); + + const guestData = { + token, + department, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + // Department validation should be skipped + expect(findOneByIdOrNameSpy).not.toHaveBeenCalled(); + + // Verify existing visitor is updated without department validation + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + _id: 'visitor-123', + token, + status: UserStatus.ONLINE, + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + }); + + describe('visitor matching and creation', () => { + it('should update existing visitor found by token', async () => { + const token = 'test-token'; + const existingVisitor = { + _id: 'visitor-123', + token, + }; + + getVisitorByTokenSpy.mockResolvedValue(existingVisitor); + + const guestData = { + token, + name: 'Updated Name', + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + expect(getVisitorByTokenSpy).toHaveBeenCalledWith(token, { projection: { _id: 1 } }); + + // Verify existing visitor data is used and updated + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + _id: 'visitor-123', + token, + status: UserStatus.ONLINE, + name: 'Updated Name', + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should match visitor by phone number and preserve existing token', async () => { + const token = 'new-token'; + const existingToken = 'existing-token'; + const phoneNumber = '+1234567890'; + const existingVisitor = { + _id: 'visitor-123', + token: existingToken, + }; + + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(existingVisitor); + + const guestData = { + token, + phone: { number: phoneNumber }, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + expect(findOneVisitorByPhoneSpy).toHaveBeenCalledWith(phoneNumber); + + // Verify existing visitor's token is preserved, not the new one + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + _id: 'visitor-123', + token: existingToken, // Should use existing token, not new one + status: UserStatus.ONLINE, + phone: [{ phoneNumber }], + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should match visitor by email', async () => { + const token = 'test-token'; + const email = 'test@example.com'; + const existingVisitor = { + _id: 'visitor-123', + token: 'existing-token', + }; + + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(existingVisitor); + + const guestData = { + token, + email, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + expect(findOneGuestByEmailAddressSpy).toHaveBeenCalledWith(email); + + // Verify existing visitor data is used + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + _id: 'visitor-123', + token, + status: UserStatus.ONLINE, + visitorEmails: [{ address: email }], + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should create new visitor when no matches found', async () => { + const token = 'test-token'; + const username = 'custom-username'; + const id = 'custom-id'; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + const guestData = { + id, + token, + username, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + // Verify new visitor data is created with provided values + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + _id: id, + token, + username, + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should generate username when not provided for new visitor', async () => { + const token = 'test-token'; + const generatedUsername = 'guest-123'; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + const guestData = { + token, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + expect(getNextVisitorUsernameSpy).toHaveBeenCalled(); + + // Verify generated username is used + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + username: generatedUsername, + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should use provided username for new visitor', async () => { + const token = 'test-token'; + const providedUsername = 'custom-username'; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + const guestData = { + token, + username: providedUsername, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + expect(getNextVisitorUsernameSpy).not.toHaveBeenCalled(); + + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + username: providedUsername, + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + }); + + describe('data formatting', () => { + it('should format phone number correctly', async () => { + const token = 'test-token'; + const phoneNumber = '+1234567890'; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + const guestData = { + token, + phone: { number: phoneNumber }, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + phone: [{ phoneNumber }], + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should format email correctly', async () => { + const token = 'test-token'; + const email = 'test@example.com'; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + findContactByEmailAndContactManagerSpy.mockResolvedValue(null); + + const guestData = { + token, + email, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + visitorEmails: [{ address: email }], + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should use default status when not provided', async () => { + const token = 'test-token'; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + const guestData = { + token, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should use custom status when provided', async () => { + const token = 'test-token'; + const status = UserStatus.AWAY; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + const guestData = { + token, + status, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + status, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + }); + + describe('connection data handling', () => { + it('should save connection data for new visitor', async () => { + const token = 'test-token'; + const connectionData = { + httpHeaders: { + 'user-agent': 'Mozilla/5.0', + 'x-real-ip': '192.168.1.1', + 'host': 'example.com', + }, + clientAddress: '10.0.0.1', + }; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + const guestData = { + token, + connectionData, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + userAgent: 'Mozilla/5.0', + ip: '192.168.1.1', + host: 'example.com', + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should use x-forwarded-for header when x-real-ip is not available', async () => { + const token = 'test-token'; + const connectionData = { + httpHeaders: { + 'x-forwarded-for': '203.0.113.1', + }, + clientAddress: '10.0.0.1', + }; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + const guestData = { + token, + connectionData, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + ip: '203.0.113.1', + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should use clientAddress when no IP headers are available', async () => { + const token = 'test-token'; + const connectionData = { + httpHeaders: {}, + clientAddress: '10.0.0.1', + }; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + const guestData = { + token, + connectionData, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + ip: '10.0.0.1', + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + }); + + describe('error scenarios', () => { + it('should return null when upsert fails', async () => { + const token = 'test-token'; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + + // Mock upsert to return null (failure case) + updateOneByIdOrTokenSpy.mockResolvedValue(null); + + const guestData = { + token, + }; + + const result = await registerGuest(guestData, { shouldConsiderIdleAgent: false }); + + expect(result).toBeNull(); + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + + it('should throw error when email validation fails', async () => { + const token = 'test-token'; + const email = 'invalid-email'; + + mockValidateEmail.mockImplementation(() => { + throw new Error('Invalid email'); + }); + + const guestData = { + token, + email, + }; + + await expect(registerGuest(guestData, { shouldConsiderIdleAgent: false })).rejects.toThrow('Invalid email'); + }); + }); + + describe('shouldConsiderIdleAgent parameter', () => { + it('should pass shouldConsiderIdleAgent to findOneOnlineAgentById', async () => { + const token = 'test-token'; + const email = 'test@example.com'; + const agentId = 'agent-123'; + + const mockAgent = { + _id: agentId, + username: 'agent.user', + name: 'Agent User', + emails: [{ address: 'agent@example.com' }], + }; + + const mockContact = { + contactManager: agentId, + }; + + // All lookup methods return null (no matches) + getVisitorByTokenSpy.mockResolvedValue(null); + findOneVisitorByPhoneSpy.mockResolvedValue(null); + findOneGuestByEmailAddressSpy.mockResolvedValue(null); + findContactByEmailAndContactManagerSpy.mockResolvedValue(mockContact); + findOneOnlineAgentByIdSpy.mockResolvedValue(mockAgent); + + const guestData = { + token, + email, + }; + + await registerGuest(guestData, { shouldConsiderIdleAgent: true }); + + expect(findOneOnlineAgentByIdSpy).toHaveBeenCalledWith( + agentId, + true, // shouldConsiderIdleAgent should be true + { projection: { _id: 1, username: 1, name: 1, emails: 1 } }, + ); + + expect(updateOneByIdOrTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + token, + visitorEmails: [{ address: email }], + contactManager: expect.objectContaining({ + username: 'agent.user', + }), + status: UserStatus.ONLINE, + ts: expect.any(Date), + }), + { upsert: true, returnDocument: 'after' }, + ); + }); + }); +}); diff --git a/packages/omni-core/src/visitor/create.ts b/packages/omni-core/src/visitor/create.ts new file mode 100644 index 0000000000000..0fc212a4b3ddc --- /dev/null +++ b/packages/omni-core/src/visitor/create.ts @@ -0,0 +1,116 @@ +import { type ILivechatVisitor, UserStatus } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { LivechatContacts, LivechatDepartment, LivechatVisitors, Users } from '@rocket.chat/models'; +import { makeFunction } from '@rocket.chat/patch-injection'; +import { validateEmail } from '@rocket.chat/tools'; + +const logger = new Logger('Livechat - Visitor'); + +type RegisterGuestType = Partial> & { + id?: string; + connectionData?: any; + email?: string; + phone?: { number: string }; +}; + +export const registerGuest = makeFunction( + async ( + { id, token, name, phone, email, department, username, connectionData, status = UserStatus.ONLINE }: RegisterGuestType, + { shouldConsiderIdleAgent }: { shouldConsiderIdleAgent: boolean }, + ): Promise => { + if (!token) { + throw Error('error-invalid-token'); + } + + logger.debug(`New incoming conversation: id: ${id} | token: ${token}`); + + const visitorDataToUpdate: Partial & { userAgent?: string; ip?: string; host?: string } = { + token, + status, + ...(phone?.number && { phone: [{ phoneNumber: phone.number }] }), + ...(name && { name }), + }; + + if (email) { + const visitorEmail = email.trim().toLowerCase(); + validateEmail(visitorEmail); + visitorDataToUpdate.visitorEmails = [{ address: visitorEmail }]; + + const contact = await LivechatContacts.findContactByEmailAndContactManager(visitorEmail); + if (contact?.contactManager) { + const agent = await Users.findOneOnlineAgentById(contact.contactManager, shouldConsiderIdleAgent, { + projection: { _id: 1, username: 1, name: 1, emails: 1 }, + }); + if (agent?.username && agent.name && agent.emails) { + visitorDataToUpdate.contactManager = { + _id: agent._id, + username: agent.username, + name: agent.name, + emails: agent.emails, + }; + logger.debug(`Assigning visitor ${token} to agent ${agent.username}`); + } + } + } + + const livechatVisitor = await LivechatVisitors.getVisitorByToken(token, { projection: { _id: 1 } }); + + if (department && livechatVisitor?.department !== department) { + logger.debug(`Attempt to find a department with id/name ${department}`); + const dep = await LivechatDepartment.findOneByIdOrName(department, { projection: { _id: 1 } }); + if (!dep) { + logger.debug(`Invalid department provided: ${department}`); + // throw new Meteor.Error('error-invalid-department', 'The provided department is invalid'); + throw new Error('error-invalid-department'); + } + logger.debug(`Assigning visitor ${token} to department ${dep._id}`); + visitorDataToUpdate.department = dep._id; + } + + visitorDataToUpdate.token = livechatVisitor?.token || token; + + let existingUser = null; + + if (livechatVisitor) { + logger.debug('Found matching user by token'); + visitorDataToUpdate._id = livechatVisitor._id; + } else if (phone?.number && (existingUser = await LivechatVisitors.findOneVisitorByPhone(phone.number))) { + logger.debug('Found matching user by phone number'); + visitorDataToUpdate._id = existingUser._id; + // Don't change token when matching by phone number, use current visitor token + visitorDataToUpdate.token = existingUser.token; + } else if (email && (existingUser = await LivechatVisitors.findOneGuestByEmailAddress(email))) { + logger.debug('Found matching user by email'); + visitorDataToUpdate._id = existingUser._id; + } else if (!livechatVisitor) { + logger.debug(`No matches found. Attempting to create new user with token ${token}`); + + visitorDataToUpdate._id = id || undefined; + visitorDataToUpdate.username = username || (await LivechatVisitors.getNextVisitorUsername()); + visitorDataToUpdate.status = status; + visitorDataToUpdate.ts = new Date(); + + if (connectionData && typeof connectionData === 'object') { + logger.debug(`Saving connection data for visitor ${token}`); + const { httpHeaders, clientAddress } = connectionData; + if (httpHeaders && typeof httpHeaders === 'object') { + visitorDataToUpdate.userAgent = httpHeaders['user-agent']; + visitorDataToUpdate.ip = httpHeaders['x-real-ip'] || httpHeaders['x-forwarded-for'] || clientAddress; + visitorDataToUpdate.host = httpHeaders.host; + } + } + } + + const upsertedLivechatVisitor = await LivechatVisitors.updateOneByIdOrToken(visitorDataToUpdate, { + upsert: true, + returnDocument: 'after', + }); + + if (!upsertedLivechatVisitor) { + logger.debug(`No visitor found after upsert`); + return null; + } + + return upsertedLivechatVisitor; + }, +); diff --git a/packages/storybook-config/package.json b/packages/storybook-config/package.json index ad7db5f0ba2cf..fae0ee7401e62 100644 --- a/packages/storybook-config/package.json +++ b/packages/storybook-config/package.json @@ -40,7 +40,6 @@ "scripts": { "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", - "test": "jest", "copy-svg": "cp -r ./src/logo.svg ./dist/logo.svg", "build": "rm -rf dist && tsc && yarn run copy-svg", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index ddde6d93c76ef..8734e9f4d8c1e 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -12,3 +12,4 @@ export * from './converter'; export * from './removeEmpty'; export * from './isObject'; export * from './isRecord'; +export * from './validateEmail'; diff --git a/packages/tools/src/validateEmail.ts b/packages/tools/src/validateEmail.ts new file mode 100644 index 0000000000000..fd9237f68ec05 --- /dev/null +++ b/packages/tools/src/validateEmail.ts @@ -0,0 +1,13 @@ +export const validateEmail = (email: string, options: { style: string } = { style: 'basic' }): boolean => { + const basicEmailRegex = /^[^@]+@[^@]+$/; + const rfcEmailRegex = + /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + + switch (options.style) { + case 'rfc': + return rfcEmailRegex.test(email); + case 'basic': + default: + return basicEmailRegex.test(email); + } +}; diff --git a/yarn.lock b/yarn.lock index 71129d9154855..4e3af52f4bac6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8375,6 +8375,7 @@ __metadata: "@rocket.chat/jest-presets": "workspace:~" "@rocket.chat/models": "workspace:^" "@rocket.chat/patch-injection": "workspace:^" + "@rocket.chat/tools": "workspace:*" "@rocket.chat/tsconfig": "workspace:*" "@types/jest": "npm:~30.0.0" eslint: "npm:~8.45.0" @@ -8890,7 +8891,7 @@ __metadata: languageName: node linkType: hard -"@rocket.chat/tools@workspace:^, @rocket.chat/tools@workspace:packages/tools, @rocket.chat/tools@workspace:~": +"@rocket.chat/tools@workspace:*, @rocket.chat/tools@workspace:^, @rocket.chat/tools@workspace:packages/tools, @rocket.chat/tools@workspace:~": version: 0.0.0-use.local resolution: "@rocket.chat/tools@workspace:packages/tools" dependencies: From 6e009b1fbf8837a57497bada0be91c3503f3be49 Mon Sep 17 00:00:00 2001 From: gabriellsh <40830821+gabriellsh@users.noreply.github.com> Date: Wed, 20 Aug 2025 23:41:57 -0300 Subject: [PATCH 004/197] chore: Implement `useWriteStream` ServerContext (#36715) Co-authored-by: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> --- .../client/providers/ServerProvider.tsx | 4 ++++ .../tests/mocks/client/ServerProviderMock.tsx | 2 ++ .../src/MockedAppRootBuilder.tsx | 1 + packages/ui-contexts/src/ServerContext.ts | 4 ++++ .../ui-contexts/src/hooks/useWriteStream.ts | 19 +++++++++++++++++++ packages/ui-contexts/src/index.ts | 1 + 6 files changed, 31 insertions(+) create mode 100644 packages/ui-contexts/src/hooks/useWriteStream.ts diff --git a/apps/meteor/client/providers/ServerProvider.tsx b/apps/meteor/client/providers/ServerProvider.tsx index fd719cf5aaebd..20f357250d8d7 100644 --- a/apps/meteor/client/providers/ServerProvider.tsx +++ b/apps/meteor/client/providers/ServerProvider.tsx @@ -69,6 +69,9 @@ const getStream = >(eventName: K, callback: (...args: StreamerCallbackArgs) => void): (() => void) => sdk.stream(streamName, [eventName], callback).stop; +const writeStream = >(streamName: N, streamKey: K, ...args: StreamerCallbackArgs) => + sdk.publish(streamName, [streamKey, ...args]); + const disconnect = () => Meteor.disconnect(); const reconnect = () => Meteor.reconnect(); @@ -92,6 +95,7 @@ const ServerProvider = ({ children }: ServerProviderProps) => { callEndpoint, uploadToEndpoint, getStream, + writeStream, disconnect, reconnect, }), diff --git a/apps/meteor/tests/mocks/client/ServerProviderMock.tsx b/apps/meteor/tests/mocks/client/ServerProviderMock.tsx index 76c17db1f554e..0013872b1038d 100644 --- a/apps/meteor/tests/mocks/client/ServerProviderMock.tsx +++ b/apps/meteor/tests/mocks/client/ServerProviderMock.tsx @@ -59,6 +59,7 @@ const getStream = () => () => () => undefined; // to be implemented const callEndpoint = () => { throw new Error('not implemented'); }; // to be implemented +const writeStream = () => undefined; // to be implemented const contextValue: ServerContextValue = { connected: true, @@ -72,6 +73,7 @@ const contextValue: ServerContextValue = { getStream, reconnect: () => undefined, disconnect: () => undefined, + writeStream, }; type ServerProviderMockProps = { diff --git a/packages/mock-providers/src/MockedAppRootBuilder.tsx b/packages/mock-providers/src/MockedAppRootBuilder.tsx index 625934876a135..228b6b9276f83 100644 --- a/packages/mock-providers/src/MockedAppRootBuilder.tsx +++ b/packages/mock-providers/src/MockedAppRootBuilder.tsx @@ -85,6 +85,7 @@ export class MockedAppRootBuilder { callMethod: () => Promise.reject(new Error('not implemented')), disconnect: () => Promise.reject(new Error('not implemented')), reconnect: () => Promise.reject(new Error('not implemented')), + writeStream: () => Promise.reject(new Error('not implemented')), }; private router: ContextType = { diff --git a/packages/ui-contexts/src/ServerContext.ts b/packages/ui-contexts/src/ServerContext.ts index d81fb7832749a..0edb41f95e811 100644 --- a/packages/ui-contexts/src/ServerContext.ts +++ b/packages/ui-contexts/src/ServerContext.ts @@ -48,6 +48,7 @@ export type ServerContextValue = { retransmitToSelf?: boolean | undefined; }, ) => (eventName: K, callback: (...args: StreamerCallbackArgs) => void) => () => void; + writeStream: >(streamName: N, eventName: K, ...args: StreamerCallbackArgs) => void; disconnect: () => void; reconnect: () => void; }; @@ -65,6 +66,9 @@ export const ServerContext = createContext({ throw new Error('not implemented'); }, getStream: () => () => (): void => undefined, + writeStream: () => { + throw new Error('not implemented'); + }, disconnect: () => { throw new Error('not implemented'); }, diff --git a/packages/ui-contexts/src/hooks/useWriteStream.ts b/packages/ui-contexts/src/hooks/useWriteStream.ts new file mode 100644 index 0000000000000..aeba6dd567223 --- /dev/null +++ b/packages/ui-contexts/src/hooks/useWriteStream.ts @@ -0,0 +1,19 @@ +import type { StreamNames, StreamKeys, StreamerCallbackArgs } from '@rocket.chat/ddp-client'; +import { useCallback, useContext } from 'react'; + +import { ServerContext } from '../ServerContext'; + +type WriteStreamCallback = >(eventName: K, ...args: StreamerCallbackArgs) => void; + +export function useWriteStream(streamName: N): WriteStreamCallback { + const { writeStream } = useContext(ServerContext); + + if (!writeStream) { + throw new Error(`cannot use useWriteStream(${streamName}) hook without a wrapping ServerContext`); + } + + return useCallback( + >(eventName: K, ...args: StreamerCallbackArgs) => writeStream(streamName, eventName, ...args), + [writeStream, streamName], + ); +} diff --git a/packages/ui-contexts/src/index.ts b/packages/ui-contexts/src/index.ts index 1ca969a47ed21..954193351beb6 100644 --- a/packages/ui-contexts/src/index.ts +++ b/packages/ui-contexts/src/index.ts @@ -96,6 +96,7 @@ export { useAccountsCustomFields } from './hooks/useAccountsCustomFields'; export { useUserPresence } from './hooks/useUserPresence'; export { useUnstoreLoginToken } from './hooks/useUnstoreLoginToken'; export { useOnLogout } from './hooks/useOnLogout'; +export { useWriteStream } from './hooks/useWriteStream'; export { UploadResult } from './ServerContext'; export { TranslationKey, TranslationLanguage } from './TranslationContext'; From a29030da3ad4086498d99dd2e058c35d25abcb67 Mon Sep 17 00:00:00 2001 From: Harmeet Kour <87123067+Harmeet221@users.noreply.github.com> Date: Thu, 21 Aug 2025 12:26:23 +0530 Subject: [PATCH 005/197] test: Improve test to ensure priority indicator is displayed in the sidebar. (#36755) --- .../omnichannel/omnichannel-priorities-sidebar.spec.ts | 4 ++++ .../tests/e2e/page-objects/omnichannel-room-info.ts | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-priorities-sidebar.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-priorities-sidebar.spec.ts index d43bb22f31609..1f91cd0dda61e 100644 --- a/apps/meteor/tests/e2e/omnichannel/omnichannel-priorities-sidebar.spec.ts +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-priorities-sidebar.spec.ts @@ -66,11 +66,13 @@ test.describe.serial('OC - Priorities [Sidebar]', () => { await poHomeChannel.sidenav.selectPriority(visitor.name, 'Lowest'); await systemMessage.locator(`text="${getPrioritySystemMessage('user1', 'Lowest')}"`).waitFor(); await expect(poRoomInfo.getLabel('Priority')).toBeVisible(); + await expect(poRoomInfo.getBadgeIndicator(visitor.name, 'Lowest')).toBeVisible(); await expect(poRoomInfo.getInfo('Lowest')).toBeVisible(); await poHomeChannel.sidenav.selectPriority(visitor.name, 'Highest'); await systemMessage.locator(`text="${getPrioritySystemMessage('user1', 'Highest')}"`).waitFor(); await expect(poRoomInfo.getInfo('Highest')).toBeVisible(); + await expect(poRoomInfo.getBadgeIndicator(visitor.name, 'Highest')).toBeVisible(); await poHomeChannel.sidenav.selectPriority(visitor.name, 'Unprioritized'); await systemMessage.locator(`text="${getPrioritySystemMessage('user1', 'Unprioritized')}"`).waitFor(); @@ -89,10 +91,12 @@ test.describe.serial('OC - Priorities [Sidebar]', () => { await systemMessage.locator(`text="${getPrioritySystemMessage('user1', 'Lowest')}"`).waitFor(); await expect(poRoomInfo.getLabel('Priority')).toBeVisible(); await expect(poRoomInfo.getInfo('Lowest')).toBeVisible(); + await expect(poRoomInfo.getBadgeIndicator(visitor.name, 'Lowest')).toBeVisible(); await poHomeChannel.sidenav.selectPriority(visitor.name, 'Highest'); await systemMessage.locator(`text="${getPrioritySystemMessage('user1', 'Highest')}"`).waitFor(); await expect(poRoomInfo.getInfo('Highest')).toBeVisible(); + await expect(poRoomInfo.getBadgeIndicator(visitor.name, 'Highest')).toBeVisible(); await poHomeChannel.sidenav.selectPriority(visitor.name, 'Unprioritized'); await systemMessage.locator(`text="${getPrioritySystemMessage('user1', 'Unprioritized')}"`).waitFor(); diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel-room-info.ts b/apps/meteor/tests/e2e/page-objects/omnichannel-room-info.ts index 5df39c592a90d..29c9b7cdf0c89 100644 --- a/apps/meteor/tests/e2e/page-objects/omnichannel-room-info.ts +++ b/apps/meteor/tests/e2e/page-objects/omnichannel-room-info.ts @@ -1,10 +1,15 @@ import type { Locator, Page } from '@playwright/test'; +import { HomeSidenav } from './fragments/home-sidenav'; + export class OmnichannelRoomInfo { private readonly page: Page; + private readonly homeSidenav: HomeSidenav; + constructor(page: Page) { this.page = page; + this.homeSidenav = new HomeSidenav(page); } get dialogRoomInfo(): Locator { @@ -34,4 +39,8 @@ export class OmnichannelRoomInfo { getLabel(label: string): Locator { return this.page.locator(`div >> text="${label}"`); } + + getBadgeIndicator(name: string, title: string): Locator { + return this.homeSidenav.getSidebarItemByName(name).getByTitle(title); + } } From ae4813962910dd8d71909f2f324d73a6684781dc Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Thu, 21 Aug 2025 20:05:44 -0300 Subject: [PATCH 006/197] fix(desktop-app): Handle failure in `CachedStore` initialization (#36764) --- apps/meteor/client/lib/cachedStores/CachedStore.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/meteor/client/lib/cachedStores/CachedStore.ts b/apps/meteor/client/lib/cachedStores/CachedStore.ts index 4e629f6ee4e8d..f57a14e58e2bd 100644 --- a/apps/meteor/client/lib/cachedStores/CachedStore.ts +++ b/apps/meteor/client/lib/cachedStores/CachedStore.ts @@ -310,8 +310,6 @@ export abstract class CachedStore implements await this.loadFromServerAndPopulate(); } - this.setReady(true); - this.reconnectionComputation?.stop(); let wentOffline = Tracker.nonreactive(() => Meteor.status().status === 'offline'); this.reconnectionComputation = Tracker.autorun(() => { @@ -340,9 +338,12 @@ export abstract class CachedStore implements return this.initializationPromise; } - this.initializationPromise = this.performInitialization().finally(() => { - this.initializationPromise = undefined; - }); + this.initializationPromise = this.performInitialization() + .catch(console.error) + .finally(() => { + this.initializationPromise = undefined; + this.setReady(true); + }); return this.initializationPromise; } From c6ef437d9071dbd8c08152984dc39542b1ae7306 Mon Sep 17 00:00:00 2001 From: gabriellsh <40830821+gabriellsh@users.noreply.github.com> Date: Fri, 22 Aug 2025 10:26:17 -0300 Subject: [PATCH 007/197] feat: Voice call device permission flow (#36397) --- .changeset/weak-windows-doubt.md | 10 + .../roomActions/useVoiceCallRoomAction.tsx | 31 +- .../DeviceProvider/DeviceProvider.tsx | 150 +++++--- packages/i18n/src/locales/en.i18n.json | 5 + .../src/MockedAppRootBuilder.tsx | 55 +-- .../src/MockedDeviceContext.tsx | 10 + .../components/GenericMenu/GenericMenu.tsx | 1 + packages/ui-contexts/jest.config.ts | 12 + packages/ui-contexts/package.json | 7 +- packages/ui-contexts/src/DeviceContext.ts | 1 + .../hooks/useMediaDevicePermission.spec.tsx | 68 ++++ .../src/hooks/useMediaDevicePermission.ts | 58 +++ packages/ui-contexts/src/index.ts | 1 + .../PermissionFlowModal.spec.tsx | 19 + .../PermissionFlowModal.stories.tsx | 66 ++++ .../PermissionFlow/PermissionFlowModal.tsx | 107 ++++++ .../PermissionFlowModal.spec.tsx.snap | 361 ++++++++++++++++++ .../VoipActions/VoipActions.stories.tsx | 6 + .../components/VoipActions/VoipActions.tsx | 9 +- .../VoipPopup/VoipPopup.stories.tsx | 4 + .../VoipPopup/views/VoipDialerView.spec.tsx | 20 +- .../VoipPopup/views/VoipDialerView.tsx | 14 +- .../VoipPopup/views/VoipIncomingView.spec.tsx | 14 +- .../VoipSettingsButton/VoipSettingsButton.tsx | 29 +- .../hooks/useVoipDeviceSettings.tsx | 80 ++-- packages/ui-voip/src/hooks/index.ts | 1 + .../hooks/useDevicePermissionPrompt.spec.tsx | 197 ++++++++++ .../src/hooks/useDevicePermissionPrompt.tsx | 123 ++++++ packages/ui-voip/src/lib/LocalStream.ts | 10 + packages/ui-voip/src/lib/VoipClient.ts | 2 +- yarn.lock | 5 +- 31 files changed, 1352 insertions(+), 124 deletions(-) create mode 100644 .changeset/weak-windows-doubt.md create mode 100644 packages/ui-contexts/jest.config.ts create mode 100644 packages/ui-contexts/src/hooks/useMediaDevicePermission.spec.tsx create mode 100644 packages/ui-contexts/src/hooks/useMediaDevicePermission.ts create mode 100644 packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.spec.tsx create mode 100644 packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.stories.tsx create mode 100644 packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.tsx create mode 100644 packages/ui-voip/src/components/PermissionFlow/__snapshots__/PermissionFlowModal.spec.tsx.snap create mode 100644 packages/ui-voip/src/hooks/useDevicePermissionPrompt.spec.tsx create mode 100644 packages/ui-voip/src/hooks/useDevicePermissionPrompt.tsx diff --git a/.changeset/weak-windows-doubt.md b/.changeset/weak-windows-doubt.md new file mode 100644 index 0000000000000..9dfcde6067fc7 --- /dev/null +++ b/.changeset/weak-windows-doubt.md @@ -0,0 +1,10 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": minor +"@rocket.chat/mock-providers": minor +"@rocket.chat/ui-client": minor +"@rocket.chat/ui-contexts": minor +"@rocket.chat/ui-voip": minor +--- + +Introduces a new flow for requesting device permissions for Voice Calling, prompting the user before the request. Also solves a few issues with the device selection menu. diff --git a/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx b/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx index e9dc459951814..a9920688b59cf 100644 --- a/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx +++ b/apps/meteor/client/hooks/roomActions/useVoiceCallRoomAction.tsx @@ -1,23 +1,22 @@ import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import { usePermission, useUserId } from '@rocket.chat/ui-contexts'; -import { useVoipAPI, useVoipState } from '@rocket.chat/ui-voip'; +import { useMediaDeviceMicrophonePermission, usePermission, useUserId } from '@rocket.chat/ui-contexts'; +import { useVoipAPI, useVoipState, useDevicePermissionPrompt } from '@rocket.chat/ui-voip'; import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useMediaPermissions } from '../../views/room/composer/messageBox/hooks/useMediaPermissions'; import { useRoom } from '../../views/room/contexts/RoomContext'; import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext'; import { useUserInfoQuery } from '../useUserInfoQuery'; import { useVoipWarningModal } from '../useVoipWarningModal'; export const useVoiceCallRoomAction = () => { - const { t } = useTranslation(); const { uids = [] } = useRoom(); const ownUserId = useUserId(); const canStartVoiceCall = usePermission('view-user-voip-extension'); const dispatchWarning = useVoipWarningModal(); - const [isMicPermissionDenied] = useMediaPermissions('microphone'); + const { state: micPermissionState } = useMediaDeviceMicrophonePermission(); + + const isMicPermissionDenied = micPermissionState === 'denied'; const { isEnabled, isRegistered, isInCall } = useVoipState(); const { makeCall } = useVoipAPI(); @@ -36,19 +35,26 @@ export const useVoiceCallRoomAction = () => { const tooltip = useMemo(() => { if (isMicPermissionDenied) { - return t('Microphone_access_not_allowed'); + return 'Microphone_access_not_allowed'; } if (isInCall) { - return t('Unable_to_make_calls_while_another_is_ongoing'); + return 'Unable_to_make_calls_while_another_is_ongoing'; } - return disabled ? t('Voice_calling_disabled') : ''; - }, [disabled, isInCall, isMicPermissionDenied, t]); + return disabled ? 'Voice_calling_disabled' : ''; + }, [disabled, isInCall, isMicPermissionDenied]); + + const promptPermission = useDevicePermissionPrompt({ + actionType: 'outgoing', + onAccept: () => { + makeCall(remoteUser?.freeSwitchExtension as string); + }, + }); const handleOnClick = useEffectEvent(() => { if (canMakeVoipCall) { - return makeCall(remoteUser?.freeSwitchExtension as string); + return promptPermission(); } dispatchWarning(); }); @@ -60,14 +66,13 @@ export const useVoiceCallRoomAction = () => { return { id: 'start-voice-call', - title: 'Voice_Call', + title: tooltip || 'Voice_Call', icon: 'phone', featured: true, action: handleOnClick, groups: ['direct'] as const, order: 2, disabled, - tooltip, }; }, [allowed, disabled, handleOnClick, tooltip]); }; diff --git a/apps/meteor/client/providers/DeviceProvider/DeviceProvider.tsx b/apps/meteor/client/providers/DeviceProvider/DeviceProvider.tsx index 358f4e7ea9988..afa2e2b236d4b 100644 --- a/apps/meteor/client/providers/DeviceProvider/DeviceProvider.tsx +++ b/apps/meteor/client/providers/DeviceProvider/DeviceProvider.tsx @@ -1,6 +1,7 @@ import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; import type { Device, DeviceContextValue } from '@rocket.chat/ui-contexts'; import { DeviceContext } from '@rocket.chat/ui-contexts'; +import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import type { ReactElement, ReactNode } from 'react'; import { useEffect, useState, useMemo } from 'react'; @@ -10,20 +11,27 @@ type DeviceProviderProps = { children?: ReactNode | undefined; }; -export const DeviceProvider = ({ children }: DeviceProviderProps): ReactElement => { - const [enabled] = useState(typeof isSecureContext && isSecureContext); - const [availableAudioOutputDevices, setAvailableAudioOutputDevices] = useState([]); - const [availableAudioInputDevices, setAvailableAudioInputDevices] = useState([]); - const [selectedAudioOutputDevice, setSelectedAudioOutputDevice] = useState({ - id: 'default', +const defaultDevices = { + audioInput: [], + audioOutput: [], + defaultAudioOutputDevice: { + id: '', label: '', - type: 'audio', - }); - const [selectedAudioInputDevice, setSelectedAudioInputDevice] = useState({ - id: 'default', + type: 'audiooutput', + }, + defaultAudioInputDevice: { + id: '', label: '', - type: 'audio', - }); + type: 'audioinput', + }, +}; + +const devicesQueryKey = ['media-devices-list']; + +export const DeviceProvider = ({ children }: DeviceProviderProps): ReactElement => { + const [enabled] = useState(typeof isSecureContext && isSecureContext); + const [selectedAudioOutputDevice, setSelectedAudioOutputDevice] = useState(undefined); + const [selectedAudioInputDevice, setSelectedAudioInputDevice] = useState(undefined); const setAudioInputDevice = (device: Device): void => { if (!isSecureContext) { @@ -45,38 +53,88 @@ export const DeviceProvider = ({ children }: DeviceProviderProps): ReactElement }, ); + const queryClient = useQueryClient(); + + const { data } = useQuery({ + queryKey: devicesQueryKey, + enabled, + queryFn: async () => { + const devices = await navigator.mediaDevices?.enumerateDevices(); + if (!devices || devices.length === 0) { + return defaultDevices; + } + + const mappedDevices: Device[] = devices.map((device) => ({ + id: device.deviceId, + label: device.label, + type: device.kind, + })); + + const audioInput = mappedDevices.filter((device) => device.type === 'audioinput'); + + const audioOutput = mappedDevices.filter((device) => device.type === 'audiooutput'); + + return { + audioInput, + audioOutput, + defaultAudioOutputDevice: audioOutput[0], + defaultAudioInputDevice: audioInput[0], + }; + }, + initialData: defaultDevices, + placeholderData: keepPreviousData, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: true, + staleTime: 0, + }); + + const { data: permissionStatus } = useQuery({ + queryKey: [...devicesQueryKey, 'permission-status'], + queryFn: async () => { + if (!navigator.permissions) { + return; + } + const result = await navigator.permissions.query({ name: 'microphone' as PermissionName }); + return result; + }, + initialData: undefined, + placeholderData: undefined, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: true, + }); + useEffect(() => { - if (!enabled) { + if (!permissionStatus) { return; } - const setMediaDevices = (): void => { - navigator.mediaDevices?.enumerateDevices().then((devices) => { - const audioInput: Device[] = []; - const audioOutput: Device[] = []; - devices.forEach((device) => { - const mediaDevice: Device = { - id: device.deviceId, - label: device.label, - type: device.kind, - }; - if (device.kind === 'audioinput') { - audioInput.push(mediaDevice); - } else if (device.kind === 'audiooutput') { - audioOutput.push(mediaDevice); - } - }); - setAvailableAudioOutputDevices(audioOutput); - setAvailableAudioInputDevices(audioInput); - }); + const invalidateQueries = (): void => { + queryClient.invalidateQueries({ queryKey: devicesQueryKey }); }; - navigator.mediaDevices?.addEventListener('devicechange', setMediaDevices); - setMediaDevices(); + permissionStatus.addEventListener('change', invalidateQueries); return (): void => { - navigator.mediaDevices?.removeEventListener('devicechange', setMediaDevices); + permissionStatus.removeEventListener('change', invalidateQueries); }; - }, [enabled]); + }, [permissionStatus, queryClient]); + + useEffect(() => { + if (!enabled || !navigator.mediaDevices) { + return; + } + + const invalidateQuery = (): void => { + queryClient.invalidateQueries({ queryKey: devicesQueryKey, exact: true }); + }; + + navigator.mediaDevices.addEventListener('devicechange', invalidateQuery); + + return (): void => { + navigator.mediaDevices.removeEventListener('devicechange', invalidateQuery); + }; + }, [enabled, queryClient]); const contextValue = useMemo((): DeviceContextValue => { if (!enabled) { @@ -84,23 +142,19 @@ export const DeviceProvider = ({ children }: DeviceProviderProps): ReactElement enabled, }; } + const { audioInput, audioOutput, defaultAudioOutputDevice, defaultAudioInputDevice } = data; return { enabled, - availableAudioOutputDevices, - availableAudioInputDevices, - selectedAudioOutputDevice, - selectedAudioInputDevice, + permissionStatus, + availableAudioOutputDevices: audioOutput, + availableAudioInputDevices: audioInput, + selectedAudioOutputDevice: selectedAudioOutputDevice || defaultAudioOutputDevice, + selectedAudioInputDevice: selectedAudioInputDevice || defaultAudioInputDevice, setAudioOutputDevice, setAudioInputDevice, }; - }, [ - availableAudioInputDevices, - availableAudioOutputDevices, - enabled, - selectedAudioInputDevice, - selectedAudioOutputDevice, - setAudioOutputDevice, - ]); + }, [enabled, data, permissionStatus, selectedAudioOutputDevice, selectedAudioInputDevice, setAudioOutputDevice]); + return {children}; }; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 55a8d6d166e8c..b9444e34604f5 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -5635,6 +5635,11 @@ "VoIP_TeamCollab_Ice_Servers_Description": "A list of Ice Servers (STUN and/or TURN), separated by comma. \n Username, password and port are allowed in the format `username:password@stun:host:port` or `username:password@turn:host:port`. \n Both username and password may be html-encoded.", "VoIP_Toggle": "Enable/Disable VoIP", "VoIP_available_setup_freeswitch_server_details": "VoIP is available but the FreeSwitch server details need to be set up from the team voice call settings.", + "VoIP_device_permission_required": "Mic/speaker access required", + "VoIP_device_permission_required_description": "Your web browser stopped {{workspaceUrl}} from using your microphone and/or speaker.\n\nAllow speaker and microphone access in your browser settings to prevent seeing this message again.", + "VoIP_allow_and_call": "Allow and call", + "VoIP_allow_and_accept": "Allow and accept", + "VoIP_cancel_and_reject": "Cancel and reject", "Voice_Call": "Voice Call", "Voice_Call_Extension": "Voice Call Extension", "Voice_and_omnichannel": "Voice and omnichannel", diff --git a/packages/mock-providers/src/MockedAppRootBuilder.tsx b/packages/mock-providers/src/MockedAppRootBuilder.tsx index 228b6b9276f83..7f1b1150a493c 100644 --- a/packages/mock-providers/src/MockedAppRootBuilder.tsx +++ b/packages/mock-providers/src/MockedAppRootBuilder.tsx @@ -16,6 +16,7 @@ import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter'; import type { Method, OperationParams, OperationResult, PathPattern, UrlParams } from '@rocket.chat/rest-typings'; import type { Device, + DeviceContext, LoginService, ModalContextValue, ServerContextValue, @@ -213,9 +214,16 @@ export class MockedAppRootBuilder { private events = new Emitter(); - private audioInputDevices: Device[] = []; - - private audioOutputDevices: Device[] = []; + private deviceContext: Partial> = { + enabled: true, + availableAudioOutputDevices: [], + availableAudioInputDevices: [], + selectedAudioOutputDevice: undefined, + selectedAudioInputDevice: undefined, + setAudioOutputDevice: () => undefined, + setAudioInputDevice: () => undefined, + permissionStatus: undefined, + }; wrap(wrapper: (children: ReactNode) => ReactNode): this { this.wrappers.push(wrapper); @@ -489,12 +497,29 @@ export class MockedAppRootBuilder { } withAudioInputDevices(devices: Device[]): this { - this.audioInputDevices = devices; + if (!this.deviceContext.enabled) { + throw new Error('DeviceContext is not enabled'); + } + + this.deviceContext.availableAudioInputDevices = devices; return this; } withAudioOutputDevices(devices: Device[]): this { - this.audioOutputDevices = devices; + if (!this.deviceContext.enabled) { + throw new Error('DeviceContext is not enabled'); + } + + this.deviceContext.availableAudioOutputDevices = devices; + return this; + } + + withMicrophonePermissionState(status: PermissionStatus): this { + if (!this.deviceContext.enabled) { + throw new Error('DeviceContext is not enabled'); + } + + this.deviceContext.permissionStatus = status; return this; } @@ -542,20 +567,7 @@ export class MockedAppRootBuilder { }, }); - const { - server, - router, - settings, - user, - userPresence, - videoConf, - i18n, - authorization, - wrappers, - audioInputDevices, - audioOutputDevices, - authentication, - } = this; + const { server, router, settings, user, userPresence, videoConf, i18n, authorization, wrappers, deviceContext, authentication } = this; const reduceTranslation = (translation?: ContextType): ContextType => { return { @@ -630,10 +642,7 @@ export class MockedAppRootBuilder { */} - + {/* diff --git a/packages/mock-providers/src/MockedDeviceContext.tsx b/packages/mock-providers/src/MockedDeviceContext.tsx index 0b861b7e13096..adfb7bac50b83 100644 --- a/packages/mock-providers/src/MockedDeviceContext.tsx +++ b/packages/mock-providers/src/MockedDeviceContext.tsx @@ -2,12 +2,22 @@ import type { DeviceContextValue } from '@rocket.chat/ui-contexts'; import { DeviceContext } from '@rocket.chat/ui-contexts'; import type { ReactNode } from 'react'; +const mockPermissionStatus: PermissionStatus = { + state: 'granted', + name: 'microphone', + onchange: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => true, +}; + const mockDeviceContextValue: DeviceContextValue = { enabled: true, selectedAudioOutputDevice: undefined, selectedAudioInputDevice: undefined, availableAudioOutputDevices: [], availableAudioInputDevices: [], + permissionStatus: mockPermissionStatus, setAudioOutputDevice: () => undefined, setAudioInputDevice: () => undefined, }; diff --git a/packages/ui-client/src/components/GenericMenu/GenericMenu.tsx b/packages/ui-client/src/components/GenericMenu/GenericMenu.tsx index 39b2c2e2a2c81..0c714a2acc09e 100644 --- a/packages/ui-client/src/components/GenericMenu/GenericMenu.tsx +++ b/packages/ui-client/src/components/GenericMenu/GenericMenu.tsx @@ -11,6 +11,7 @@ type GenericMenuCommonProps = { icon?: ComponentProps['icon']; disabled?: boolean; callbackAction?: () => void; + isOpen?: boolean; }; type GenericMenuConditionalProps = diff --git a/packages/ui-contexts/jest.config.ts b/packages/ui-contexts/jest.config.ts new file mode 100644 index 0000000000000..45bc947f274c2 --- /dev/null +++ b/packages/ui-contexts/jest.config.ts @@ -0,0 +1,12 @@ +import client from '@rocket.chat/jest-presets/client'; +import type { Config } from 'jest'; + +export default { + preset: client.preset, + setupFilesAfterEnv: [...client.setupFilesAfterEnv], + moduleNameMapper: { + '^react($|/.+)': '/../../node_modules/react$1', + '^react-dom($|/.+)': '/../../node_modules/react-dom$1', + '^react-i18next($|/.+)': '/../../node_modules/react-i18next$1', + }, +} satisfies Config; diff --git a/packages/ui-contexts/package.json b/packages/ui-contexts/package.json index b8cba15fee64e..a96416dad6046 100644 --- a/packages/ui-contexts/package.json +++ b/packages/ui-contexts/package.json @@ -9,13 +9,16 @@ "@rocket.chat/fuselage-hooks": "^0.37.0", "@rocket.chat/fuselage-tokens": "~0.33.2", "@rocket.chat/i18n": "workspace:~", + "@rocket.chat/jest-presets": "workspace:~", "@rocket.chat/rest-typings": "workspace:^", "@rocket.chat/tools": "workspace:~", + "@types/jest": "~30.0.0", "@types/react": "~18.3.23", "@types/react-dom": "~18.3.7", "eslint": "~8.45.0", "eslint-plugin-react-hooks": "^5.0.0", "i18next": "~23.4.9", + "jest": "~30.0.2", "mongodb": "6.10.0", "react": "~18.3.1", "typescript": "~5.9.2" @@ -39,7 +42,9 @@ "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", "dev": "tsc --watch --preserveWatchOutput -p tsconfig.json", - "build": "rm -rf dist && tsc -p tsconfig.json" + "build": "rm -rf dist && tsc -p tsconfig.json", + "test": "jest", + "testunit": "jest" }, "main": "./dist/index.js", "typings": "./dist/index.d.ts", diff --git a/packages/ui-contexts/src/DeviceContext.ts b/packages/ui-contexts/src/DeviceContext.ts index 5bbda60cfcd56..2911081bf75ce 100644 --- a/packages/ui-contexts/src/DeviceContext.ts +++ b/packages/ui-contexts/src/DeviceContext.ts @@ -17,6 +17,7 @@ type EnabledDeviceContextValue = { setAudioOutputDevice: (data: { outputDevice: Device; HTMLAudioElement: HTMLAudioElement }) => void; setAudioInputDevice: (device: Device) => void; // setVideoInputDevice: (device: Device) => void; + permissionStatus: PermissionStatus | undefined; }; type DisabledDeviceContextValue = { diff --git a/packages/ui-contexts/src/hooks/useMediaDevicePermission.spec.tsx b/packages/ui-contexts/src/hooks/useMediaDevicePermission.spec.tsx new file mode 100644 index 0000000000000..fe6e5385b9bbd --- /dev/null +++ b/packages/ui-contexts/src/hooks/useMediaDevicePermission.spec.tsx @@ -0,0 +1,68 @@ +import { renderHook } from '@testing-library/react'; + +import { useMediaDeviceMicrophonePermission } from './useMediaDevicePermission'; +import { DeviceContext } from '../DeviceContext'; + +const states = [ + { expectedState: 'granted', state: 'granted', requestDevice: 'function' }, + { expectedState: 'denied', state: 'denied', requestDevice: 'undefined' }, + { expectedState: 'prompt', state: 'prompt', requestDevice: 'function' }, +]; + +const getWrapper = + (state: PermissionState | undefined, availableAudioInputDevices: any[] = [], enabled = true) => + ({ children }: { children: any }) => { + return ( + undefined, + setAudioInputDevice: () => undefined, + }} + > + {children} + + ); + }; + +describe('useMediaDeviceMicrophonePermission', () => { + it('Should return permission state denied and requestDevice is undefined if context is disabled', async () => { + const { result } = renderHook(() => useMediaDeviceMicrophonePermission(), { + wrapper: getWrapper(undefined, ['device1', 'device2'], false), + }); + + expect(result.current.state).toBe('denied'); + expect(result.current.requestDevice).toBeUndefined(); + }); + it.each(states)('Should return permission state $state and requestDevice is $requestDevice', async ({ state, requestDevice }) => { + const { result } = renderHook(() => useMediaDeviceMicrophonePermission(), { + wrapper: getWrapper(state as PermissionState), + }); + + expect(result.current.state).toBe(state); + expect(typeof result.current.requestDevice).toBe(requestDevice); + }); + + it('Should return permission state granted and requestDevice is function if permissionStatus is undefined and availableAudioInputDevices has records', async () => { + const { result } = renderHook(() => useMediaDeviceMicrophonePermission(), { + wrapper: getWrapper(undefined, ['device1', 'device2']), + }); + + expect(result.current.state).toBe('granted'); + expect(typeof result.current.requestDevice).toBe('function'); + }); + + it('Should return permission state prompt and requestDevice is function if permissionStatus is undefined and availableAudioInputDevices is empty', async () => { + const { result } = renderHook(() => useMediaDeviceMicrophonePermission(), { + wrapper: getWrapper(undefined), + }); + + expect(result.current.state).toBe('prompt'); + expect(typeof result.current.requestDevice).toBe('function'); + }); +}); diff --git a/packages/ui-contexts/src/hooks/useMediaDevicePermission.ts b/packages/ui-contexts/src/hooks/useMediaDevicePermission.ts new file mode 100644 index 0000000000000..2edee8cc407b8 --- /dev/null +++ b/packages/ui-contexts/src/hooks/useMediaDevicePermission.ts @@ -0,0 +1,58 @@ +import { useContext } from 'react'; + +import { DeviceContext, isDeviceContextEnabled } from '../DeviceContext'; + +export const requestDevice = async ({ + onAccept, + onReject, +}: { + onAccept?: (stream: MediaStream) => void; + onReject?: (error: DOMException) => void; +}): Promise => { + if (!navigator.mediaDevices) { + return; + } + navigator.mediaDevices.getUserMedia({ audio: true }).then(onAccept, onReject); +}; + +const isPermissionDenied = (state: PermissionState): state is 'denied' => { + return state === 'denied'; +}; + +type DeniedReturn = { state: 'denied'; requestDevice?: never }; +type PromptOrGrantedReturn = { state: 'prompt' | 'granted'; requestDevice: typeof requestDevice }; + +/** + * @description Hook to check if the microphone permission is granted. If the permission is denied, or the permission is not requested, the hook will return a function to request the permission. Right now just the microphone permission is handled with this hook, since DeviceContext is only used for audio input and output. + * @returns { state: 'granted' } if the permission is granted + * @returns { state: 'denied' } if the permission is denied + * @returns { state: 'prompt', requestPrompt: function ({onAccept, onReject}) {} } if the permission is in prompt state. + */ +export const useMediaDeviceMicrophonePermission = (): DeniedReturn | PromptOrGrantedReturn => { + const context = useContext(DeviceContext); + + if (!isDeviceContextEnabled(context)) { + return { + state: 'denied', + }; + } + + const { permissionStatus, availableAudioInputDevices } = context; + + if (permissionStatus) { + if (isPermissionDenied(permissionStatus.state)) { + return { state: permissionStatus.state }; + } + + return { state: permissionStatus.state, requestDevice }; + } + + if (availableAudioInputDevices.length > 0) { + return { state: 'granted', requestDevice }; + } + + return { + state: 'prompt', + requestDevice, + }; +}; diff --git a/packages/ui-contexts/src/index.ts b/packages/ui-contexts/src/index.ts index 954193351beb6..b30813e345a41 100644 --- a/packages/ui-contexts/src/index.ts +++ b/packages/ui-contexts/src/index.ts @@ -96,6 +96,7 @@ export { useAccountsCustomFields } from './hooks/useAccountsCustomFields'; export { useUserPresence } from './hooks/useUserPresence'; export { useUnstoreLoginToken } from './hooks/useUnstoreLoginToken'; export { useOnLogout } from './hooks/useOnLogout'; +export { useMediaDeviceMicrophonePermission, type requestDevice } from './hooks/useMediaDevicePermission'; export { useWriteStream } from './hooks/useWriteStream'; export { UploadResult } from './ServerContext'; diff --git a/packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.spec.tsx b/packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.spec.tsx new file mode 100644 index 0000000000000..2fbb3b251a4d8 --- /dev/null +++ b/packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.spec.tsx @@ -0,0 +1,19 @@ +import { composeStories } from '@storybook/react'; +import { render } from '@testing-library/react'; +import { axe } from 'jest-axe'; + +import * as stories from './PermissionFlowModal.stories'; + +const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); + +test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { + const view = render(); + expect(view.baseElement).toMatchSnapshot(); +}); + +test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(); + + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); diff --git a/packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.stories.tsx b/packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.stories.tsx new file mode 100644 index 0000000000000..86f9815337519 --- /dev/null +++ b/packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.stories.tsx @@ -0,0 +1,66 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { ReactElement } from 'react'; + +import PermissionFlowModal from './PermissionFlowModal'; + +const noop = () => undefined; + +const meta = { + title: 'Components/Permission Flow', + component: PermissionFlowModal, + decorators: [ + mockAppRoot() + .withTranslations('en', 'core', { + VoIP_device_permission_required: 'Mic/speaker access required', + VoIP_allow_and_call: 'Allow and call', + VoIP_allow_and_accept: 'Allow and accept', + VoIP_cancel_and_reject: 'Cancel and reject', + Cancel: 'Cancel', + VoIP_device_permission_required_description: + 'Your web browser stopped {{workspaceUrl}} from using your microphone and/or speaker.\n\nAllow speaker and microphone access in your browser settings to prevent seeing this message again.', + }) + .buildStoryDecorator(), + (Story): ReactElement => , + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const PermissionFlowModalOutgoingPrompt: Story = { + args: { + onCancel: noop, + onConfirm: noop, + type: 'outgoingPrompt', + }, + name: 'Outgoing call, permission in prompt state', +}; + +export const PermissionFlowModalIncomingPrompt: Story = { + args: { + onCancel: noop, + onConfirm: noop, + type: 'incomingPrompt', + }, + name: 'Incoming call, permission in prompt state', +}; + +export const PermissionFlowModalDeviceChangePrompt: Story = { + args: { + onCancel: noop, + onConfirm: noop, + type: 'deviceChangePrompt', + }, + name: 'Device change, permission in prompt state', +}; + +export const PermissionFlowModalDenied: Story = { + args: { + onCancel: noop, + onConfirm: noop, + type: 'denied', + }, + name: 'Permission denied', +}; diff --git a/packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.tsx b/packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.tsx new file mode 100644 index 0000000000000..9674a5a7ec211 --- /dev/null +++ b/packages/ui-voip/src/components/PermissionFlow/PermissionFlowModal.tsx @@ -0,0 +1,107 @@ +import { css } from '@rocket.chat/css-in-js'; +import { + Box, + Button, + Modal, + ModalHeader, + ModalTitle, + ModalClose, + ModalContent, + ModalFooter, + ModalFooterControllers, +} from '@rocket.chat/fuselage'; +import { useAbsoluteUrl, useSetModal } from '@rocket.chat/ui-contexts'; +import { useId } from 'react'; +import { useTranslation } from 'react-i18next'; + +export type PermissionFlowModalType = 'denied' | 'incomingPrompt' | 'outgoingPrompt' | 'deviceChangePrompt'; + +type PermissionFlowModalProps = { + onCancel: () => void; + onConfirm: () => void; + type: PermissionFlowModalType; +}; + +// MarkdownText is a bit overkill for this +// This css rules ensures that `\n` actually breaks lines. +const breakSpaces = css` + white-space: break-spaces; +`; + +const getFooter = ( + type: PermissionFlowModalProps['type'], + { + onCancel, + onConfirm, + onClose, + t, + }: { onCancel: () => void; onConfirm: () => void; onClose: () => void; t: ReturnType['t'] }, +) => { + switch (type) { + case 'denied': + return [ + , + ]; + case 'incomingPrompt': + return [ + , + , + ]; + case 'outgoingPrompt': + return [ + , + , + ]; + case 'deviceChangePrompt': + return [ + , + , + ]; + } +}; + +const PermissionFlowModal = ({ onCancel, onConfirm, type }: PermissionFlowModalProps) => { + const { t } = useTranslation(); + const modalId = useId(); + const absoluteUrl = useAbsoluteUrl(); + const setModal = useSetModal(); + + const onClose = () => { + setModal(null); + }; + + return ( + + + {t('VoIP_device_permission_required')} + + + + + {t('VoIP_device_permission_required_description', { + workspaceUrl: absoluteUrl(''), + })} + + + + {getFooter(type, { onCancel, onConfirm, onClose, t })} + + + ); +}; + +export default PermissionFlowModal; diff --git a/packages/ui-voip/src/components/PermissionFlow/__snapshots__/PermissionFlowModal.spec.tsx.snap b/packages/ui-voip/src/components/PermissionFlow/__snapshots__/PermissionFlowModal.spec.tsx.snap new file mode 100644 index 0000000000000..2c5ba57f846e4 --- /dev/null +++ b/packages/ui-voip/src/components/PermissionFlow/__snapshots__/PermissionFlowModal.spec.tsx.snap @@ -0,0 +1,361 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`renders PermissionFlowModalDenied without crashing 1`] = ` + +
+ +
+
+
+

+ Mic/speaker access required +

+ +
+
+
+
+ + Your web browser stopped http://localhost:3000/ from using your microphone and/or speaker. + +Allow speaker and microphone access in your browser settings to prevent seeing this message again. + +
+
+ +
+
+
+ +`; + +exports[`renders PermissionFlowModalDeviceChangePrompt without crashing 1`] = ` + +
+ +
+
+
+

+ Mic/speaker access required +

+ +
+
+
+
+ + Your web browser stopped http://localhost:3000/ from using your microphone and/or speaker. + +Allow speaker and microphone access in your browser settings to prevent seeing this message again. + +
+
+ +
+
+
+ +`; + +exports[`renders PermissionFlowModalIncomingPrompt without crashing 1`] = ` + +
+ +
+
+
+

+ Mic/speaker access required +

+ +
+
+
+
+ + Your web browser stopped http://localhost:3000/ from using your microphone and/or speaker. + +Allow speaker and microphone access in your browser settings to prevent seeing this message again. + +
+
+ +
+
+
+ +`; + +exports[`renders PermissionFlowModalOutgoingPrompt without crashing 1`] = ` + +
+ +
+
+
+

+ Mic/speaker access required +

+ +
+
+
+
+ + Your web browser stopped http://localhost:3000/ from using your microphone and/or speaker. + +Allow speaker and microphone access in your browser settings to prevent seeing this message again. + +
+
+ +
+
+
+ +`; diff --git a/packages/ui-voip/src/components/VoipActions/VoipActions.stories.tsx b/packages/ui-voip/src/components/VoipActions/VoipActions.stories.tsx index 07c5601c5900a..622b4bbc25473 100644 --- a/packages/ui-voip/src/components/VoipActions/VoipActions.stories.tsx +++ b/packages/ui-voip/src/components/VoipActions/VoipActions.stories.tsx @@ -1,3 +1,4 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; import type { Meta, StoryFn } from '@storybook/react'; import VoipActions from './VoipActions'; @@ -7,6 +8,11 @@ const noop = () => undefined; export default { title: 'Components/VoipActions', component: VoipActions, + decorators: [ + mockAppRoot() + .withMicrophonePermissionState({ state: 'granted' } as PermissionStatus) + .buildStoryDecorator(), + ], } satisfies Meta; export const IncomingActions: StoryFn = () => { diff --git a/packages/ui-voip/src/components/VoipActions/VoipActions.tsx b/packages/ui-voip/src/components/VoipActions/VoipActions.tsx index 0cd3c95a2dc21..d17a68791a8d9 100644 --- a/packages/ui-voip/src/components/VoipActions/VoipActions.tsx +++ b/packages/ui-voip/src/components/VoipActions/VoipActions.tsx @@ -1,6 +1,7 @@ import { ButtonGroup } from '@rocket.chat/fuselage'; import { useTranslation } from 'react-i18next'; +import { useDevicePermissionPrompt } from '../../hooks/useDevicePermissionPrompt'; import ActionButton from '../VoipActionButton'; type VoipGenericActionsProps = { @@ -37,6 +38,12 @@ const isOngoing = (props: VoipActionsProps): props is VoipOngoingActionsProps => const VoipActions = ({ isMuted, isHeld, isDTMFActive, isTransferActive, ...events }: VoipActionsProps) => { const { t } = useTranslation(); + const onAcceptIncoming = useDevicePermissionPrompt({ + actionType: 'incoming', + onAccept: events.onAccept ?? (() => undefined), + onReject: events.onDecline ?? (() => undefined), + }); + return ( {isIncoming(events) && } @@ -77,7 +84,7 @@ const VoipActions = ({ isMuted, isHeld, isDTMFActive, isTransferActive, ...event {isOngoing(events) && } - {isIncoming(events) && } + {isIncoming(events) && } ); }; diff --git a/packages/ui-voip/src/components/VoipPopup/VoipPopup.stories.tsx b/packages/ui-voip/src/components/VoipPopup/VoipPopup.stories.tsx index 4c5a72497b68c..6939e30fe0e79 100644 --- a/packages/ui-voip/src/components/VoipPopup/VoipPopup.stories.tsx +++ b/packages/ui-voip/src/components/VoipPopup/VoipPopup.stories.tsx @@ -1,3 +1,4 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; import type { Meta, StoryFn } from '@storybook/react'; import VoipPopup from './VoipPopup'; @@ -5,6 +6,8 @@ import { createMockVoipProviders } from '../../tests/mocks'; const [MockedProviders, voipClient] = createMockVoipProviders(); +const appRoot = mockAppRoot().withMicrophonePermissionState({ state: 'granted' } as PermissionStatus); + export default { title: 'Components/VoipPopup', component: VoipPopup, @@ -14,6 +17,7 @@ export default { ), + appRoot.buildStoryDecorator(), ], } satisfies Meta; diff --git a/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.spec.tsx b/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.spec.tsx index 7a55f50fd09e6..b2e52863bf6b4 100644 --- a/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.spec.tsx +++ b/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.spec.tsx @@ -11,8 +11,20 @@ jest.mock('../../../hooks/useVoipAPI', () => ({ useVoipAPI: jest.fn(() => ({ makeCall, closeDialer })), })); +Object.defineProperty(global.navigator, 'mediaDevices', { + value: { + getUserMedia: jest.fn().mockImplementation(() => { + return Promise.resolve({ + getTracks: () => [], + }); + }), + }, +}); + +const appRoot = mockAppRoot().withMicrophonePermissionState({ state: 'granted' } as PermissionStatus); + it('should look good', async () => { - render(, { wrapper: mockAppRoot().build() }); + render(, { wrapper: appRoot.build() }); expect(screen.getByText('New_Call')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Device_settings/ })).toBeInTheDocument(); @@ -20,7 +32,7 @@ it('should look good', async () => { }); it('should only enable call button if input has value (keyboard)', async () => { - render(, { wrapper: mockAppRoot().build() }); + render(, { wrapper: appRoot.build() }); expect(screen.getByRole('button', { name: /Call/i })).toBeDisabled(); await userEvent.type(screen.getByLabelText('Phone_number'), '123'); @@ -28,7 +40,7 @@ it('should only enable call button if input has value (keyboard)', async () => { }); it('should only enable call button if input has value (mouse)', async () => { - render(, { wrapper: mockAppRoot().build() }); + render(, { wrapper: appRoot.build() }); expect(screen.getByRole('button', { name: /Call/i })).toBeDisabled(); @@ -39,7 +51,7 @@ it('should only enable call button if input has value (mouse)', async () => { }); it('should call methods makeCall and closeDialer when call button is clicked', async () => { - render(, { wrapper: mockAppRoot().build() }); + render(, { wrapper: appRoot.build() }); await userEvent.type(screen.getByLabelText('Phone_number'), '123'); await userEvent.click(screen.getByTestId(`dial-pad-button-1`)); diff --git a/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx b/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx index a35b28763bda4..bb890ae5214b4 100644 --- a/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx +++ b/packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx @@ -3,6 +3,7 @@ import { useState, forwardRef, Ref } from 'react'; import { useTranslation } from 'react-i18next'; import { VoipDialPad as DialPad, VoipSettingsButton as SettingsButton } from '../..'; +import { useDevicePermissionPrompt } from '../../../hooks/useDevicePermissionPrompt'; import { useVoipAPI } from '../../../hooks/useVoipAPI'; import type { PositionOffsets } from '../components/VoipPopupContainer'; import Container from '../components/VoipPopupContainer'; @@ -20,10 +21,13 @@ const VoipDialerView = forwardRef(function const { makeCall, closeDialer } = useVoipAPI(); const [number, setNumber] = useState(''); - const handleCall = () => { - makeCall(number); - closeDialer(); - }; + const handleCall = useDevicePermissionPrompt({ + actionType: 'outgoing', + onAccept: () => { + makeCall(number); + closeDialer(); + }, + }); return ( @@ -38,7 +42,7 @@ const VoipDialerView = forwardRef(function