diff --git a/.changeset/eighty-weeks-rush.md b/.changeset/eighty-weeks-rush.md new file mode 100644 index 0000000000000..d79aadd40c93a --- /dev/null +++ b/.changeset/eighty-weeks-rush.md @@ -0,0 +1,7 @@ +--- +"@rocket.chat/meteor": patch +"@rocket.chat/ui-client": patch +"@rocket.chat/ui-voip": patch +--- + +Introduces in the call widget a quick link that redirects to the participant's direct message diff --git a/.changeset/purple-jobs-swim.md b/.changeset/purple-jobs-swim.md new file mode 100644 index 0000000000000..f95ac7f889285 --- /dev/null +++ b/.changeset/purple-jobs-swim.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/ui-voip": minor +--- + +Introduces realtime user presence updates for the selected user when starting/transferring a voice call. diff --git a/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts b/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts index 6595c8e90fc48..3545e734004ca 100644 --- a/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts +++ b/apps/meteor/app/cloud/server/functions/getWorkspaceAccessToken.ts @@ -34,13 +34,17 @@ export async function getWorkspaceAccessToken(forceNew = false, scope = '', save const workspaceCredentials = await WorkspaceCredentials.getCredentialByScope(scope); if (workspaceCredentials && !hasWorkspaceAccessTokenExpired(workspaceCredentials) && !forceNew) { - SystemLogger.debug( - `Workspace credentials cache hit using scope: ${scope}. Avoiding generating a new access token from cloud services.`, - ); + SystemLogger.debug({ + msg: 'Workspace credentials cache hit. Avoiding generating a new access token from cloud services.', + scope, + }); return workspaceCredentials.accessToken; } - SystemLogger.debug(`Workspace credentials cache miss using scope: ${scope}, fetching new access token from cloud services.`); + SystemLogger.debug({ + msg: 'Workspace credentials cache miss, fetching new access token from cloud services.', + scope, + }); const accessToken = await getWorkspaceAccessTokenWithScope({ scope, throwOnError }); diff --git a/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts index b14d3a77ec4a0..2643b673c5b24 100644 --- a/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts +++ b/apps/meteor/app/cloud/server/functions/supportedVersionsToken/supportedVersionsToken.ts @@ -64,7 +64,10 @@ const cacheValueInSettings = ( reset: (retry?: number) => Promise; } => { const reset = async (retry?: number) => { - SystemLogger.debug(`Resetting cached value ${key} in settings`); + SystemLogger.debug({ + msg: 'Resetting cached value in settings', + key, + }); const value = await fn(retry); if ( @@ -181,7 +184,10 @@ const getSupportedVersionsToken = async (retry = 0) => { 5000 * Math.pow(2, retry), ); } else { - SystemLogger.error(`Failed to get supported versions from cloud after ${retry} retries.`); + SystemLogger.error({ + msg: 'Failed to get supported versions from cloud after retries.', + retry, + }); await buildVersionUpdateMessage(supportedVersions?.versions); } diff --git a/apps/meteor/app/custom-sounds/server/startup/custom-sounds.js b/apps/meteor/app/custom-sounds/server/startup/custom-sounds.js index 2f4f77aa0bd2d..117a7d3c9e759 100644 --- a/apps/meteor/app/custom-sounds/server/startup/custom-sounds.js +++ b/apps/meteor/app/custom-sounds/server/startup/custom-sounds.js @@ -20,7 +20,10 @@ Meteor.startup(() => { throw new Error(`Invalid RocketChatStore type [${storeType}]`); } - SystemLogger.info(`Using ${storeType} for custom sounds storage`); + SystemLogger.info({ + msg: 'Using custom sounds storage', + storeType, + }); let path = '~/uploads'; if (settings.get('CustomSounds_FileSystemPath') != null) { diff --git a/apps/meteor/app/emoji-custom/server/startup/emoji-custom.js b/apps/meteor/app/emoji-custom/server/startup/emoji-custom.js index 28e82cf0849ed..fed5123b115f4 100644 --- a/apps/meteor/app/emoji-custom/server/startup/emoji-custom.js +++ b/apps/meteor/app/emoji-custom/server/startup/emoji-custom.js @@ -21,7 +21,10 @@ Meteor.startup(() => { throw new Error(`Invalid RocketChatStore type [${storeType}]`); } - SystemLogger.info(`Using ${storeType} for custom emoji storage`); + SystemLogger.info({ + msg: 'Using custom emoji storage', + storeType, + }); let path = '~/uploads'; if (settings.get('EmojiUpload_FileSystemPath') != null) { diff --git a/apps/meteor/app/file-upload/server/config/_configUploadStorage.ts b/apps/meteor/app/file-upload/server/config/_configUploadStorage.ts index 55cc1afbbab16..8629304cb2984 100644 --- a/apps/meteor/app/file-upload/server/config/_configUploadStorage.ts +++ b/apps/meteor/app/file-upload/server/config/_configUploadStorage.ts @@ -13,7 +13,10 @@ const configStore = _.debounce(() => { const store = settings.get('FileUpload_Storage_Type'); if (store) { - SystemLogger.info(`Setting default file store to ${store}`); + SystemLogger.info({ + msg: 'Setting default file store', + store, + }); UploadFS.getStores().Avatars = UploadFS.getStore(`${store}:Avatars`); UploadFS.getStores().Uploads = UploadFS.getStore(`${store}:Uploads`); UploadFS.getStores().UserDataFiles = UploadFS.getStore(`${store}:UserDataFiles`); diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.ts index f7a8890e5bf58..8c1aa7b9d0e3e 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts @@ -537,12 +537,17 @@ export const FileUpload = { getStoreByName(handlerName?: string) { if (!handlerName) { - SystemLogger.error(`Empty Upload handler does not exists`); + SystemLogger.error({ + msg: 'Empty Upload handler does not exists', + }); throw new Error(`Empty Upload handler does not exists`); } if (this.handlers[handlerName] == null) { - SystemLogger.error(`Upload handler "${handlerName}" does not exists`); + SystemLogger.error({ + msg: 'Upload handler does not exists', + handlerName, + }); } return this.handlers[handlerName]; }, diff --git a/apps/meteor/app/lib/server/functions/setUserAvatar.ts b/apps/meteor/app/lib/server/functions/setUserAvatar.ts index e5a12b110e2b9..85e12ef044f44 100644 --- a/apps/meteor/app/lib/server/functions/setUserAvatar.ts +++ b/apps/meteor/app/lib/server/functions/setUserAvatar.ts @@ -114,7 +114,11 @@ export async function setUserAvatar( try { response = await fetch(dataURI, { redirect: 'error' }); } catch (e) { - SystemLogger.info(`Not a valid response, from the avatar url: ${encodeURI(dataURI)}`); + SystemLogger.info({ + msg: 'Not a valid response from the avatar url', + url: encodeURI(dataURI), + err: e, + }); throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${encodeURI(dataURI)}`, { function: 'setUserAvatar', url: dataURI, @@ -123,7 +127,12 @@ export async function setUserAvatar( if (response.status !== 200) { if (response.status !== 404) { - SystemLogger.info(`Error while handling the setting of the avatar from a url (${encodeURI(dataURI)}) for ${user.username}`); + SystemLogger.info({ + msg: 'Error while handling the setting of the avatar from a url', + url: encodeURI(dataURI), + username: user.username, + status: response.status, + }); throw new Meteor.Error( 'error-avatar-url-handling', `Error while handling avatar setting from a URL (${encodeURI(dataURI)}) for ${user.username}`, @@ -131,7 +140,11 @@ export async function setUserAvatar( ); } - SystemLogger.info(`Not a valid response, ${response.status}, from the avatar url: ${dataURI}`); + SystemLogger.info({ + msg: 'Not a valid response from the avatar url', + status: response.status, + url: dataURI, + }); throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${dataURI}`, { function: 'setUserAvatar', url: dataURI, @@ -139,9 +152,11 @@ export async function setUserAvatar( } if (!/image\/.+/.test(response.headers.get('content-type') || '')) { - SystemLogger.info( - `Not a valid content-type from the provided url, ${response.headers.get('content-type')}, from the avatar url: ${dataURI}`, - ); + SystemLogger.info({ + msg: 'Not a valid content-type from the provided avatar url', + contentType: response.headers.get('content-type'), + url: dataURI, + }); throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${dataURI}`, { function: 'setUserAvatar', url: dataURI, diff --git a/apps/meteor/app/livechat/server/sendMessageBySMS.ts b/apps/meteor/app/livechat/server/sendMessageBySMS.ts index 5b79806951ca1..2d370410fa337 100644 --- a/apps/meteor/app/livechat/server/sendMessageBySMS.ts +++ b/apps/meteor/app/livechat/server/sendMessageBySMS.ts @@ -63,7 +63,11 @@ callbacks.add( try { await SMSService.send(room.sms.from, visitor.phone[0].phoneNumber, message.msg, extraData); - callbackLogger.debug(`SMS message sent to ${visitor.phone[0].phoneNumber} via ${service}`); + callbackLogger.debug({ + msg: 'SMS message sent', + phoneNumber: visitor.phone[0].phoneNumber, + service, + }); } catch (e) { callbackLogger.error(e); } diff --git a/apps/meteor/app/mail-messages/server/functions/sendMail.ts b/apps/meteor/app/mail-messages/server/functions/sendMail.ts index 50435bc248129..c0e179d919d0a 100644 --- a/apps/meteor/app/mail-messages/server/functions/sendMail.ts +++ b/apps/meteor/app/mail-messages/server/functions/sendMail.ts @@ -57,7 +57,10 @@ export const sendMail = async function ({ email, }); - SystemLogger.debug(`Sending email to ${email}`); + SystemLogger.debug({ + msg: 'Sending email', + email, + }); return Mailer.send({ to: email, from, @@ -83,7 +86,10 @@ export const sendMail = async function ({ name: escapeHTML(user.name || ''), email: escapeHTML(email), }); - SystemLogger.debug(`Sending email to ${email}`); + SystemLogger.debug({ + msg: 'Sending email', + email, + }); await Mailer.send({ to: email, from, diff --git a/apps/meteor/app/message-mark-as-unread/server/unreadMessages.ts b/apps/meteor/app/message-mark-as-unread/server/unreadMessages.ts index 202dde93d062d..ccf972c561e31 100644 --- a/apps/meteor/app/message-mark-as-unread/server/unreadMessages.ts +++ b/apps/meteor/app/message-mark-as-unread/server/unreadMessages.ts @@ -69,7 +69,10 @@ export const unreadMessages = async (userId: string, firstUnreadMessage?: Pick role._id); if (roles.length !== normalizedRoleNamesOrIds.length) { - SystemLogger.warn(`Failed to convert some role names to ids: ${normalizedRoleNamesOrIds.join(', ')}`); + SystemLogger.warn({ + msg: 'Failed to convert some role names to ids', + roles: normalizedRoleNamesOrIds, + }); } if (!roles.length) { diff --git a/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts b/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts index 917032c54306e..3b93fe22c88b4 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/lib/settings.ts @@ -151,7 +151,10 @@ export const loadSamlServiceProviders = async function (): Promise { }; export const addSamlService = function (name: string): void { - SystemLogger.warn(`Adding ${name} is deprecated`); + SystemLogger.warn({ + msg: 'Adding SAML service is deprecated', + serviceName: name, + }); }; export const addSettings = async function (name: string): Promise { diff --git a/apps/meteor/app/meteor-accounts-saml/server/listener.ts b/apps/meteor/app/meteor-accounts-saml/server/listener.ts index 8cdf7c9e6f636..9af8c4d84c2d1 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/listener.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/listener.ts @@ -54,14 +54,17 @@ const middleware = async function (req: express.Request, res: ServerResponse, ne const service = SAMLUtils.getServiceProviderOptions(samlObject.serviceName); if (!service) { - SystemLogger.error(`${samlObject.serviceName} service provider not found`); + SystemLogger.error({ + msg: 'SAML service provider not found', + serviceName: samlObject.serviceName, + }); throw new Error('SAML Service Provider not found.'); } await SAML.processRequest(req, res, service, samlObject); } catch (err) { // @ToDo: Ideally we should send some error message to the client, but there's no way to do it on a redirect right now. - SystemLogger.error(err); + SystemLogger.error({ err }); const url = Meteor.absoluteUrl('home'); res.writeHead(302, { diff --git a/apps/meteor/app/push/server/apn.ts b/apps/meteor/app/push/server/apn.ts index cd335aa06bad6..634665cad9403 100644 --- a/apps/meteor/app/push/server/apn.ts +++ b/apps/meteor/app/push/server/apn.ts @@ -70,10 +70,17 @@ export const sendAPN = ({ void apnConnection.send(note, userToken).then((response) => { response.failed.forEach((failure) => { - logger.debug(`Got error code ${failure.status} for token ${userToken}`); + logger.debug({ + msg: 'Got error code for APN token', + status: failure.status, + token: userToken, + }); if (['400', '410'].includes(String(failure.status))) { - logger.debug(`Removing token ${userToken}`); + logger.debug({ + msg: 'Removing APN token', + token: userToken, + }); _removeToken({ apn: userToken, }); @@ -105,7 +112,10 @@ export const initAPN = ({ options, absoluteUrl }: { options: RequiredField`, err: response }); + logger.error({ msg: 'Error sending push to gateway', tries, err: response }); if (tries <= 4) { // [1, 2, 4, 8, 16] minutes (total 31) @@ -368,7 +368,11 @@ class PushClass { throw new Error('Push.send: option "text" not a string'); } - logger.debug(`send message "${notification.title}" to userId`, notification.userId); + logger.debug({ + msg: 'send message to userId', + title: notification.title, + userId: notification.userId, + }); const query = { userId: notification.userId, @@ -389,7 +393,12 @@ class PushClass { } if (settings.get('Log_Level') === '2') { - logger.debug(`Sent message "${notification.title}" to ${countApn.length} ios apps ${countGcm.length} android apps`); + logger.debug({ + msg: 'Sent message to apps', + title: notification.title, + iosApps: countApn.length, + androidApps: countGcm.length, + }); // Add some verbosity about the send result, making sure the developer // understands what just happened. @@ -489,7 +498,11 @@ class PushClass { try { await this.sendNotification(notification); } catch (error: any) { - logger.debug(`Could not send notification to user "${notification.userId}", Error: ${error.message}`); + logger.debug({ + msg: 'Could not send notification to user', + userId: notification.userId, + err: error, + }); logger.debug(error.stack); } } diff --git a/apps/meteor/client/providers/MediaCallProvider.tsx b/apps/meteor/client/providers/MediaCallProvider.tsx index 68d773d49e776..006b2bb067f22 100644 --- a/apps/meteor/client/providers/MediaCallProvider.tsx +++ b/apps/meteor/client/providers/MediaCallProvider.tsx @@ -17,6 +17,7 @@ const MediaCallProvider = ({ children }: { children: ReactNode }) => { onToggleWidget: undefined, onEndCall: undefined, peerInfo: undefined, + setOpenRoomId: undefined, }), [], ); diff --git a/apps/meteor/client/views/room/Room.tsx b/apps/meteor/client/views/room/Room.tsx index fed2a9da31e58..cc5a119f61c02 100644 --- a/apps/meteor/client/views/room/Room.tsx +++ b/apps/meteor/client/views/room/Room.tsx @@ -1,6 +1,7 @@ import { isInviteSubscription } from '@rocket.chat/core-typings'; import { ContextualbarSkeleton } from '@rocket.chat/ui-client'; import { useSetting, useRoomToolbox, useUserId } from '@rocket.chat/ui-contexts'; +import { useMediaCallOpenRoomTracker } from '@rocket.chat/ui-voip'; import type { ReactElement } from 'react'; import { createElement, lazy, memo, Suspense } from 'react'; import { FocusScope } from 'react-aria'; @@ -34,6 +35,8 @@ const Room = (): ReactElement => { const roomLabel = room.t === 'd' ? t('Conversation_with__roomName__', { roomName: room.name }) : t('Channel__roomName__', { roomName: room.name }); + useMediaCallOpenRoomTracker(room._id); + if (subscription && isInviteSubscription(subscription)) { return ( diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.spec.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.spec.tsx index 68a16d072ee68..0f7f038df95bf 100644 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.spec.tsx +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useUserMediaCallAction.spec.tsx @@ -46,6 +46,7 @@ describe('useUserMediaCallAction', () => { onToggleWidget: undefined, onEndCall: undefined, peerInfo: undefined, + setOpenRoomId: undefined, }); const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid), { wrapper: mockAppRoot().build() }); @@ -114,6 +115,7 @@ describe('useUserMediaCallAction', () => { onToggleWidget: mockOnToggleWidget, peerInfo: undefined, onEndCall: () => undefined, + setOpenRoomId: () => undefined, }); const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid)); @@ -133,6 +135,7 @@ describe('useUserMediaCallAction', () => { onToggleWidget: jest.fn(), peerInfo: undefined, onEndCall: () => undefined, + setOpenRoomId: () => undefined, }); const { result } = renderHook(() => useUserMediaCallAction(fakeUser, mockRid)); diff --git a/packages/ui-voip/src/components/Widget/WidgetHeader.tsx b/packages/ui-voip/src/components/Widget/WidgetHeader.tsx index bc76e085a77e9..8d635607be9a9 100644 --- a/packages/ui-voip/src/components/Widget/WidgetHeader.tsx +++ b/packages/ui-voip/src/components/Widget/WidgetHeader.tsx @@ -13,7 +13,9 @@ const WidgetHeader = ({ title, children }: WidgetHeaderProps): ReactElement => { {title} - {children} + + {children} + ); }; diff --git a/packages/ui-voip/src/context/MediaCallContext.ts b/packages/ui-voip/src/context/MediaCallContext.ts index 463bd335df102..b01b346c95b92 100644 --- a/packages/ui-voip/src/context/MediaCallContext.ts +++ b/packages/ui-voip/src/context/MediaCallContext.ts @@ -1,8 +1,6 @@ import type { UserStatus } from '@rocket.chat/core-typings'; -import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import type { Device } from '@rocket.chat/ui-contexts'; -import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import { createContext, useContext, useState } from 'react'; +import { createContext, useContext } from 'react'; import type { PeerAutocompleteOptions } from '../components'; @@ -40,6 +38,8 @@ type MediaCallContextType = { remoteMuted: boolean; remoteHeld: boolean; + onClickDirectMessage?: () => void; + onMute: () => void; onHold: () => void; @@ -56,6 +56,8 @@ type MediaCallContextType = { onSelectPeer: (peerInfo: PeerInfo) => void; + setOpenRoomId: (roomId: string | undefined) => void; + getAutocompleteOptions: (filter: string) => Promise; // This is used to get the peer info from the server in case it's not available in the autocomplete options. getPeerInfo: (id: string) => Promise; @@ -90,6 +92,8 @@ export const defaultMediaCallContextValue: MediaCallContextType = { onSelectPeer: () => undefined, + setOpenRoomId: () => undefined, + getAutocompleteOptions: () => Promise.resolve([]), getPeerInfo: () => Promise.resolve(undefined), }; @@ -99,6 +103,7 @@ type MediaCallExternalContextType = { onToggleWidget: (peerInfo?: PeerInfo) => void; onEndCall: () => void; peerInfo: PeerInfo | undefined; + setOpenRoomId: (roomId: string | undefined) => void; }; type MediaCallUnauthorizedContextType = { @@ -106,6 +111,7 @@ type MediaCallUnauthorizedContextType = { onToggleWidget: undefined; onEndCall: undefined; peerInfo: undefined; + setOpenRoomId: undefined; }; type MediaCallUnlicensedContextType = { @@ -113,6 +119,7 @@ type MediaCallUnlicensedContextType = { onToggleWidget: (peerInfo?: any) => void; onEndCall: undefined; peerInfo: undefined; + setOpenRoomId: undefined; }; const MediaCallContext = createContext( @@ -148,69 +155,12 @@ export const useMediaCallExternalContext = (): return context; } - return { state: context.state, onToggleWidget: context.onToggleWidget, onEndCall: context.onEndCall, peerInfo: context.peerInfo }; -}; - -const PREFIX_FIRST_OPTION = 'rcx-first-option-'; - -export const isFirstPeerAutocompleteOption = (value: string) => { - return value.startsWith(PREFIX_FIRST_OPTION); -}; - -const getFirstOption = (filter: string): PeerAutocompleteOptions => { - return { value: `${PREFIX_FIRST_OPTION}${filter}`, label: filter, avatarUrl: '' }; -}; - -export const usePeerAutocomplete = (onSelectPeer: (peerInfo: PeerInfo) => void, peerInfo: PeerInfo | undefined) => { - const { getAutocompleteOptions } = useMediaCallContext(); - const [filter, setFilter] = useState(''); - - const debouncedFilter = useDebouncedValue(filter, 400); - - const { data: options } = useQuery({ - queryKey: ['mediaCall/peerAutocomplete', debouncedFilter], - queryFn: async () => { - const options = await getAutocompleteOptions(debouncedFilter); - - if (debouncedFilter.length > 0) { - return [getFirstOption(debouncedFilter), ...options]; - } - - return options; - }, - placeholderData: keepPreviousData, - initialData: [], - }); - return { - options, - onChangeFilter: setFilter, - onChangeValue: (value: string | string[]) => { - if (Array.isArray(value)) { - return; - } - - if (isFirstPeerAutocompleteOption(value)) { - onSelectPeer({ number: value.replace(PREFIX_FIRST_OPTION, '') }); - return; - } - - const localInfo = options.find((option) => option.value === value); - - if (!localInfo) { - throw new Error(`Peer info not found for value: ${value}`); - } - - onSelectPeer({ - userId: localInfo.value, - displayName: localInfo.label, - avatarUrl: localInfo.avatarUrl, - status: localInfo.status as UserStatus, - }); - }, - value: peerInfo && 'userId' in peerInfo ? peerInfo.userId : undefined, - filter, - onKeypadPress: (key: string) => setFilter((filter) => filter + key), + state: context.state, + onToggleWidget: context.onToggleWidget, + onEndCall: context.onEndCall, + peerInfo: context.peerInfo, + setOpenRoomId: context.setOpenRoomId, }; }; diff --git a/packages/ui-voip/src/context/MediaCallProvider.tsx b/packages/ui-voip/src/context/MediaCallProvider.tsx index 2c665a9eb20ae..2ca12897d604a 100644 --- a/packages/ui-voip/src/context/MediaCallProvider.tsx +++ b/packages/ui-voip/src/context/MediaCallProvider.tsx @@ -1,4 +1,4 @@ -import { AnchorPortal } from '@rocket.chat/ui-client'; +import { AnchorPortal, useGoToDirectMessage } from '@rocket.chat/ui-client'; import type { Device } from '@rocket.chat/ui-contexts'; import { useEndpoint, @@ -12,7 +12,7 @@ import { useSetting, } from '@rocket.chat/ui-contexts'; import type { ReactNode } from 'react'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; @@ -36,6 +36,7 @@ const MediaCallProvider = ({ children }: MediaCallProviderProps) => { const user = useUser(); const { t } = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); + const [openRoomId, setOpenRoomId] = useState(undefined); const setModal = useSetModal(); @@ -57,6 +58,11 @@ const MediaCallProvider = ({ children }: MediaCallProviderProps) => { const forceSIPRouting = useSetting('VoIP_TeamCollab_SIP_Integration_For_Internal_Calls'); + const onClickDirectMessage = useGoToDirectMessage( + { username: session.peerInfo && 'username' in session.peerInfo ? session.peerInfo.username : undefined }, + openRoomId, + ); + // For some reason `exhaustive-deps` is complaining that "session" is not in the dependencies // But we're only using the changeDevice method from the session // So I'll just destructure it here @@ -262,6 +268,8 @@ const MediaCallProvider = ({ children }: MediaCallProviderProps) => { hidden: session.hidden, remoteMuted: session.remoteMuted, remoteHeld: session.remoteHeld, + onClickDirectMessage, + setOpenRoomId, onMute, onHold, onDeviceChange, diff --git a/packages/ui-voip/src/context/MockedMediaCallProvider.tsx b/packages/ui-voip/src/context/MockedMediaCallProvider.tsx index f361fdb2c5a19..ed0e429e450d1 100644 --- a/packages/ui-voip/src/context/MockedMediaCallProvider.tsx +++ b/packages/ui-voip/src/context/MockedMediaCallProvider.tsx @@ -16,11 +16,13 @@ type MockedMediaCallProviderProps = { remoteHeld?: boolean; muted?: boolean; held?: boolean; + onClickDirectMessage?: () => void; }; const MockedMediaCallProvider = ({ children, state = 'closed', + onClickDirectMessage = undefined, transferredBy = undefined, remoteMuted = false, remoteHeld = false, @@ -131,6 +133,8 @@ const MockedMediaCallProvider = ({ transferredBy, muted: mutedState, held: heldState, + setOpenRoomId: () => undefined, + onClickDirectMessage, remoteMuted, remoteHeld, onMute, diff --git a/packages/ui-voip/src/context/index.ts b/packages/ui-voip/src/context/index.ts index 8b1ca3d329105..8e187cb42cd72 100644 --- a/packages/ui-voip/src/context/index.ts +++ b/packages/ui-voip/src/context/index.ts @@ -1,4 +1,5 @@ -export { useMediaCallContext, useMediaCallExternalContext, default as MediaCallContext, usePeerAutocomplete } from './MediaCallContext'; +export { useMediaCallContext, useMediaCallExternalContext, default as MediaCallContext } from './MediaCallContext'; export type { PeerInfo, ConnectionState, MediaCallExternalState as MediaCallState } from './MediaCallContext'; -export { isFirstPeerAutocompleteOption, isCallingBlocked } from './MediaCallContext'; +export { isCallingBlocked } from './MediaCallContext'; +export { usePeerAutocomplete, isFirstPeerAutocompleteOption } from './usePeerAutocomplete'; export { default as MockedMediaCallProvider } from './MockedMediaCallProvider'; diff --git a/packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx b/packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx new file mode 100644 index 0000000000000..c59d5630fa1de --- /dev/null +++ b/packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx @@ -0,0 +1,328 @@ +import { UserStatus } from '@rocket.chat/core-typings'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook, waitFor, act } from '@testing-library/react'; + +import type { PeerInfo } from './MediaCallContext'; +import type { PeerAutocompleteOptions } from '../components'; +import MediaCallContext, { defaultMediaCallContextValue } from './MediaCallContext'; +import { usePeerAutocomplete, isFirstPeerAutocompleteOption } from './usePeerAutocomplete'; + +jest.mock('@rocket.chat/ui-contexts', () => ({ + ...jest.requireActual('@rocket.chat/ui-contexts'), + useUserPresence: jest.fn(() => ({ _id: 'user1', status: 'online' as const })), +})); + +jest.useFakeTimers(); + +const mockGetAutocompleteOptions = jest.fn(); +const mockOnSelectPeer = jest.fn(); + +const appRoot = () => + mockAppRoot() + .wrap((children) => ( + + {children} + + )) + .build(); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('isFirstPeerAutocompleteOption', () => { + it('should return true for values starting with prefix', () => { + expect(isFirstPeerAutocompleteOption('rcx-first-option-test')).toBe(true); + }); + + it('should return false for values not starting with prefix', () => { + expect(isFirstPeerAutocompleteOption('test')).toBe(false); + }); +}); + +describe('hook', () => { + it('should initialize with empty filter and no value', () => { + mockGetAutocompleteOptions.mockResolvedValue([]); + + const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), { + wrapper: appRoot(), + }); + + expect(result.current.filter).toBe(''); + expect(result.current.value).toBeUndefined(); + }); + + it('should update filter when onChangeFilter is called', async () => { + mockGetAutocompleteOptions.mockResolvedValue([]); + + const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), { + wrapper: appRoot(), + }); + + act(() => { + result.current.onChangeFilter('test'); + }); + + expect(result.current.filter).toBe('test'); + }); + + it('should fetch autocomplete options', async () => { + const mockOptions: PeerAutocompleteOptions[] = [ + { value: 'user1', label: 'User 1', avatarUrl: '' }, + { value: 'user2', label: 'User 2', avatarUrl: '' }, + ]; + mockGetAutocompleteOptions.mockResolvedValue(mockOptions); + + const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), { + wrapper: appRoot(), + }); + + await waitFor(() => { + expect(result.current.options).toEqual(mockOptions); + }); + }); + + it('should add first option when filter is not empty', async () => { + const mockOptions: PeerAutocompleteOptions[] = [{ value: 'user1', label: 'User 1', avatarUrl: '' }]; + mockGetAutocompleteOptions.mockResolvedValue(mockOptions); + + const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), { + wrapper: appRoot(), + }); + + act(() => { + result.current.onChangeFilter('123'); + }); + + await waitFor(() => { + expect(result.current.options).toHaveLength(2); + expect(result.current.options[0]).toEqual({ + value: 'rcx-first-option-123', + label: '123', + avatarUrl: '', + }); + }); + }); + + it('should return value when peerInfo has userId', () => { + mockGetAutocompleteOptions.mockResolvedValue([]); + const peerInfo: PeerInfo = { userId: 'user1', displayName: 'User 1' }; + + const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), { + wrapper: appRoot(), + }); + + expect(result.current.value).toBe('user1'); + }); + + it('should return undefined value when peerInfo has no userId', () => { + mockGetAutocompleteOptions.mockResolvedValue([]); + const peerInfo: PeerInfo = { number: '123456' }; + + const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), { + wrapper: appRoot(), + }); + + expect(result.current.value).toBeUndefined(); + }); + + describe('onChangeValue', () => { + it('should do nothing if value is an array', async () => { + mockGetAutocompleteOptions.mockResolvedValue([]); + + const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), { + wrapper: appRoot(), + }); + + act(() => { + result.current.onChangeValue(['value1', 'value2']); + }); + + expect(mockOnSelectPeer).not.toHaveBeenCalled(); + }); + + it('should call onSelectPeer with number when value is first option', async () => { + mockGetAutocompleteOptions.mockResolvedValue([]); + + const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), { + wrapper: appRoot(), + }); + + act(() => { + result.current.onChangeValue('rcx-first-option-123456'); + }); + + expect(mockOnSelectPeer).toHaveBeenCalledWith({ number: '123456' }); + }); + + it('should call onSelectPeer with full peer info when value matches option', async () => { + const mockOptions: PeerAutocompleteOptions[] = [ + { value: 'user1', label: 'User 1', avatarUrl: 'avatar.jpg', status: UserStatus.ONLINE }, + ]; + mockGetAutocompleteOptions.mockResolvedValue(mockOptions); + + const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), { + wrapper: appRoot(), + }); + + await waitFor(() => { + expect(result.current.options).toHaveLength(1); + }); + + act(() => { + result.current.onChangeValue('user1'); + }); + + expect(mockOnSelectPeer).toHaveBeenCalledWith({ + userId: 'user1', + displayName: 'User 1', + avatarUrl: 'avatar.jpg', + status: UserStatus.ONLINE, + }); + }); + + it('should throw error when value does not match any option', async () => { + mockGetAutocompleteOptions.mockResolvedValue([]); + + const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), { + wrapper: appRoot(), + }); + + await waitFor(() => { + expect(result.current.options).toEqual([]); + }); + + expect(() => { + act(() => { + result.current.onChangeValue('unknown-user'); + }); + }).toThrow('Peer info not found for value: unknown-user'); + }); + }); + + describe('onKeypadPress', () => { + it('should append key to filter', async () => { + mockGetAutocompleteOptions.mockResolvedValue([]); + + const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), { + wrapper: appRoot(), + }); + + act(() => { + result.current.onKeypadPress('1'); + }); + + expect(result.current.filter).toBe('1'); + + act(() => { + result.current.onKeypadPress('2'); + }); + + expect(result.current.filter).toBe('12'); + + act(() => { + result.current.onKeypadPress('3'); + }); + + expect(result.current.filter).toBe('123'); + }); + }); + + describe('user presence updates', () => { + it('should update peer status when user presence changes', async () => { + const { useUserPresence } = await import('@rocket.chat/ui-contexts'); + const mockUseUserPresence = useUserPresence as jest.MockedFunction; + + mockGetAutocompleteOptions.mockResolvedValue([]); + const peerInfo: PeerInfo = { userId: 'user1', displayName: 'User 1', status: UserStatus.ONLINE }; + + mockUseUserPresence.mockReturnValue({ _id: 'user1', status: UserStatus.AWAY, statusText: '' }); + + renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), { + wrapper: appRoot(), + }); + + await waitFor(() => { + expect(mockOnSelectPeer).toHaveBeenCalledWith({ + ...peerInfo, + status: 'away', + }); + }); + }); + + it('should not update peer status when status has not changed', async () => { + const { useUserPresence } = await import('@rocket.chat/ui-contexts'); + const mockUseUserPresence = useUserPresence as jest.MockedFunction; + + mockGetAutocompleteOptions.mockResolvedValue([]); + const peerInfo: PeerInfo = { userId: 'user1', displayName: 'User 1', status: UserStatus.ONLINE }; + + mockUseUserPresence.mockReturnValue({ _id: 'user1', status: UserStatus.ONLINE, statusText: '' }); + + renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), { + wrapper: appRoot(), + }); + + await waitFor(() => { + expect(mockOnSelectPeer).not.toHaveBeenCalled(); + }); + }); + + it('should not update when peerInfo is undefined', async () => { + const { useUserPresence } = await import('@rocket.chat/ui-contexts'); + const mockUseUserPresence = useUserPresence as jest.MockedFunction; + + mockGetAutocompleteOptions.mockResolvedValue([]); + mockUseUserPresence.mockReturnValue({ _id: 'user1', status: UserStatus.ONLINE, statusText: '' }); + + renderHook(() => usePeerAutocomplete(mockOnSelectPeer, undefined), { + wrapper: appRoot(), + }); + + await waitFor(() => { + expect(mockOnSelectPeer).not.toHaveBeenCalled(); + }); + }); + + it('should not update when peerInfo has no status property', async () => { + const { useUserPresence } = await import('@rocket.chat/ui-contexts'); + const mockUseUserPresence = useUserPresence as jest.MockedFunction; + + mockGetAutocompleteOptions.mockResolvedValue([]); + const peerInfo: PeerInfo = { number: '123456' }; + + mockUseUserPresence.mockReturnValue({ _id: 'user1', status: UserStatus.ONLINE, statusText: '' }); + + renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), { + wrapper: appRoot(), + }); + + await waitFor(() => { + expect(mockOnSelectPeer).not.toHaveBeenCalled(); + }); + }); + + it('should not update when useUserPresence returns no status', async () => { + const { useUserPresence } = await import('@rocket.chat/ui-contexts'); + const mockUseUserPresence = useUserPresence as jest.MockedFunction; + + mockGetAutocompleteOptions.mockResolvedValue([]); + const peerInfo: PeerInfo = { userId: 'user1', displayName: 'User 1', status: UserStatus.ONLINE }; + + mockUseUserPresence.mockReturnValue(undefined); + + renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), { + wrapper: appRoot(), + }); + + await waitFor(() => { + expect(mockOnSelectPeer).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/ui-voip/src/context/usePeerAutocomplete.ts b/packages/ui-voip/src/context/usePeerAutocomplete.ts new file mode 100644 index 0000000000000..e0f81fac92789 --- /dev/null +++ b/packages/ui-voip/src/context/usePeerAutocomplete.ts @@ -0,0 +1,89 @@ +import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { useUserPresence } from '@rocket.chat/ui-contexts'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; + +import type { PeerInfo } from './MediaCallContext'; +import { useMediaCallContext } from './MediaCallContext'; +import type { PeerAutocompleteOptions } from '../components'; +import { mediaCallQueryKeys } from '../utils/queryKeys'; + +const PREFIX_FIRST_OPTION = 'rcx-first-option-'; + +export const isFirstPeerAutocompleteOption = (value: string) => { + return value.startsWith(PREFIX_FIRST_OPTION); +}; + +const getFirstOption = (filter: string): PeerAutocompleteOptions => { + return { value: `${PREFIX_FIRST_OPTION}${filter}`, label: filter, avatarUrl: '' }; +}; + +export const usePeerAutocomplete = (onSelectPeer: (peerInfo: PeerInfo) => void, peerInfo: PeerInfo | undefined) => { + const { getAutocompleteOptions } = useMediaCallContext(); + const [filter, setFilter] = useState(''); + + const debouncedFilter = useDebouncedValue(filter, 400); + + const { data: options } = useQuery({ + queryKey: mediaCallQueryKeys.peerAutocomplete(debouncedFilter), + queryFn: async () => { + const options = await getAutocompleteOptions(debouncedFilter); + + if (debouncedFilter.length > 0) { + return [getFirstOption(debouncedFilter), ...options]; + } + + return options; + }, + placeholderData: keepPreviousData, + initialData: [], + }); + + const status = useUserPresence(peerInfo && 'userId' in peerInfo ? peerInfo.userId : undefined); + + useEffect(() => { + if (!peerInfo || !('status' in peerInfo) || !status?.status) { + return; + } + + if (status.status === peerInfo?.status) { + return; + } + + onSelectPeer({ + ...peerInfo, + status: status.status, + }); + }, [status, peerInfo, onSelectPeer]); + + return { + options, + onChangeFilter: setFilter, + onChangeValue: (value: string | string[]) => { + if (Array.isArray(value)) { + return; + } + + if (isFirstPeerAutocompleteOption(value)) { + onSelectPeer({ number: value.replace(PREFIX_FIRST_OPTION, '') }); + return; + } + + const localInfo = options.find((option) => option.value === value); + + if (!localInfo) { + throw new Error(`Peer info not found for value: ${value}`); + } + + onSelectPeer({ + userId: localInfo.value, + displayName: localInfo.label, + avatarUrl: localInfo.avatarUrl, + status: localInfo.status, + }); + }, + value: peerInfo && 'userId' in peerInfo ? peerInfo.userId : undefined, + filter, + onKeypadPress: (key: string) => setFilter((filter) => filter + key), + }; +}; diff --git a/packages/ui-voip/src/hooks/index.ts b/packages/ui-voip/src/hooks/index.ts index 3f9656838500c..7e9aeb8130db0 100644 --- a/packages/ui-voip/src/hooks/index.ts +++ b/packages/ui-voip/src/hooks/index.ts @@ -1,3 +1,4 @@ export * from './useDevicePermissionPrompt'; export { useDraggable } from './VoipPopupDraggable/DraggableCore'; export { useMediaCallAction } from './useMediaCallAction'; +export { useMediaCallOpenRoomTracker } from './useMediaCallOpenRoomTracker'; diff --git a/packages/ui-voip/src/hooks/useMediaCallOpenRoomTracker.ts b/packages/ui-voip/src/hooks/useMediaCallOpenRoomTracker.ts new file mode 100644 index 0000000000000..0059d158fb045 --- /dev/null +++ b/packages/ui-voip/src/hooks/useMediaCallOpenRoomTracker.ts @@ -0,0 +1,17 @@ +import { useEffect } from 'react'; + +import { useMediaCallExternalContext } from '../context/MediaCallContext'; + +export const useMediaCallOpenRoomTracker = (openRoomId?: string) => { + const { setOpenRoomId } = useMediaCallExternalContext(); + + useEffect(() => { + if (!setOpenRoomId) { + return; + } + setOpenRoomId(openRoomId); + return () => { + setOpenRoomId(undefined); + }; + }, [setOpenRoomId, openRoomId]); +}; diff --git a/packages/ui-voip/src/index.ts b/packages/ui-voip/src/index.ts index a659830740e53..ad103e6939a2f 100644 --- a/packages/ui-voip/src/index.ts +++ b/packages/ui-voip/src/index.ts @@ -2,7 +2,7 @@ export { default as MediaCallProvider } from './context/MediaCallProvider'; export { MediaCallContext, useMediaCallExternalContext as useMediaCallContext, isCallingBlocked } from './context'; export type { PeerInfo, MediaCallState } from './context'; -export { useMediaCallAction } from './hooks'; +export { useMediaCallAction, useMediaCallOpenRoomTracker } from './hooks'; export { CallHistoryContextualBar } from './views'; export type { InternalCallHistoryContact, ExternalCallHistoryContact, CallHistoryData } from './views'; diff --git a/packages/ui-voip/src/utils/queryKeys.ts b/packages/ui-voip/src/utils/queryKeys.ts new file mode 100644 index 0000000000000..d4a48e0ec8299 --- /dev/null +++ b/packages/ui-voip/src/utils/queryKeys.ts @@ -0,0 +1,4 @@ +export const mediaCallQueryKeys = { + all: ['mediaCall'] as const, + peerAutocomplete: (filter: string) => [...mediaCallQueryKeys.all, 'peerAutocomplete', filter] as const, +}; diff --git a/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.stories.tsx b/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.stories.tsx index d97afe302369d..291574bdb1680 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.stories.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.stories.tsx @@ -62,3 +62,11 @@ export const OngoingCallWithSlotsAndRemoteStatus: StoryFn = ); }; + +export const OngoingCallWithDmButton: StoryFn = () => { + return ( + undefined}> + + + ); +}; diff --git a/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx b/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx index f1827c563e69e..b07475b82c308 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx +++ b/packages/ui-voip/src/views/MediaCallWidget/OngoingCall.tsx @@ -21,8 +21,20 @@ import { useMediaCallContext } from '../../context'; const OngoingCall = () => { const { t } = useTranslation(); - const { muted, held, remoteMuted, remoteHeld, onMute, onHold, onForward, onEndCall, onTone, peerInfo, connectionState } = - useMediaCallContext(); + const { + muted, + held, + remoteMuted, + remoteHeld, + onMute, + onHold, + onForward, + onEndCall, + onTone, + peerInfo, + connectionState, + onClickDirectMessage, + } = useMediaCallContext(); const { element: keypad, buttonProps: keypadButtonProps } = useKeypad(onTone); @@ -41,6 +53,9 @@ const OngoingCall = () => { }> + {onClickDirectMessage && ( + + )} diff --git a/packages/ui-voip/src/views/MediaCallWidget/__snapshots__/MediaCallWidget.spec.tsx.snap b/packages/ui-voip/src/views/MediaCallWidget/__snapshots__/MediaCallWidget.spec.tsx.snap index dee814d8a2f74..d9c2c8430eb0e 100644 --- a/packages/ui-voip/src/views/MediaCallWidget/__snapshots__/MediaCallWidget.spec.tsx.snap +++ b/packages/ui-voip/src/views/MediaCallWidget/__snapshots__/MediaCallWidget.spec.tsx.snap @@ -31,7 +31,7 @@ exports[`renders IncomingCall without crashing 1`] = ` Incoming Call...