From af228951760939e72f98ccb787f9991a61bd27a9 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen Date: Thu, 20 Aug 2026 13:49:18 -0300 Subject: [PATCH] feat: Persistent Chat --- apps/meteor/client/definitions/global.d.ts | 9 + .../hooks/notification/useNotification.ts | 18 +- .../hooks/roomActions/useCallsRoomAction.ts | 6 +- apps/meteor/client/lib/appLayout.tsx | 8 +- .../meteor/client/lib/utils/mapRoomFromApi.ts | 23 + .../client/providers/MediaCallProvider.tsx | 8 +- .../client/providers/VideoConfProvider.tsx | 21 +- apps/meteor/client/startup/routes.tsx | 2 +- .../hooks/useMessageBlockContextValue.ts | 17 +- .../views/conference/AddParticipantsModal.tsx | 286 ++++ .../views/conference/ConferenceChat.tsx | 66 + .../ConferenceDisconnectedModal.tsx | 62 + .../conference/ConferenceEmbeddedPage.tsx | 111 ++ .../views/conference/ConferenceIframe.tsx | 25 + .../views/conference/ConferencePage.tsx | 44 - .../views/conference/ConferencePageError.tsx | 13 +- .../conference/ConferenceRedirectPage.tsx | 32 + .../views/conference/ConferenceRoom.tsx | 59 + .../conference/ConferenceRoomPreload.tsx | 78 + .../views/conference/ConferenceRoute.tsx | 45 +- .../conference/ConferenceScheduledPage.tsx | 24 + .../conference/ConferenceUnauthorizedPage.tsx | 37 + .../components/SideRail/SideRail.spec.tsx | 22 + .../components/SideRail/SideRail.stories.tsx | 82 + .../components/SideRail/SideRail.tsx | 14 + .../components/SideRail/SideRailAction.tsx | 15 + .../components/SideRail/SideRailActions.tsx | 26 + .../components/SideRail/SideRailPanel.tsx | 32 + .../__snapshots__/SideRail.spec.tsx.snap | 281 ++++ .../conference/components/SideRail/index.ts | 4 + .../views/conference/components/index.ts | 1 + .../conference/hooks/useConferenceCallUrl.ts | 16 + .../hooks/useConferenceEmbedded.tsx | 50 + .../hooks/useConferenceScheduled.tsx | 17 + .../hooks/useConfinedNavigation.spec.ts | 309 ++++ .../conference/hooks/useConfinedNavigation.ts | 158 ++ .../views/conference/hooks/usePexipPlugin.ts | 139 ++ .../OngoingConferenceBanner.tsx | 32 + .../client/views/room/body/RoomBody.tsx | 3 + .../composer/RoomComposer/RoomComposer.tsx | 10 +- .../VideoConfList/VideoConfList.stories.tsx | 97 ++ .../VideoConfList/VideoConfList.tsx | 39 +- .../VideoConfList/VideoConfListItem.tsx | 58 +- .../VideoConfList/VideoConfSectionDivider.tsx | 26 + .../VideoConference/VideoConfList/mocks.ts | 57 + .../VideoConfList/useVideoConfList.ts | 24 +- .../hooks/useVideoConfOpenCall.tsx | 49 +- .../views/room/hooks/useOpenRoomById.tsx | 117 ++ apps/meteor/client/views/root/AppLayout.tsx | 2 + .../views/root/MainLayout/MainLayout.tsx | 9 +- .../MainLayout/TwoFactorAuthSetupCheck.tsx | 3 +- .../root/hooks/useExternalRouteNavigation.ts | 37 + apps/meteor/server/api/v1/videoConference.ts | 118 +- apps/meteor/server/lib/videoConfProviders.ts | 9 +- .../modules/listeners/listeners.module.ts | 4 + .../notifications/notifications.module.ts | 30 +- .../services/video-conference/service.ts | 1389 +++++++++++++---- apps/meteor/server/settings/pexip.ts | 40 +- packages/core-services/src/events/Events.ts | 1 + .../src/types/IVideoConfService.ts | 27 +- packages/core-typings/src/INotification.ts | 10 + packages/core-typings/src/IVideoConference.ts | 10 + packages/ddp-client/src/types/streams.ts | 3 + packages/desktop-api/src/index.ts | 4 + .../VideoConferenceBlock.tsx | 10 +- .../src/contexts/UiKitContext.ts | 3 + packages/i18n/src/locales/de.i18n.json | 26 + packages/i18n/src/locales/en.i18n.json | 26 + .../src/models/IVideoConferenceModel.ts | 23 +- packages/models/src/models/VideoConference.ts | 118 +- packages/pexip/package.json | 1 + .../pexip/src/definition/PexipSettings.ts | 5 + packages/pexip/src/endpoints/endpoint.ts | 37 + packages/pexip/src/endpoints/eventSink.ts | 86 +- .../src/endpoints/serviceConfiguration.ts | 24 +- packages/pexip/src/videoConfProvider.ts | 104 +- .../VideoConfAddParticipantsProps.ts | 35 + .../VideoConfJoinScheduledProps.ts | 21 + .../src/v1/videoConference/index.ts | 25 +- .../src/providers/TooltipProvider.tsx | 2 +- yarn.lock | 1 + 81 files changed, 4335 insertions(+), 580 deletions(-) create mode 100644 apps/meteor/client/lib/utils/mapRoomFromApi.ts create mode 100644 apps/meteor/client/views/conference/AddParticipantsModal.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceChat.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceDisconnectedModal.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceEmbeddedPage.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceIframe.tsx delete mode 100644 apps/meteor/client/views/conference/ConferencePage.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceRedirectPage.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceRoom.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceRoomPreload.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceScheduledPage.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceUnauthorizedPage.tsx create mode 100644 apps/meteor/client/views/conference/components/SideRail/SideRail.spec.tsx create mode 100644 apps/meteor/client/views/conference/components/SideRail/SideRail.stories.tsx create mode 100644 apps/meteor/client/views/conference/components/SideRail/SideRail.tsx create mode 100644 apps/meteor/client/views/conference/components/SideRail/SideRailAction.tsx create mode 100644 apps/meteor/client/views/conference/components/SideRail/SideRailActions.tsx create mode 100644 apps/meteor/client/views/conference/components/SideRail/SideRailPanel.tsx create mode 100644 apps/meteor/client/views/conference/components/SideRail/__snapshots__/SideRail.spec.tsx.snap create mode 100644 apps/meteor/client/views/conference/components/SideRail/index.ts create mode 100644 apps/meteor/client/views/conference/components/index.ts create mode 100644 apps/meteor/client/views/conference/hooks/useConferenceCallUrl.ts create mode 100644 apps/meteor/client/views/conference/hooks/useConferenceEmbedded.tsx create mode 100644 apps/meteor/client/views/conference/hooks/useConferenceScheduled.tsx create mode 100644 apps/meteor/client/views/conference/hooks/useConfinedNavigation.spec.ts create mode 100644 apps/meteor/client/views/conference/hooks/useConfinedNavigation.ts create mode 100644 apps/meteor/client/views/conference/hooks/usePexipPlugin.ts create mode 100644 apps/meteor/client/views/room/OngoingConferenceBanner/OngoingConferenceBanner.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfList.stories.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfSectionDivider.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/mocks.ts create mode 100644 apps/meteor/client/views/room/hooks/useOpenRoomById.tsx create mode 100644 apps/meteor/client/views/root/hooks/useExternalRouteNavigation.ts create mode 100644 packages/pexip/src/endpoints/endpoint.ts create mode 100644 packages/rest-typings/src/v1/videoConference/VideoConfAddParticipantsProps.ts create mode 100644 packages/rest-typings/src/v1/videoConference/VideoConfJoinScheduledProps.ts diff --git a/apps/meteor/client/definitions/global.d.ts b/apps/meteor/client/definitions/global.d.ts index 1f1072ec6313b..c8e3c611d3542 100644 --- a/apps/meteor/client/definitions/global.d.ts +++ b/apps/meteor/client/definitions/global.d.ts @@ -4,6 +4,15 @@ declare global { interface Window { RocketChatDesktop?: IRocketChatDesktop; + // Bridge injected into the desktop app's internal video-chat window (separate from + // `RocketChatDesktop`, which is only present in the main app webview). + videoCallWindow?: { + // Navigate the main app window to an in-app route (e.g. "/channel/general") and focus it. + openInMainWindow?: (path: string) => void; + // Close the conference window (renderer `window.close()` can't close a main-process window). + close?: () => void; + }; + /** @deprecated use `window.RTCPeerConnection` */ mozRTCPeerConnection?: RTCPeerConnection; /** @deprecated use `window.RTCPeerConnection` */ diff --git a/apps/meteor/client/hooks/notification/useNotification.ts b/apps/meteor/client/hooks/notification/useNotification.ts index 88296e3334323..4ff42eba25925 100644 --- a/apps/meteor/client/hooks/notification/useNotification.ts +++ b/apps/meteor/client/hooks/notification/useNotification.ts @@ -2,6 +2,7 @@ import type { INotificationDesktop } from '@rocket.chat/core-typings'; import { useStableCallback } from '@rocket.chat/fuselage-hooks'; import { Random } from '@rocket.chat/random'; import { useRouter, useUserPreference } from '@rocket.chat/ui-contexts'; +import { useVideoConfJoinCall } from '@rocket.chat/ui-video-conf'; import { useNotificationAllowed } from './useNotificationAllowed'; import { getUserAvatarURL } from '../../../app/utils/client'; @@ -10,8 +11,9 @@ import { stripTags } from '../../../lib/utils/stringUtils'; import { onClientMessageReceived } from '../../lib/onClientMessageReceived'; export const useNotification = () => { - const requireInteraction = useUserPreference('desktopNotificationRequireInteraction'); + const requireInteractionPreference = useUserPreference('desktopNotificationRequireInteraction'); const router = useRouter(); + const joinCall = useVideoConfJoinCall(); const notificationAllowed = useNotificationAllowed(); const notify = useStableCallback(async (notification: INotificationDesktop) => { @@ -22,6 +24,9 @@ export const useNotification = () => { return; } + // A notification can opt into staying until interacted with, on top of the user preference. + const requireInteraction = Boolean(notification.requireInteraction || requireInteractionPreference); + const { rid, name: roomName, _id: msgId } = notification.payload; if (!rid) { return; @@ -39,6 +44,7 @@ export const useNotification = () => { canReply: true, silent: true, requireInteraction, + ...(window.RocketChatDesktop && notification.actions?.length ? { actions: notification.actions } : {}), } as NotificationOptions & { canReply?: boolean; }); @@ -59,6 +65,16 @@ export const useNotification = () => { }, }), ); + + // "Join" action (desktop app): join the call the same way the ongoing-call banner does. + const { conferenceId } = notification.payload; + if (conferenceId) { + n.addEventListener('action', () => { + n.close(); + window.focus(); + joinCall(conferenceId); + }); + } } n.onclick = () => { diff --git a/apps/meteor/client/hooks/roomActions/useCallsRoomAction.ts b/apps/meteor/client/hooks/roomActions/useCallsRoomAction.ts index 440628e722a1f..a0132f90377c0 100644 --- a/apps/meteor/client/hooks/roomActions/useCallsRoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useCallsRoomAction.ts @@ -22,14 +22,14 @@ export const useCallsRoomAction = () => { return { id: 'calls', groups: ['channel', 'group', 'team', 'direct', 'direct_multiple'], - icon: 'phone', - title: 'Calls', + icon: 'history', + title: 'Conference_call_history', ...(federated && { tooltip: t('core.Video_Call_unavailable_for_this_type_of_room'), disabled: true, }), tabComponent: VideoConfList, - order: 999, + order: 8, }; }, [licensed, federated, t]); }; diff --git a/apps/meteor/client/lib/appLayout.tsx b/apps/meteor/client/lib/appLayout.tsx index 0f2fc6920b729..0fcffb5a4f508 100644 --- a/apps/meteor/client/lib/appLayout.tsx +++ b/apps/meteor/client/lib/appLayout.tsx @@ -25,13 +25,15 @@ class AppLayoutSubscription extends Emitter<{ update: void }> { this.setCurrentValue(element); } - wrap(element: ReactNode): ReactNode { + // `embedded` standalone views (e.g. the conference page) omit the global announcement/banner + // chrome so app-level banners (E2E password prompt, admin announcements) don't bleed into them. + wrap(element: ReactNode, { embedded = false }: { embedded?: boolean } = {}): ReactNode { return ( - - + {!embedded && } + {!embedded && } {element} diff --git a/apps/meteor/client/lib/utils/mapRoomFromApi.ts b/apps/meteor/client/lib/utils/mapRoomFromApi.ts new file mode 100644 index 0000000000000..e4b797859cbdc --- /dev/null +++ b/apps/meteor/client/lib/utils/mapRoomFromApi.ts @@ -0,0 +1,23 @@ +import type { IRoom, Serialized } from '@rocket.chat/core-typings'; + +import { mapMessageFromApi } from './mapMessageFromApi'; + +export const mapRoomFromApi = ({ + _updatedAt, + lm, + ts, + lastMessage, + webRtcCallStartTime, + usersWaitingForE2EKeys, + ...room +}: Serialized): IRoom => ({ + ...room, + _updatedAt: new Date(_updatedAt), + ...(lm && { lm: new Date(lm) }), + ...(ts && { ts: new Date(ts) }), + ...(lastMessage && { lastMessage: mapMessageFromApi(lastMessage) }), + ...(webRtcCallStartTime && { webRtcCallStartTime: new Date(webRtcCallStartTime) }), + ...(usersWaitingForE2EKeys && { + usersWaitingForE2EKeys: usersWaitingForE2EKeys.map((user) => ({ ...user, ts: new Date(user.ts) })), + }), +}); diff --git a/apps/meteor/client/providers/MediaCallProvider.tsx b/apps/meteor/client/providers/MediaCallProvider.tsx index 3aec25aa6ed26..c84aad6ff78a8 100644 --- a/apps/meteor/client/providers/MediaCallProvider.tsx +++ b/apps/meteor/client/providers/MediaCallProvider.tsx @@ -1,4 +1,4 @@ -import { usePermission } from '@rocket.chat/ui-contexts'; +import { usePermission, useCurrentRoutePath } from '@rocket.chat/ui-contexts'; import { MediaCallProvider as MediaCallProviderBase } from '@rocket.chat/ui-voip'; import { MediaCallAppActionsProvider } from '@rocket.chat/ui-voip/dist/experimental/AppActionButtons'; import type { ReactNode } from 'react'; @@ -13,9 +13,13 @@ const MediaCallProvider = ({ children }: MediaCallProviderProps) => { const canMakeExternalCall = usePermission('allow-external-voice-calls'); const { actions, handleInteraction } = useMediaCallWidgetAppsActionButtons(); + const currentRoute = useCurrentRoutePath(); + + const isConferenceRoute = currentRoute?.includes('/conference'); + const { data: hasModule = false } = useHasLicenseModule('teams-voip'); - const enabled = hasModule && (canMakeInternalCall || canMakeExternalCall); + const enabled = hasModule && (canMakeInternalCall || canMakeExternalCall) && !isConferenceRoute; return ( diff --git a/apps/meteor/client/providers/VideoConfProvider.tsx b/apps/meteor/client/providers/VideoConfProvider.tsx index bbcfad32a7d03..aa2a5afc32692 100644 --- a/apps/meteor/client/providers/VideoConfProvider.tsx +++ b/apps/meteor/client/providers/VideoConfProvider.tsx @@ -1,4 +1,4 @@ -import { useToastMessageDispatch, useSetting } from '@rocket.chat/ui-contexts'; +import { useRouter, useToastMessageDispatch, useSetting } from '@rocket.chat/ui-contexts'; import type { VideoConfPopupPayload, VideoConfContextValue } from '@rocket.chat/ui-video-conf'; import { VideoConfContext } from '@rocket.chat/ui-video-conf'; import type { ReactNode } from 'react'; @@ -6,15 +6,20 @@ import { useState, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { VideoConfManager } from '../lib/VideoConfManager'; +import { absoluteUrl } from '../lib/absoluteUrl'; import VideoConfPopups from '../views/room/contextualBar/VideoConference/VideoConfPopups'; import { useVideoConfOpenCall } from '../views/room/contextualBar/VideoConference/hooks/useVideoConfOpenCall'; export type VideoConfContextProviderProps = { children: ReactNode }; +// The internal Pexip integration (core provider) replaces the external Pexip app. +const PEXIP_PROVIDER_NAME = 'core.pexip'; + const VideoConfContextProvider = ({ children }: VideoConfContextProviderProps) => { const [outgoing, setOutgoing] = useState(); const handleOpenCall = useVideoConfOpenCall(); const dispatchToastMessage = useToastMessageDispatch(); + const router = useRouter(); const { t } = useTranslation(); const logLevel = useSetting('Log_Level', 0); @@ -22,10 +27,18 @@ const VideoConfContextProvider = ({ children }: VideoConfContextProviderProps) = useEffect( () => - VideoConfManager.on('call/join', (props) => { - handleOpenCall(props.url, props.providerName); + VideoConfManager.on('call/join', ({ url, callId, providerName }) => { + // When the internal Pexip integration is the provider, open the in-product conference + // page (persistent chat + call) in a new tab — mirroring voice-call escalation — instead + // of the external provider URL. + if (providerName === PEXIP_PROVIDER_NAME) { + handleOpenCall(absoluteUrl(router.buildRoutePath({ name: 'conference', params: { id: callId } })), providerName); + return; + } + + handleOpenCall(url, providerName); }), - [handleOpenCall], + [handleOpenCall, router], ); useEffect( diff --git a/apps/meteor/client/startup/routes.tsx b/apps/meteor/client/startup/routes.tsx index 19c4ebb65fb83..e8ef56c73a990 100644 --- a/apps/meteor/client/startup/routes.tsx +++ b/apps/meteor/client/startup/routes.tsx @@ -211,7 +211,7 @@ router.defineRoutes([ { path: '/conference/:id', id: 'conference', - element: appLayout.wrap(), + element: appLayout.wrap(, { embedded: true }), }, { path: '/setup-wizard/:step?', diff --git a/apps/meteor/client/uikit/hooks/useMessageBlockContextValue.ts b/apps/meteor/client/uikit/hooks/useMessageBlockContextValue.ts index 0fe1ae17256e4..e009cff1c13f3 100644 --- a/apps/meteor/client/uikit/hooks/useMessageBlockContextValue.ts +++ b/apps/meteor/client/uikit/hooks/useMessageBlockContextValue.ts @@ -1,7 +1,7 @@ import type { IRoom, IMessage } from '@rocket.chat/core-typings'; import { useStableCallback } from '@rocket.chat/fuselage-hooks'; import type { UiKitContext } from '@rocket.chat/fuselage-ui-kit'; -import { useRoomToolbox } from '@rocket.chat/ui-contexts'; +import { useRoomToolbox, useRouter } from '@rocket.chat/ui-contexts'; import { useVideoConfDispatchOutgoing, useVideoConfIsCalling, @@ -10,7 +10,7 @@ import { useVideoConfLoadCapabilities, useVideoConfSetPreferences, } from '@rocket.chat/ui-video-conf'; -import type { ContextType } from 'react'; +import { useCallback, useSyncExternalStore, type ContextType } from 'react'; import { useUiKitActionManager } from './useUiKitActionManager'; import { useVideoConfWarning } from '../../views/room/contextualBar/VideoConference/hooks/useVideoConfWarning'; @@ -24,6 +24,14 @@ export const useMessageBlockContextValue = (rid: IRoom['_id'], mid: IMessage['_i const dispatchPopup = useVideoConfDispatchOutgoing(); const loadVideoConfCapabilities = useVideoConfLoadCapabilities(); + // Inside a conference window, block message-block actions that would open/join another conference. + const router = useRouter(); + const routeName = useSyncExternalStore( + router.subscribeToRouteChange, + useCallback(() => router.getRouteName(), [router]), + ); + const videoConfJoinDisabled = routeName === 'conference'; + const handleOpenVideoConf = useStableCallback(async (rid: IRoom['_id']) => { if (isCalling || isRinging) { return; @@ -45,6 +53,10 @@ export const useMessageBlockContextValue = (rid: IRoom['_id'], mid: IMessage['_i action: ({ appId, actionId, blockId, value }, event) => { if (appId === 'videoconf-core') { event.preventDefault(); + // Don't let a user in a conference open/join another conference from a message block. + if (videoConfJoinDisabled && (actionId === 'join' || actionId === 'callBack')) { + return undefined; + } setPreferences({ mic: true, cam: false }); if (actionId === 'join') { return joinCall(blockId); @@ -77,6 +89,7 @@ export const useMessageBlockContextValue = (rid: IRoom['_id'], mid: IMessage['_i }); }, rid, + videoConfJoinDisabled, values: {}, // TODO: this is a hack to make the context work, but it should be removed }; }; diff --git a/apps/meteor/client/views/conference/AddParticipantsModal.tsx b/apps/meteor/client/views/conference/AddParticipantsModal.tsx new file mode 100644 index 0000000000000..377b6bdddc27a --- /dev/null +++ b/apps/meteor/client/views/conference/AddParticipantsModal.tsx @@ -0,0 +1,286 @@ +import type { UserStatus } from '@rocket.chat/core-typings'; +import { + AutoComplete, + Box, + Button, + CheckBox, + Field, + FieldDescription, + FieldRow, + Icon, + IconButton, + Label, + Modal, + ModalClose, + ModalContent, + ModalFooter, + ModalFooterControllers, + ModalHeader, + ModalHeaderText, + ModalTitle, + Option, + StatusBullet, +} from '@rocket.chat/fuselage'; +import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useId, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Rooms } from '../../stores'; + +// Mirrors the VoIP widget's PeerAutocomplete: a typed value becomes a synthetic top option so the +// user can add a raw phone number that isn't a known user. +const PREFIX_FIRST_OPTION = 'rcx-first-option-'; +const isFirstOption = (value: string) => value.startsWith(PREFIX_FIRST_OPTION); + +type AutocompleteUser = { _id: string; username: string; name?: string; status?: UserStatus }; + +type SelectedParticipant = { kind: 'user'; username: string; name: string; status?: UserStatus } | { kind: 'number'; number: string }; + +const keyOf = (participant: SelectedParticipant) => + participant.kind === 'user' ? `user:${participant.username}` : `number:${participant.number}`; + +type AddParticipantsModalProps = { + callId: string; + rid: string; + onClose: () => void; + onDialOut?: (destination: string) => void; +}; + +const AddParticipantsModal = ({ callId, rid, onClose, onDialOut }: AddParticipantsModalProps) => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const queryClient = useQueryClient(); + + const [filter, setFilter] = useState(''); + const [selected, setSelected] = useState([]); + const [adding, setAdding] = useState(false); + // Checked: add the users to the current room (keeping its history). Unchecked: create a discussion + // so the new participants don't get the room's history. Not offered for DMs (always a discussion). + const [keepHistory, setKeepHistory] = useState(true); + const keepHistoryId = useId(); + const debouncedFilter = useDebouncedValue(filter, 300); + + // The room is already loaded into the store by the conference chat (EmbeddedPreload). + const room = Rooms.use((state) => state.get(rid)); + const isPrivate = room?.t === 'p'; + // A DM can't grow, so adding participants spins up a discussion server-side instead of inviting. + const isDirect = room?.t === 'd'; + + const getUsers = useEndpoint('GET', '/v1/users.autocomplete'); + const addParticipants = useEndpoint('POST', '/v1/video-conference.add-participants'); + + // Exclude users already in the room from the autocomplete so they can't be selected again. DMs + // expose their members on the room doc; other room types are fetched from the members endpoint. + const getMembers = useEndpoint('GET', isPrivate ? '/v1/groups.members' : '/v1/channels.members'); + const membersQuery = useQuery({ + enabled: !!room && !isDirect, + queryKey: ['conference', 'add-participants', 'members', rid, room?.t], + queryFn: () => getMembers({ roomId: rid, count: 100 }), + }); + + const memberUsernames = useMemo(() => { + if (isDirect) { + return room?.usernames ?? []; + } + return (membersQuery.data?.members ?? []).map((member) => member.username).filter((username): username is string => !!username); + }, [isDirect, room?.usernames, membersQuery.data]); + + const selectedUsernames = useMemo( + () => selected.flatMap((participant) => (participant.kind === 'user' ? [participant.username] : [])), + [selected], + ); + + const exceptions = useMemo(() => [...memberUsernames, ...selectedUsernames], [memberUsernames, selectedUsernames]); + + const usersQuery = useQuery({ + enabled: !!room, + queryKey: ['conference', 'add-participants', 'autocomplete', debouncedFilter, exceptions], + queryFn: async () => { + const { items } = await getUsers({ selector: JSON.stringify({ term: debouncedFilter, exceptions }) }); + return items as AutocompleteUser[]; + }, + placeholderData: keepPreviousData, + }); + + const options = useMemo(() => { + const userOptions = (usersQuery.data ?? []).map((user) => ({ + value: user.username, + label: user.name || user.username, + status: user.status, + })); + + // Offer the typed text as a phone number, just like the VoIP dial input. + if (debouncedFilter.length > 0) { + return [{ value: `${PREFIX_FIRST_OPTION}${debouncedFilter}`, label: debouncedFilter, status: undefined }, ...userOptions]; + } + + return userOptions; + }, [usersQuery.data, debouncedFilter]); + + const addParticipant = (participant: SelectedParticipant) => { + setSelected((prev) => (prev.some((current) => keyOf(current) === keyOf(participant)) ? prev : [...prev, participant])); + setFilter(''); + }; + + const handleSelect = (value: string | string[]) => { + if (Array.isArray(value)) { + return; + } + + if (isFirstOption(value)) { + addParticipant({ kind: 'number', number: value.replace(PREFIX_FIRST_OPTION, '') }); + return; + } + + const option = options.find((current) => current.value === value); + if (!option) { + return; + } + addParticipant({ kind: 'user', username: option.value, name: option.label, status: option.status }); + }; + + const handleRemove = (key: string) => setSelected((prev) => prev.filter((participant) => keyOf(participant) !== key)); + + // 'invite' adds the users to the current room (they see its history); 'discussion' spins up a new + // discussion off the room instead, so the new participants don't get the room's history. DMs only + // support 'discussion' (they can't grow). + const handleAdd = async (mode: 'invite' | 'discussion') => { + if (!selected.length) { + return; + } + setAdding(true); + try { + const usersToAdd = selected.flatMap((participant) => (participant.kind === 'user' ? [participant.username] : [])); + const numbersToDial = selected.flatMap((participant) => (participant.kind === 'number' ? [participant.number] : [])); + + // Dial each phone number / SIP destination into the conference via the Pexip iframe API. + numbersToDial.forEach((number) => onDialOut?.(number)); + + if (usersToAdd.length) { + // The server either adds the users to the current room (invite/keep-history) or creates a + // discussion off it (existing members + the new ones) and repoints the conference at it. It + // also notifies everyone added in both cases. + await addParticipants({ callId, users: usersToAdd, keepHistory: mode === 'invite' }); + + if (mode === 'discussion') { + // The conference now points at the new discussion — refresh its info so the chat panel switches. + await queryClient.invalidateQueries({ queryKey: ['conference-info', callId] }); + } + } + + dispatchToastMessage({ type: 'success', message: t('Users_added') }); + onClose(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } finally { + setAdding(false); + } + }; + + return ( + + + + {t('Add_participants')} + + + + + + + null} + renderItem={({ value, label, ...props }) => { + if (isFirstOption(value)) { + return + {t('Enter_username_or_number')} + + + {selected.length > 0 && ( + + {selected.map((participant) => ( + + {participant.kind === 'user' ? ( + <> + + + + + + {participant.name || participant.username} + + + ) : ( + <> + + + + + {participant.number} + + + )} + handleRemove(keyOf(participant))} /> + + ))} + + )} + + {/* DMs always create a discussion, so the choice only applies to channels/groups. */} + {!isDirect && ( + + setKeepHistory((prev) => !prev)} /> + + + )} + + + + + + + + + ); +}; + +export default AddParticipantsModal; diff --git a/apps/meteor/client/views/conference/ConferenceChat.tsx b/apps/meteor/client/views/conference/ConferenceChat.tsx new file mode 100644 index 0000000000000..47276021b08ff --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceChat.tsx @@ -0,0 +1,66 @@ +import { Box, Button, IconButton } from '@rocket.chat/fuselage'; +import { useSetModal } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; + +import AddParticipantsModal from './AddParticipantsModal'; +import ConferenceRoom from './ConferenceRoom'; +import ConferenceRoomPreload from './ConferenceRoomPreload'; +import NotFoundPage from '../notFound/NotFoundPage'; +import PageLoading from '../root/PageLoading'; + +type ConferenceChatProps = { + callId: string; + rid?: string; + loading: boolean; + onClose?: () => void; + onDialOut?: (destination: string) => void; +}; + +const ConferenceChat = ({ callId, rid, loading, onClose, onDialOut }: ConferenceChatProps) => { + const { t } = useTranslation(); + const setModal = useSetModal(); + + if (loading) { + return ; + } + + if (!rid) { + return ; + } + + return ( + + + + + {onClose && } + + {t('Chat')} + + + + + + + + + ); +}; + +export default ConferenceChat; diff --git a/apps/meteor/client/views/conference/ConferenceDisconnectedModal.tsx b/apps/meteor/client/views/conference/ConferenceDisconnectedModal.tsx new file mode 100644 index 0000000000000..2d10ee5cd539c --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceDisconnectedModal.tsx @@ -0,0 +1,62 @@ +import { + Box, + Button, + Modal, + ModalClose, + ModalContent, + ModalFooter, + ModalFooterControllers, + ModalHeader, + ModalHeaderText, + ModalTitle, +} from '@rocket.chat/fuselage'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +const COUNTDOWN_SECONDS = 10; + +type ConferenceDisconnectedModalProps = { + // Keep the conference open (dismiss the modal). + onCancel: () => void; + // Close the conference window now. + onClose: () => void; +}; + +const ConferenceDisconnectedModal = ({ onCancel, onClose }: ConferenceDisconnectedModalProps) => { + const { t } = useTranslation(); + const [secondsLeft, setSecondsLeft] = useState(COUNTDOWN_SECONDS); + + useEffect(() => { + if (secondsLeft <= 0) { + onClose(); + return undefined; + } + + const timeout = setTimeout(() => setSecondsLeft((seconds) => seconds - 1), 1000); + return () => clearTimeout(timeout); + }, [secondsLeft, onClose]); + + return ( + + + + {t('You_have_been_disconnected')} + + + + + {t('Conference_will_close_in_seconds', { count: Math.max(secondsLeft, 0) })} + + + + + + + + + ); +}; + +export default ConferenceDisconnectedModal; diff --git a/apps/meteor/client/views/conference/ConferenceEmbeddedPage.tsx b/apps/meteor/client/views/conference/ConferenceEmbeddedPage.tsx new file mode 100644 index 0000000000000..5042b1bd1d4a8 --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceEmbeddedPage.tsx @@ -0,0 +1,111 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useBreakpoints } from '@rocket.chat/fuselage-hooks'; +import { useSetModal, useUserSubscription } from '@rocket.chat/ui-contexts'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import ConferenceChat from './ConferenceChat'; +import ConferenceDisconnectedModal from './ConferenceDisconnectedModal'; +import ConferenceIframe from './ConferenceIframe'; +import ConferencePageError from './ConferencePageError'; +import ConferenceUnauthorizedPage from './ConferenceUnauthorizedPage'; +import { SideRail, SideRailActions, SideRailAction, SideRailPanel } from './components'; +import { useConferenceEmbedded } from './hooks/useConferenceEmbedded'; +import { useConfinedNavigation } from './hooks/useConfinedNavigation'; +import { usePexipPlugin } from './hooks/usePexipPlugin'; +import PageLoading from '../root/PageLoading'; + +type ConferenceEmbeddedPageProps = { + callId: string; +}; + +const ConferenceEmbeddedPage = ({ callId }: ConferenceEmbeddedPageProps) => { + const { room, conference } = useConferenceEmbedded(callId); + const { t } = useTranslation(); + const setModal = useSetModal(); + + // Keep this window pinned to the conference — links/navigations go to a new tab or the opener. + useConfinedNavigation(); + + // When the user is disconnected from the call, offer a 10s countdown to keep the window open, + // otherwise close the conference window/tab. + const handleDisconnected = useCallback(() => { + const closeWindow = () => { + setModal(null); + // On desktop the conference is a main-process Electron window that the renderer's + // `window.close()` can't close, so prefer the desktop bridge when available. + if (window.videoCallWindow?.close) { + window.videoCallWindow.close(); + return; + } + window.close(); + }; + + setModal( setModal(null)} onClose={closeWindow} />); + }, [setModal]); + + const subscription = useUserSubscription(room.rid ?? ''); + const hasUnread = Boolean(subscription && subscription.unread > 0); + + const breakpoints = useBreakpoints(); + const overlayPanel = !breakpoints.includes('md'); + + const [activePanel, setActivePanel] = useState('chat'); + + const togglePanel = useCallback((panel: string) => { + setActivePanel((prev) => (prev === panel ? null : panel)); + }, []); + + const { + closeChat, + dialOut, + connected: pluginConnected, + } = usePexipPlugin({ + conferenceUrl: conference.url, + hasUnread, + chatVisible: activePanel === 'chat', + onToggleChat: (active) => { + setActivePanel(active ? 'chat' : null); + }, + onDisconnected: handleDisconnected, + }); + + // No access to the conference's room — show the unauthorized screen for the whole page rather + // than a broken split with a "not found" chat panel. + if (room.error) { + return ; + } + + if (conference.loading) { + return ; + } + + if (conference.error || !conference.url) { + return ; + } + + return ( + + + {/* The Pexip plugin renders its own chat toggle in the in-meeting toolbar; once the user is + connected (`connected`), drop this rail's button to avoid a duplicate control. It stays as a + fallback during preflight, after disconnect, and when the plugin isn't installed. */} + {!pluginConnected && ( + + togglePanel('chat')} /> + + )} + + + + + + + + + + + ); +}; + +export default ConferenceEmbeddedPage; diff --git a/apps/meteor/client/views/conference/ConferenceIframe.tsx b/apps/meteor/client/views/conference/ConferenceIframe.tsx new file mode 100644 index 0000000000000..16a696eaaf0cf --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceIframe.tsx @@ -0,0 +1,25 @@ +import PageLoading from '../root/PageLoading'; + +type ConferenceIframeProps = { + url: string | undefined; + loading?: boolean; +}; + +const ConferenceIframe = ({ url, loading }: ConferenceIframeProps) => { + if (loading) { + return ; + } + + return ( +