From 1548ff0d71420a9f9e640836a25f673d80f917f3 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Wed, 26 Aug 2026 14:47:26 -0300 Subject: [PATCH 01/40] feat(video-conf): add the shared-package plumbing for the conference window Additive only, and inert until something passes the new values: - `desktop-api` gains `IVideoCallWindow`, the surface the desktop app exposes to a call window (open in main window, close, request screen sharing, credentials). - `fuselage-ui-kit`'s ui-kit context gains `videoConfJoinDisabled`, so a surface that is itself a call can render the join button disabled rather than offering a second call. Undefined everywhere else, which is how the button behaved before. - `VideoConferenceBlock` counts and shows only members who actually joined. `users` is the membership list, so someone who was rung but never answered was being reported as having joined. - `ui-kit` allows `phone` as an icon element. - `ui-client`: `GenericMenuItem` accepts `textValue`, without which a menu item whose content is rendered rather than plain text warns per item and cannot be reached by typing its name; `AnnouncementBanner` accepts `css` output for `className` as well as a plain string. - `mock-providers` answers the video-conf capability and preference reads instead of throwing, so a test that renders a popup fails on its own assertion rather than on the render, and returns module-constant snapshots because `useSyncExternalStore` compares them by identity. Co-Authored-By: Claude Fable 5 --- packages/desktop-api/src/index.ts | 7 ++++ .../VideoConferenceBlock.tsx | 33 +++++++++++-------- .../src/contexts/UiKitContext.ts | 1 + .../src/MockedAppRootBuilder.tsx | 26 +++++++++++---- .../AnnouncementBanner/AnnouncementBanner.tsx | 5 ++- .../components/GenericMenu/GenericMenu.tsx | 4 +-- .../GenericMenu/GenericMenuItem.tsx | 8 +++++ .../ui-kit/src/blocks/elements/IconElement.ts | 2 +- 8 files changed, 62 insertions(+), 24 deletions(-) diff --git a/packages/desktop-api/src/index.ts b/packages/desktop-api/src/index.ts index 04b1b6491103a..7a0415d20f63a 100644 --- a/packages/desktop-api/src/index.ts +++ b/packages/desktop-api/src/index.ts @@ -67,3 +67,10 @@ export interface IRocketChatDesktop { getE2ePdfPreviewSizeLimit: () => number; openInBrowser: (url: string) => void; } + +export interface IVideoCallWindow { + openInMainWindow: (path: string) => void; + close: () => void; + requestScreenSharing: () => Promise; + getAuthCredentials: () => Promise<{ userId: string; authToken: string; serverUrl: string } | null>; +} diff --git a/packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx b/packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx index bfdb5d79a2cba..5a019637e9e43 100644 --- a/packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx +++ b/packages/fuselage-ui-kit/src/blocks/VideoConferenceBlock/VideoConferenceBlock.tsx @@ -1,4 +1,4 @@ -import { getUserDisplayName, VideoConferenceStatus } from '@rocket.chat/core-typings'; +import { getUserDisplayName, hasJoinedVideoConference, VideoConferenceStatus } from '@rocket.chat/core-typings'; import { useSetting, useUserId, useUserPreference } from '@rocket.chat/ui-contexts'; import type * as UiKit from '@rocket.chat/ui-kit'; import { @@ -38,7 +38,7 @@ const VideoConferenceBlock = ({ block }: VideoConferenceBlockProps) => { const displayAvatars = useUserPreference('displayAvatars'); const showRealName = useSetting('UI_Use_Real_Name', false); - const { action, viewId = undefined, rid } = useContext(UiKitContext); + const { action, viewId = undefined, rid, videoConfJoinDisabled } = useContext(UiKitContext); if (surfaceType !== 'message') { throw new Error('VideoConferenceBlock cannot be rendered outside message'); @@ -95,8 +95,13 @@ const VideoConferenceBlock = ({ block }: VideoConferenceBlockProps) => { } }; + // `users` is the conference's membership list, not who's currently in the call — a member can be added + // without ever joining, so this must be filtered down to those who actually joined before it's counted + // or displayed anywhere below. + const joinedUsers = useMemo(() => result.data?.users.filter(hasJoinedVideoConference) ?? [], [result.data?.users]); + const messageFooterText = useMemo(() => { - const usersCount = result.data?.users.length; + const usersCount = joinedUsers.length; if (!displayAvatars) { return t('__usersCount__joined', { @@ -109,7 +114,7 @@ const VideoConferenceBlock = ({ block }: VideoConferenceBlockProps) => { count: usersCount - MAX_USERS, }) : t('joined'); - }, [displayAvatars, t, result.data?.users.length]); + }, [displayAvatars, t, joinedUsers.length]); if (result.isPending || result.isError) { // TODO: error handling @@ -119,16 +124,16 @@ const VideoConferenceBlock = ({ block }: VideoConferenceBlockProps) => { const { data } = result; const isUserCaller = data.createdBy._id === userId; - const joinedNamesOrUsernames = [...data.users] + const joinedNamesOrUsernames = [...joinedUsers] .splice(0, MAX_USERS) .map(({ name, username }) => getUserDisplayName(name, username, showRealName)) .join(', '); const title = - data.users.length > MAX_USERS + joinedUsers.length > MAX_USERS ? t('__usernames__and__count__more_joined', { usernames: joinedNamesOrUsernames, - count: data.users.length - MAX_USERS, + count: joinedUsers.length - MAX_USERS, }) : t('__usernames__joined', { usernames: joinedNamesOrUsernames }); @@ -152,16 +157,18 @@ const VideoConferenceBlock = ({ block }: VideoConferenceBlockProps) => { {data.type === 'direct' && ( <> - {isUserCaller ? t('Call_again') : t('Call_back')} + + {isUserCaller ? t('Call_again') : t('Call_back')} + {[VideoConferenceStatus.EXPIRED, VideoConferenceStatus.DECLINED].includes(data.status) && ( {t('Call_was_not_answered')} )} )} {data.type !== 'direct' && - (data.users.length ? ( + (joinedUsers.length ? ( <> - + {messageFooterText} ) : ( @@ -201,12 +208,12 @@ const VideoConferenceBlock = ({ block }: VideoConferenceBlockProps) => { {actions} - + {t('Join')} - {Boolean(data.users.length) && ( + {Boolean(joinedUsers.length) && ( <> - + {messageFooterText} )} diff --git a/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts b/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts index 3cf1b5efa341c..42bf479228bb3 100644 --- a/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts +++ b/packages/fuselage-ui-kit/src/contexts/UiKitContext.ts @@ -22,6 +22,7 @@ type UiKitContextValue = { values: Record; viewId?: string; rid?: string; + videoConfJoinDisabled?: boolean; }; export const UiKitContext = createContext({ diff --git a/packages/mock-providers/src/MockedAppRootBuilder.tsx b/packages/mock-providers/src/MockedAppRootBuilder.tsx index df504505dee05..c8c809c04aef1 100644 --- a/packages/mock-providers/src/MockedAppRootBuilder.tsx +++ b/packages/mock-providers/src/MockedAppRootBuilder.tsx @@ -95,6 +95,9 @@ export type StreamControllerRef = { const empty = [] as const; +const mockedVideoConfCapabilities: ProviderCapabilities = { mic: true, cam: true }; +const mockedVideoConfPreferences: CallPreferences = { mic: true, cam: true }; + export class MockedAppRootBuilder { private _settings: Map = new Map(); @@ -177,7 +180,9 @@ export class MockedAppRootBuilder { }; private videoConf: ContextType = { - queryIncomingCalls: () => [() => () => undefined, () => []], + // `empty` rather than a fresh array: `useSyncExternalStore` compares snapshots by identity, and a new one + // every read is an endless re-render. + queryIncomingCalls: () => [() => () => undefined, () => empty as unknown as DirectCallData[]], queryRinging: () => [() => () => undefined, () => false], queryCalling: () => [() => () => undefined, () => false], dispatchOutgoing(_options: Omit): void { @@ -210,12 +215,19 @@ export class MockedAppRootBuilder { loadCapabilities(): Promise { throw new Error('Function not implemented.'); }, - queryCapabilities(): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ProviderCapabilities] { - throw new Error('Function not implemented.'); - }, - queryPreferences(): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => CallPreferences] { - throw new Error('Function not implemented.'); - }, + // The actions above throw so that a test triggering one has to say what it expects to happen. These two + // are reads, and every video-conf popup does them just by rendering — throwing would fail such a test on + // the render rather than on anything it means to assert. + // Both snapshots are module constants, not fresh objects: `useSyncExternalStore` compares them by identity + // and a new object every read is an endless re-render. + queryCapabilities: (): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => ProviderCapabilities] => [ + () => () => undefined, + () => mockedVideoConfCapabilities, + ], + queryPreferences: (): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => CallPreferences] => [ + () => () => undefined, + () => mockedVideoConfPreferences, + ], }; private room: IRoom | undefined = undefined; diff --git a/packages/ui-client/src/components/AnnouncementBanner/AnnouncementBanner.tsx b/packages/ui-client/src/components/AnnouncementBanner/AnnouncementBanner.tsx index 17dd94f1970b0..eb3202c14c52e 100644 --- a/packages/ui-client/src/components/AnnouncementBanner/AnnouncementBanner.tsx +++ b/packages/ui-client/src/components/AnnouncementBanner/AnnouncementBanner.tsx @@ -1,3 +1,4 @@ +import type { css as cssFn } from '@rocket.chat/css-in-js'; import { css } from '@rocket.chat/css-in-js'; import { Box, Palette } from '@rocket.chat/fuselage'; import type { AllHTMLAttributes, ReactNode, MouseEvent } from 'react'; @@ -5,7 +6,9 @@ import type { AllHTMLAttributes, ReactNode, MouseEvent } from 'react'; export type AnnouncementBannerProps = { children: ReactNode; onClick?: (e: MouseEvent) => void; -} & Omit, 'is'>; + /** Composed with the banner's own styles, so `css` output is as welcome as a plain class name. */ + className?: string | ReturnType; +} & Omit, 'is' | 'className'>; const AnnouncementBanner = ({ children, className, onClick, ...props }: AnnouncementBannerProps) => { const announcementBar = css` diff --git a/packages/ui-client/src/components/GenericMenu/GenericMenu.tsx b/packages/ui-client/src/components/GenericMenu/GenericMenu.tsx index 95f921750eacb..dc1d0a651bd4d 100644 --- a/packages/ui-client/src/components/GenericMenu/GenericMenu.tsx +++ b/packages/ui-client/src/components/GenericMenu/GenericMenu.tsx @@ -76,7 +76,7 @@ const GenericMenu = ({ title, icon = 'menu', disabled, onAction, callbackAction, key={`${title}-${key}`} > {(item) => ( - + )} @@ -95,7 +95,7 @@ const GenericMenu = ({ title, icon = 'menu', disabled, onAction, callbackAction, {...props} > {handleItems(items).map((item) => ( - + ))} diff --git a/packages/ui-client/src/components/GenericMenu/GenericMenuItem.tsx b/packages/ui-client/src/components/GenericMenu/GenericMenuItem.tsx index dcf6043d5a23c..b51c350230337 100644 --- a/packages/ui-client/src/components/GenericMenu/GenericMenuItem.tsx +++ b/packages/ui-client/src/components/GenericMenu/GenericMenuItem.tsx @@ -14,6 +14,14 @@ export type GenericMenuItemProps = { gap?: boolean; tooltip?: string; variant?: string; + /** + * What this item *says*, for an item whose `content` is rendered rather than plain text. + * + * The collection underneath needs a string to match typeahead against and to announce, and it cannot read one + * out of arbitrary JSX — without it, it warns per item ("unsupported by type to select for accessibility") and + * the item is unreachable by typing its name. + */ + textValue?: string; }; const GenericMenuItem = ({ icon, iconColor, content, addon, status, gap, tooltip }: GenericMenuItemProps) => ( diff --git a/packages/ui-kit/src/blocks/elements/IconElement.ts b/packages/ui-kit/src/blocks/elements/IconElement.ts index 46e540b3d50a1..eec826cfbae9f 100644 --- a/packages/ui-kit/src/blocks/elements/IconElement.ts +++ b/packages/ui-kit/src/blocks/elements/IconElement.ts @@ -1,4 +1,4 @@ -type AvailableIcons = 'phone-off' | 'phone-issue' | 'clock' | 'arrow-forward' | 'info' | 'phone-question-mark'; +type AvailableIcons = 'phone' | 'phone-off' | 'phone-issue' | 'clock' | 'arrow-forward' | 'info' | 'phone-question-mark'; export type IconElement = { type: 'icon'; From 6e75f46b556af7c57c3a291314f9d3a4a37942ae Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Wed, 26 Aug 2026 14:47:47 -0300 Subject: [PATCH 02/40] refactor(video-conf): let a route render without the navigation chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for a standalone conference route. Every change is either additive or a move that leaves the rendered tree as it was. `LayoutWithSidebar` moves out of `TwoFactorAuthSetupCheck` and into `MainLayout`, which passes it down as the children the authentication chain threads through. It still sits immediately below the 2FA check and above the `Suspense` boundary, so the tree for every branch `MainLayout` can reach is unchanged — but the navigation chrome is now the layout's business, letting a route use the authentication checks alone. `AuthenticationCheck` and `UsernameCheck` accept a `loading` placeholder, forwarded to the skeleton shown while the user resolves; the app-shaped default only suits routes that render inside the chrome. `appLayout.wrap` accepts `embedded`, which omits the announcement and banner regions so app-level banners don't bleed into a standalone view. Both default to the previous behaviour. `UserAction.addStream` reference-counts its subscribers instead of throwing on a second call for the same room, so a room can be mounted twice. Its disposer releases only its own reference however many times it is called: an effect cleanup can run twice, and a second decrement would close the stream under the mounts still holding it and then never close it at all. Also adds the client-side date reifiers for the conference REST shapes (extracted from `useVideoConfList`, which now uses them), a `timeLabel` on the extended sidebar row for a row with something better to say than when it happened, and per-notification `requireInteraction`/`actions` handling. The room's conference list and the message block now count only members who actually joined. Co-Authored-By: Claude Fable 5 --- .../UserAutoCompleteMultiple.tsx | 1 + apps/meteor/client/definitions/global.d.ts | 5 +- .../hooks/notification/useNotification.ts | 18 ++- apps/meteor/client/hooks/useRingingExpiry.ts | 37 +++++ apps/meteor/client/lib/UserAction.ts | 49 +++++-- apps/meteor/client/lib/appLayout.tsx | 8 +- apps/meteor/client/lib/queryKeys.ts | 7 +- .../meteor/client/lib/utils/mapRoomFromApi.ts | 23 ++++ .../client/lib/utils/mapVideoConfFromApi.ts | 26 ++++ .../lib/utils/mapVideoConfUserFromApi.ts | 25 ++++ apps/meteor/client/sidebar/Item/Extended.tsx | 8 +- apps/meteor/client/startup/routes.tsx | 2 +- .../hooks/useMessageBlockContextValue.ts | 4 +- .../VideoConfList/VideoConfListItem.tsx | 6 +- .../VideoConfList/useVideoConfList.ts | 21 +-- .../views/room/hooks/useOpenRoomById.tsx | 127 ++++++++++++++++++ .../root/MainLayout/AuthenticationCheck.tsx | 17 ++- .../views/root/MainLayout/MainLayout.tsx | 22 ++- .../MainLayout/TwoFactorAuthSetupCheck.tsx | 5 +- .../views/root/MainLayout/UsernameCheck.tsx | 14 +- 20 files changed, 362 insertions(+), 63 deletions(-) create mode 100644 apps/meteor/client/hooks/useRingingExpiry.ts create mode 100644 apps/meteor/client/lib/utils/mapRoomFromApi.ts create mode 100644 apps/meteor/client/lib/utils/mapVideoConfFromApi.ts create mode 100644 apps/meteor/client/lib/utils/mapVideoConfUserFromApi.ts create mode 100644 apps/meteor/client/views/room/hooks/useOpenRoomById.tsx diff --git a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx index 8a65bcb2d4e58..bd31dc7c2688b 100644 --- a/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx +++ b/apps/meteor/client/components/UserAutoCompleteMultiple/UserAutoCompleteMultiple.tsx @@ -14,6 +14,7 @@ export type UserAutoCompleteMultipleProps = { value: Array | undefined; placeholder?: string; federated?: boolean; + /** Usernames to leave out of the options — people it would make no sense to offer. */ exceptions?: string[]; error?: string; } & Omit, 'is' | 'onChange' | 'value'>; diff --git a/apps/meteor/client/definitions/global.d.ts b/apps/meteor/client/definitions/global.d.ts index 16442d1aabfaf..c713df5a4caf6 100644 --- a/apps/meteor/client/definitions/global.d.ts +++ b/apps/meteor/client/definitions/global.d.ts @@ -1,8 +1,9 @@ -import type { IRocketChatDesktop } from '@rocket.chat/desktop-api'; +import type { IRocketChatDesktop, IVideoCallWindow } from '@rocket.chat/desktop-api'; declare global { interface Window { RocketChatDesktop?: IRocketChatDesktop; + videoCallWindow?: IVideoCallWindow; opera?: string; } @@ -42,5 +43,7 @@ declare global { interface NotificationEventMap { reply: { response: string }; + /** Fired by the desktop app when one of a notification's action buttons is pressed. */ + action: { action: string }; } } diff --git a/apps/meteor/client/hooks/notification/useNotification.ts b/apps/meteor/client/hooks/notification/useNotification.ts index 41482bc004d33..55e0019decf35 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 { stripTags } from '../../../lib/utils/stringUtils'; @@ -10,8 +11,9 @@ import { getUserAvatarURL } from '../../lib/getUserAvatarURL'; 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/useRingingExpiry.ts b/apps/meteor/client/hooks/useRingingExpiry.ts new file mode 100644 index 0000000000000..01c8d51927024 --- /dev/null +++ b/apps/meteor/client/hooks/useRingingExpiry.ts @@ -0,0 +1,37 @@ +import { VIDEO_CONF_RINGING_WINDOW_MS } from '@rocket.chat/core-typings'; +import { useEffect, useState } from 'react'; + +/** + * Re-renders when the earliest of these rings stops being a ring. + * + * A ring lapses on its own — nothing announces it, because nothing happened — so anything that reads "is this + * ringing?" would keep saying yes until something unrelated moved. Both readers of that question need this: the + * list, to let a ringing call settle into an ordinary one, and a member's row, to offer to ring them again. + * + * @param ringingAt when each ring started; anything absent is ignored. + */ +export const useRingingExpiry = (ringingAt: (Date | undefined)[]): void => { + const [, setElapsed] = useState(0); + + // The moments are what matter, not the array identity — a fresh array of the same rings must not restart the + // timer, and callers build these lists inline. + const earliest = ringingAt.reduce((soonest, at) => { + if (!at) { + return soonest; + } + + const stopsAt = at.getTime() + VIDEO_CONF_RINGING_WINDOW_MS; + return soonest === undefined || stopsAt < soonest ? stopsAt : soonest; + }, undefined); + + useEffect(() => { + if (earliest === undefined) { + return; + } + + // A little past the window, so the wake-up lands on the far side of it rather than exactly on the edge. + const timer = setTimeout(() => setElapsed((tick) => tick + 1), Math.max(earliest - Date.now(), 0) + 100); + + return () => clearTimeout(timer); + }, [earliest]); +}; diff --git a/apps/meteor/client/lib/UserAction.ts b/apps/meteor/client/lib/UserAction.ts index 327fd76ef45ac..883fd1eb30641 100644 --- a/apps/meteor/client/lib/UserAction.ts +++ b/apps/meteor/client/lib/UserAction.ts @@ -23,7 +23,13 @@ const activityTimeouts = new Map(); const activityRenews = new Map(); const continuingIntervals = new Map(); const roomActivities = new Map>(); -const rooms = new Map void>(); +type RoomActivityStream = { + handler: (username: string, activityType: string[], extras?: object) => void; + stop: () => void; + refs: number; +}; + +const rooms = new Map(); const performingUsers = new Map(); const performingUsersEmitter = new Emitter<{ changed: void }>(); @@ -67,10 +73,35 @@ function handleStreamAction(rid: string, username: string, activityTypes: string performingUsers.set(rid, roomActivities); performingUsersEmitter.emit('changed'); } +/** + * The disposer for one `addStream` call. It releases that call's reference and no more, however many times it is + * invoked: an effect cleanup can run twice (StrictMode's double invoke, a cleanup racing a re-mount), and a second + * decrement would take `refs` below the number of mounts still holding the stream — closing it under them, and then + * never closing it at all once the count can no longer come back to zero. + */ +const releaseRoomStream = (rid: string, entry: RoomActivityStream): (() => void) => { + let released = false; + + return () => { + if (released) { + return; + } + released = true; + + entry.refs--; + if (entry.refs === 0) { + entry.stop(); + rooms.delete(rid); + } + }; +}; + export const UserAction = new (class { addStream(rid: string): () => void { - if (rooms.get(rid)) { - throw new Error('UserAction - addStream should only be called once per room'); + const existing = rooms.get(rid); + if (existing) { + existing.refs++; + return releaseRoomStream(rid, existing); } const handler = function (username: string, activityType: string[], extras?: object): void { @@ -82,16 +113,12 @@ export const UserAction = new (class { } handleStreamAction(rid, username, activityType, extras); }; - rooms.set(rid, handler); const { stop } = sdk.stream('notify-room', [`${rid}/${USER_ACTIVITY}`], handler); - return () => { - if (!rooms.get(rid)) { - return; - } - stop(); - rooms.delete(rid); - }; + const entry: RoomActivityStream = { handler, stop, refs: 1 }; + rooms.set(rid, entry); + + return releaseRoomStream(rid, entry); } performContinuously(rid: string, activityType: string, extras: IExtras = {}): void { diff --git a/apps/meteor/client/lib/appLayout.tsx b/apps/meteor/client/lib/appLayout.tsx index 0f2fc6920b729..dec8b5c3e9d03 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/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts index da9979eef2da2..608a0eb58b43b 100644 --- a/apps/meteor/client/lib/queryKeys.ts +++ b/apps/meteor/client/lib/queryKeys.ts @@ -123,7 +123,7 @@ export const usersQueryKeys = { userInfo: ({ uid, username }: { uid?: IUser['_id']; username?: IUser['username'] }) => [...usersQueryKeys.all, 'info', { uid, username }] as const, userAutoComplete: (filter: string, federated: boolean, exceptions: string[] = []) => - [...usersQueryKeys.all, 'autocomplete', filter, federated, exceptions] as const, + [...usersQueryKeys.all, 'autocomplete', filter, federated, ...(exceptions.length ? [exceptions] : [])] as const, }; export const teamsQueryKeys = { @@ -190,6 +190,11 @@ export const marketplaceQueryKeys = { export const videoConferenceQueryKeys = { all: ['video-conference'] as const, fromRoom: (roomId: IRoom['_id']) => [...videoConferenceQueryKeys.all, 'rooms', roomId] as const, + conference: (callId: string) => [...videoConferenceQueryKeys.all, callId] as const, + join: (callId: string) => [...videoConferenceQueryKeys.conference(callId), 'join'] as const, + joinable: () => [...videoConferenceQueryKeys.all, 'joinable'] as const, + /** What the provider can be told about devices — asked before any conference exists. */ + capabilities: () => [...videoConferenceQueryKeys.all, 'capabilities'] as const, } as const; export const messagesQueryKeys = { 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/lib/utils/mapVideoConfFromApi.ts b/apps/meteor/client/lib/utils/mapVideoConfFromApi.ts new file mode 100644 index 0000000000000..13875397bcec2 --- /dev/null +++ b/apps/meteor/client/lib/utils/mapVideoConfFromApi.ts @@ -0,0 +1,26 @@ +import type { Serialized, VideoConference } from '@rocket.chat/core-typings'; + +import { mapVideoConfUserFromApi } from './mapVideoConfUserFromApi'; + +/** + * REST hands every date over as an ISO string; the in-memory model uses `Date`. Reifying here is what lets + * every consumer rely on date methods rather than each one remembering which fields are strings. + * + * The native provider's own record of who was in the call is dated the same way, so it is reified + * alongside rather than by whoever happens to read it. + */ +export const mapVideoConfFromApi = (videoConf: Serialized): VideoConference => + ({ + ...videoConf, + _updatedAt: new Date(videoConf._updatedAt), + createdAt: new Date(videoConf.createdAt), + endedAt: videoConf.endedAt ? new Date(videoConf.endedAt) : undefined, + users: videoConf.users.map(mapVideoConfUserFromApi), + ...(videoConf.participants && { + participants: videoConf.participants.map((participant) => ({ + ...participant, + joinedAt: participant.joinedAt ? new Date(participant.joinedAt) : undefined, + leftAt: participant.leftAt ? new Date(participant.leftAt) : undefined, + })), + }), + }) as VideoConference; diff --git a/apps/meteor/client/lib/utils/mapVideoConfUserFromApi.ts b/apps/meteor/client/lib/utils/mapVideoConfUserFromApi.ts new file mode 100644 index 0000000000000..0c4e9c67895c5 --- /dev/null +++ b/apps/meteor/client/lib/utils/mapVideoConfUserFromApi.ts @@ -0,0 +1,25 @@ +import type { IVideoConferenceUser, Serialized } from '@rocket.chat/core-typings'; + +/** + * Revives the dates on a conference member. Membership carries several optional timestamps and each one + * arrives as a string over REST, so they are handled in one place — adding a field to + * `IVideoConferenceUser` without deserializing it here is otherwise only caught by a type error at the + * consumer, far from the cause. + */ +export const mapVideoConfUserFromApi = ({ + ts, + joinedAt, + declinedAt, + leftAt, + lastSeenAt, + ringingAt, + ...user +}: Serialized): IVideoConferenceUser => ({ + ...user, + ts: new Date(ts), + ...(joinedAt && { joinedAt: new Date(joinedAt) }), + ...(declinedAt && { declinedAt: new Date(declinedAt) }), + ...(leftAt && { leftAt: new Date(leftAt) }), + ...(lastSeenAt && { lastSeenAt: new Date(lastSeenAt) }), + ...(ringingAt && { ringingAt: new Date(ringingAt) }), +}); diff --git a/apps/meteor/client/sidebar/Item/Extended.tsx b/apps/meteor/client/sidebar/Item/Extended.tsx index f585e7de2667b..b860e75468270 100644 --- a/apps/meteor/client/sidebar/Item/Extended.tsx +++ b/apps/meteor/client/sidebar/Item/Extended.tsx @@ -23,6 +23,11 @@ export type ExtendedProps = { href?: string; time?: any; menu?: () => ReactNode; + /** + * Said in the timestamp's place, when a row has something more useful to put there than when it happened — a + * call that is ringing right now, say. Wins over `time`. + */ + timeLabel?: ReactNode; subtitle?: ReactNode; badges?: ReactNode; unread?: boolean; @@ -39,6 +44,7 @@ const Extended = ({ actions, href, time, + timeLabel, menu, menuOptions: _menuOptions, subtitle = '', @@ -59,7 +65,7 @@ const Extended = ({ {icon} {title} - {time && {formatDate(time)}} + {(timeLabel || time) && {timeLabel ?? formatDate(time)}} {subtitle} diff --git a/apps/meteor/client/startup/routes.tsx b/apps/meteor/client/startup/routes.tsx index d09d2a6bc5cbd..19684697877ff 100644 --- a/apps/meteor/client/startup/routes.tsx +++ b/apps/meteor/client/startup/routes.tsx @@ -210,7 +210,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..2432fb3a80533 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 { useCurrentRoutePath, useRoomToolbox } from '@rocket.chat/ui-contexts'; import { useVideoConfDispatchOutgoing, useVideoConfIsCalling, @@ -23,6 +23,7 @@ export const useMessageBlockContextValue = (rid: IRoom['_id'], mid: IMessage['_i const dispatchWarning = useVideoConfWarning(); const dispatchPopup = useVideoConfDispatchOutgoing(); const loadVideoConfCapabilities = useVideoConfLoadCapabilities(); + const videoConfJoinDisabled = !!useCurrentRoutePath()?.startsWith('/conference/'); const handleOpenVideoConf = useStableCallback(async (rid: IRoom['_id']) => { if (isCalling || isRinging) { @@ -77,6 +78,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/room/contextualBar/VideoConference/VideoConfList/VideoConfListItem.tsx b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfListItem.tsx index b1d7ae715bea0..1a87afd285f67 100644 --- a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfListItem.tsx +++ b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfListItem.tsx @@ -1,4 +1,4 @@ -import type { VideoConference } from '@rocket.chat/core-typings'; +import { hasJoinedVideoConference, type VideoConference } from '@rocket.chat/core-typings'; import { css } from '@rocket.chat/css-in-js'; import { Button, @@ -50,7 +50,9 @@ const VideoConfListItem = ({ } = videoConfData; const displayName = useUserDisplayName({ name, username }); - const joinedUsers = users.filter((user) => user._id !== _id); + // Excludes the creator, and also members who never joined: `users` is the conference's membership list, so + // someone added to the call is in it whether or not they ever answered. + const joinedUsers = users.filter((user) => user._id !== _id && hasJoinedVideoConference(user)); const hovered = css` &:hover, diff --git a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts index 8c897a589e10c..e3800036057fc 100644 --- a/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts +++ b/apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts @@ -1,8 +1,9 @@ -import type { IRoom, VideoConference } from '@rocket.chat/core-typings'; +import type { IRoom } from '@rocket.chat/core-typings'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useInfiniteQuery } from '@tanstack/react-query'; import { videoConferenceQueryKeys } from '../../../../../lib/queryKeys'; +import { mapVideoConfFromApi } from '../../../../../lib/utils/mapVideoConfFromApi'; export const useVideoConfList = ({ roomId }: { roomId: IRoom['_id'] }) => { const getVideoConfs = useEndpoint('GET', '/v1/video-conference.list'); @@ -19,23 +20,7 @@ export const useVideoConfList = ({ roomId }: { roomId: IRoom['_id'] }) => { }); return { - items: data.map( - ({ _updatedAt, createdAt, endedAt, users, ...rest }): VideoConference => ({ - ...rest, - _updatedAt: new Date(_updatedAt), - createdAt: new Date(createdAt), - endedAt: endedAt ? new Date(endedAt) : undefined, - users: users.map(({ ts, joinedAt, declinedAt, leftAt, lastSeenAt, ringingAt, ...userRest }) => ({ - ...userRest, - ts: new Date(ts), - joinedAt: joinedAt ? new Date(joinedAt) : undefined, - declinedAt: declinedAt ? new Date(declinedAt) : undefined, - leftAt: leftAt ? new Date(leftAt) : undefined, - lastSeenAt: lastSeenAt ? new Date(lastSeenAt) : undefined, - ringingAt: ringingAt ? new Date(ringingAt) : undefined, - })), - }), - ), + items: data.map(mapVideoConfFromApi), itemCount: total, }; }, diff --git a/apps/meteor/client/views/room/hooks/useOpenRoomById.tsx b/apps/meteor/client/views/room/hooks/useOpenRoomById.tsx new file mode 100644 index 0000000000000..2d6e47b1fed75 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useOpenRoomById.tsx @@ -0,0 +1,127 @@ +import { isPublicRoom, type IRoom } from '@rocket.chat/core-typings'; +import { getObjectKeys } from '@rocket.chat/tools'; +import { useEndpoint, usePermission, useUser } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import { useCallback } from 'react'; + +import { useOpenRoomMutation } from './useOpenRoomMutation'; +import { LegacyRoomManager } from '../../../../app/ui-utils/client'; +import { roomFields } from '../../../../lib/publishFields'; +import { SubscriptionsCachedStore } from '../../../cachedStores'; +import { RoomManager } from '../../../lib/RoomManager'; +import { NotSubscribedToRoomError } from '../../../lib/errors/NotSubscribedToRoomError'; +import { RoomNotFoundError } from '../../../lib/errors/RoomNotFoundError'; +import { roomsQueryKeys } from '../../../lib/queryKeys'; +import { mapRoomFromApi } from '../../../lib/utils/mapRoomFromApi'; +import { mapSubscriptionFromApi } from '../../../lib/utils/mapSubscriptionFromApi'; +import { Rooms, Subscriptions } from '../../../stores'; + +/** + * Opens a room by its id, for callers that already know the rid and can't go through the router-driven + * `useOpenRoom` (which resolves a room by type + name/username). + */ +export function useOpenRoomById(rid: IRoom['_id']) { + const user = useUser(); + const hasPreviewPermission = usePermission('preview-c-room'); + const getRoomInfo = useEndpoint('GET', '/v1/rooms.info'); + const getSubscription = useEndpoint('GET', '/v1/subscriptions.getOne'); + const openRoom = useOpenRoomMutation(); + + const tryCacheShortcut = useCallback((): { rid: IRoom['_id'] } | undefined => { + if (!user?._id) { + return undefined; + } + const room = Rooms.state.get(rid); + if (!room) { + return undefined; + } + const sub = Subscriptions.state.find((record) => record.rid === rid); + // Sub exists but is closed — must still call openRoom.mutateAsync, so don't shortcut. + if (sub?.open === false) { + return undefined; + } + return { rid }; + }, [rid, user?._id]); + + return useQuery({ + queryKey: [...roomsQueryKeys.room(rid), 'open', user?._id, user?.username], + + placeholderData: tryCacheShortcut, + + queryFn: async (): Promise<{ rid: IRoom['_id'] }> => { + const cached = tryCacheShortcut(); + if (cached) { + const room = Rooms.state.get(rid); + if (room) { + const openIdentifier = room.t === 'd' ? rid : room.name; + if (openIdentifier) { + LegacyRoomManager.open({ typeName: room.t + openIdentifier, rid }); + } + } + return cached; + } + + let roomData: IRoom | null = null; + try { + const result = await getRoomInfo({ roomId: rid }); + roomData = result.room ? mapRoomFromApi(result.room) : null; + } catch (error) { + throw new RoomNotFoundError(undefined, { rid }); + } + + if (!roomData?._id) { + throw new RoomNotFoundError(undefined, { rid }); + } + + const unsetKeys = getObjectKeys(roomData).filter((key) => !(key in roomFields)); + unsetKeys.forEach((key) => { + delete roomData[key]; + }); + Rooms.state.store(roomData); + + const room = Rooms.state.get(roomData._id); + if (!room) { + throw new TypeError('room is undefined'); + } + + // Subscriptions.state may be empty when used without a pre-populating parent (e.g. the conference + // chat panel). Fetch the subscription as a fallback so openRoom.mutateAsync is not silently skipped. + let sub = Subscriptions.state.find((record) => record.rid === rid); + if (!sub) { + try { + const subResult = await getSubscription({ roomId: rid }); + if (subResult.subscription) { + SubscriptionsCachedStore.upsertSubscription(mapSubscriptionFromApi(subResult.subscription)); + sub = Subscriptions.state.find((record) => record.rid === rid); + } + } catch { + // Not subscribed — falls through to the NotSubscribedToRoomError check below. + } + } + + if (user && !sub && !hasPreviewPermission && isPublicRoom(room)) { + throw new NotSubscribedToRoomError(undefined, { rid: room._id }); + } + + // LegacyRoomManager starts the message stream that the composer waits on (via `streamActive`). It + // resolves the room through `findRoom`, which matches channels/groups by name but DMs by rid (DM + // rooms have no usable `name`). Passing the wrong identifier leaves the composer stuck loading, so + // pick per room type. + const openIdentifier = room.t === 'd' ? rid : room.name; + if (openIdentifier) { + LegacyRoomManager.open({ typeName: room.t + openIdentifier, rid }); + } + + if (rid === RoomManager.opened) { + return { rid }; + } + + if (!!user?._id && sub && !sub.open) { + await openRoom.mutateAsync({ roomId: rid, userId: user._id }); + } + + return { rid }; + }, + retry: 0, + }); +} diff --git a/apps/meteor/client/views/root/MainLayout/AuthenticationCheck.tsx b/apps/meteor/client/views/root/MainLayout/AuthenticationCheck.tsx index 37276ae272000..97204701962b6 100644 --- a/apps/meteor/client/views/root/MainLayout/AuthenticationCheck.tsx +++ b/apps/meteor/client/views/root/MainLayout/AuthenticationCheck.tsx @@ -19,7 +19,12 @@ import HomeSkeleton from '../../home/HomeSkeleton'; * Guest is only for certain locations, it shows a form asking if the user wants to stay as guest and if so * renders the page, without creating an user (not even an anonymous user) */ -export type AuthenticationCheckProps = { children: ReactNode; guest?: boolean }; +export type AuthenticationCheckProps = { + children: ReactNode; + guest?: boolean; + /** Placeholder shown while the user is resolved — see `UsernameCheck`. */ + loading?: ReactNode; +}; /** * The connection states that mean a server was reached for and lost, as opposed to not having answered yet. @@ -30,7 +35,7 @@ export type AuthenticationCheckProps = { children: ReactNode; guest?: boolean }; */ const hasGivenUp = (status: ReturnType['status']): boolean => status === 'waiting' || status === 'failed'; -const AuthenticationCheck = ({ children, guest }: AuthenticationCheckProps) => { +const AuthenticationCheck = ({ children, guest, loading }: AuthenticationCheckProps) => { const user = useUser(); const allowAnonymousRead = useSetting('Accounts_AllowAnonymousRead'); const forceLogin = useSession('forceLogin'); @@ -93,13 +98,15 @@ const AuthenticationCheck = ({ children, guest }: AuthenticationCheckProps) => { const isResumingSession = !user && !hasSeenUser.current && !forceLogin && !unreachable && !!loginToken; if (isResumingSession) { - return ; + // A route that brought its own placeholder gets it here too: the app-shaped skeleton is the wrong shape + // for a window that never shows the app around it. + return <>{loading ?? }; } if (user) { return ( - {children} + {children} ); } @@ -109,7 +116,7 @@ const AuthenticationCheck = ({ children, guest }: AuthenticationCheckProps) => { } if (!forceLogin && allowAnonymousRead) { - return {children}; + return {children}; } return ; diff --git a/apps/meteor/client/views/root/MainLayout/MainLayout.tsx b/apps/meteor/client/views/root/MainLayout/MainLayout.tsx index 0293401a11145..aab425b9739e4 100644 --- a/apps/meteor/client/views/root/MainLayout/MainLayout.tsx +++ b/apps/meteor/client/views/root/MainLayout/MainLayout.tsx @@ -4,6 +4,7 @@ import { Suspense } from 'react'; import AuthenticationCheck from './AuthenticationCheck'; import EmbeddedPreload from './EmbeddedPreload'; +import LayoutWithSidebar from './LayoutWithSidebar'; import Preload from './Preload'; import { useCustomScript } from './useCustomScript'; @@ -15,23 +16,18 @@ const MainLayout = ({ children = null }: MainLayoutProps) => { useCustomScript(); const isEmbeddedLayout = useEmbeddedLayout(); + const Layout = isEmbeddedLayout ? EmbeddedPreload : Preload; - if (isEmbeddedLayout) { - return ( - - - {children} - - - ); - } - + // The navigation chrome belongs to this layout rather than to the authentication chain, so routes that + // only need the auth checks (the conference page) render standalone. return ( - + - {children} + + {children} + - + ); }; diff --git a/apps/meteor/client/views/root/MainLayout/TwoFactorAuthSetupCheck.tsx b/apps/meteor/client/views/root/MainLayout/TwoFactorAuthSetupCheck.tsx index 8aa5bdbd64317..1fa45d43d1516 100644 --- a/apps/meteor/client/views/root/MainLayout/TwoFactorAuthSetupCheck.tsx +++ b/apps/meteor/client/views/root/MainLayout/TwoFactorAuthSetupCheck.tsx @@ -3,7 +3,6 @@ import { useLayout } from '@rocket.chat/ui-contexts'; import type { ReactNode } from 'react'; import { lazy } from 'react'; -import LayoutWithSidebar from './LayoutWithSidebar'; import MainContent from './MainContent'; import { useRequire2faSetup } from '../../hooks/useRequire2faSetup'; @@ -25,7 +24,9 @@ const TwoFactorAuthSetupCheck = ({ children }: TwoFactorAuthSetupCheckProps) => ); } - return {children}; + // The surrounding navigation chrome is applied by `MainLayout`, not here, so routes that only need + // the authentication checks (e.g. the conference page) render standalone. + return children; }; export default TwoFactorAuthSetupCheck; diff --git a/apps/meteor/client/views/root/MainLayout/UsernameCheck.tsx b/apps/meteor/client/views/root/MainLayout/UsernameCheck.tsx index 61d656a3a2c3b..e8ca11c1dabc2 100644 --- a/apps/meteor/client/views/root/MainLayout/UsernameCheck.tsx +++ b/apps/meteor/client/views/root/MainLayout/UsernameCheck.tsx @@ -7,9 +7,17 @@ import RegisterUsername from './RegisterUsername'; import { useUserInfoQuery } from '../../../hooks/useUserInfoQuery'; import HomeSkeleton from '../../home/HomeSkeleton'; -export type UsernameCheckProps = { children: ReactNode }; +export type UsernameCheckProps = { + children: ReactNode; + /** + * Placeholder shown while the user is being resolved. Defaults to the app-shaped skeleton, which only + * suits routes that render inside the navigation chrome — standalone routes should pass their own so + * they don't flash a sidebar and composer they will never show. + */ + loading?: ReactNode; +}; -const UsernameCheck = ({ children }: UsernameCheckProps) => { +const UsernameCheck = ({ children, loading }: UsernameCheckProps) => { const userId = useUserId(); const { data: userData, isLoading } = useUserInfoQuery({ userId: userId || '' }, { enabled: !!userId }); @@ -31,7 +39,7 @@ const UsernameCheck = ({ children }: UsernameCheckProps) => { }, [userData?.user, userId, allowAnonymousRead]); if (isLoading) { - return ; + return loading ?? ; } if (shouldRegisterUsername) { From 1d73cfb77d0379529128529aeeba2fef31eb8b5e Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Wed, 26 Aug 2026 14:47:56 -0300 Subject: [PATCH 03/40] feat(video-conf): add the conference window views The window a call runs in: the provider iframe and its viewport, the call bar and member panel, the preflight page where a call is named and people are picked before it starts, the chat alongside the call (main room or thread, per `VideoConf_Persistent_Chat_Mode`), the chat-access modal and notice for people in the call who cannot see the conversation, and the state pages for a call that has ended, was declined or was never answered. Reached only by visiting a `/conference/:id` URL. That route already existed and rendered a placeholder; this grows what it renders. Nothing links to it yet, and no existing screen mounts any of this. Co-Authored-By: Claude Fable 5 --- .../conference/AddParticipantsModal.spec.tsx | 138 +++++++++ .../views/conference/AddParticipantsModal.tsx | 110 +++++++ .../views/conference/CallDeviceToggle.tsx | 44 +++ .../views/conference/CallMemberItem.tsx | 78 +++++ .../views/conference/CallMembersPanel.tsx | 75 +++++ .../views/conference/CallPanelHeader.tsx | 28 ++ .../client/views/conference/CallTimer.tsx | 50 +++ .../views/conference/ChatAccessModal.spec.tsx | 65 ++++ .../views/conference/ChatAccessModal.tsx | 128 ++++++++ .../conference/ChatAccessNotice.spec.tsx | 62 ++++ .../views/conference/ChatAccessNotice.tsx | 61 ++++ .../views/conference/ConferenceChat.spec.tsx | 46 +++ .../views/conference/ConferenceChat.tsx | 99 ++++++ .../conference/ConferenceChatNotShared.tsx | 23 ++ .../conference/ConferenceEmbeddedPage.tsx | 289 ++++++++++++++++++ .../views/conference/ConferenceIframe.tsx | 29 ++ .../views/conference/ConferenceMemberRow.tsx | 21 ++ .../views/conference/ConferencePreflight.tsx | 198 ++++++++++++ .../views/conference/ConferenceRoom.tsx | 80 +++++ .../views/conference/ConferenceRoute.tsx | 48 ++- .../conference/ConferenceStartPage.spec.tsx | 88 ++++++ .../views/conference/ConferenceStartPage.tsx | 57 ++++ .../views/conference/ConferenceStatePage.tsx | 44 +++ .../conference/ConferenceStoresReady.tsx | 33 ++ .../views/conference/ConferenceThread.tsx | 66 ++++ .../views/conference/ConferenceThreadChat.tsx | 158 ++++++++++ .../conference/ConferenceThreadModal.tsx | 47 +++ .../conference/ConferenceUnauthorizedPage.tsx | 26 ++ .../views/conference/ConferenceViewport.tsx | 15 + .../conference/components/CallBar/CallBar.tsx | 43 +++ .../components/CallBar/CallBarAction.tsx | 48 +++ .../components/CallBar/CallTopBar.tsx | 43 +++ .../components/CallPanel/CallPanel.tsx | 46 +++ .../conference/hooks/useCallPreferences.ts | 179 +++++++++++ .../hooks/useConferenceEmbedded.spec.tsx | 220 +++++++++++++ .../hooks/useConferenceEmbedded.tsx | 216 +++++++++++++ .../hooks/useConferencePresenceLease.spec.ts | 73 +++++ .../hooks/useConferencePresenceLease.ts | 48 +++ .../hooks/useConferenceSubscription.spec.ts | 75 +++++ .../hooks/useConferenceSubscription.ts | 55 ++++ .../hooks/useConfinedNavigation.spec.ts | 178 +++++++++++ .../conference/hooks/useConfinedNavigation.ts | 143 +++++++++ .../conference/hooks/useJoinCall.spec.tsx | 99 ++++++ .../views/conference/hooks/useJoinCall.tsx | 65 ++++ .../conference/hooks/useJoinableCalls.ts | 77 +++++ .../hooks/useLeaveConferenceOnClose.spec.ts | 78 +++++ .../hooks/useLeaveConferenceOnClose.ts | 50 +++ .../conference/hooks/useStartConference.ts | 67 ++++ .../client/views/conference/lib/callWindow.ts | 26 ++ .../client/views/conference/testFixtures.ts | 51 ++++ 50 files changed, 4083 insertions(+), 3 deletions(-) create mode 100644 apps/meteor/client/views/conference/AddParticipantsModal.spec.tsx create mode 100644 apps/meteor/client/views/conference/AddParticipantsModal.tsx create mode 100644 apps/meteor/client/views/conference/CallDeviceToggle.tsx create mode 100644 apps/meteor/client/views/conference/CallMemberItem.tsx create mode 100644 apps/meteor/client/views/conference/CallMembersPanel.tsx create mode 100644 apps/meteor/client/views/conference/CallPanelHeader.tsx create mode 100644 apps/meteor/client/views/conference/CallTimer.tsx create mode 100644 apps/meteor/client/views/conference/ChatAccessModal.spec.tsx create mode 100644 apps/meteor/client/views/conference/ChatAccessModal.tsx create mode 100644 apps/meteor/client/views/conference/ChatAccessNotice.spec.tsx create mode 100644 apps/meteor/client/views/conference/ChatAccessNotice.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceChat.spec.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceChat.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceChatNotShared.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceEmbeddedPage.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceIframe.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceMemberRow.tsx create mode 100644 apps/meteor/client/views/conference/ConferencePreflight.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceRoom.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceStartPage.spec.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceStartPage.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceStatePage.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceStoresReady.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceThread.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceThreadChat.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceThreadModal.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceUnauthorizedPage.tsx create mode 100644 apps/meteor/client/views/conference/ConferenceViewport.tsx create mode 100644 apps/meteor/client/views/conference/components/CallBar/CallBar.tsx create mode 100644 apps/meteor/client/views/conference/components/CallBar/CallBarAction.tsx create mode 100644 apps/meteor/client/views/conference/components/CallBar/CallTopBar.tsx create mode 100644 apps/meteor/client/views/conference/components/CallPanel/CallPanel.tsx create mode 100644 apps/meteor/client/views/conference/hooks/useCallPreferences.ts create mode 100644 apps/meteor/client/views/conference/hooks/useConferenceEmbedded.spec.tsx create mode 100644 apps/meteor/client/views/conference/hooks/useConferenceEmbedded.tsx create mode 100644 apps/meteor/client/views/conference/hooks/useConferencePresenceLease.spec.ts create mode 100644 apps/meteor/client/views/conference/hooks/useConferencePresenceLease.ts create mode 100644 apps/meteor/client/views/conference/hooks/useConferenceSubscription.spec.ts create mode 100644 apps/meteor/client/views/conference/hooks/useConferenceSubscription.ts 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/useJoinCall.spec.tsx create mode 100644 apps/meteor/client/views/conference/hooks/useJoinCall.tsx create mode 100644 apps/meteor/client/views/conference/hooks/useJoinableCalls.ts create mode 100644 apps/meteor/client/views/conference/hooks/useLeaveConferenceOnClose.spec.ts create mode 100644 apps/meteor/client/views/conference/hooks/useLeaveConferenceOnClose.ts create mode 100644 apps/meteor/client/views/conference/hooks/useStartConference.ts create mode 100644 apps/meteor/client/views/conference/lib/callWindow.ts create mode 100644 apps/meteor/client/views/conference/testFixtures.ts diff --git a/apps/meteor/client/views/conference/AddParticipantsModal.spec.tsx b/apps/meteor/client/views/conference/AddParticipantsModal.spec.tsx new file mode 100644 index 0000000000000..5478a2283fa74 --- /dev/null +++ b/apps/meteor/client/views/conference/AddParticipantsModal.spec.tsx @@ -0,0 +1,138 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import AddParticipantsModal from './AddParticipantsModal'; +import { createFakeRoom } from '../../../tests/mocks/data'; +import { Rooms } from '../../stores'; + +// The mocked app root leaves its toast provider commented out, so what the modal reports has to be observed +// at the dispatch instead of in the DOM. +const dispatchToastMessage = jest.fn(); +jest.mock('@rocket.chat/ui-contexts', () => ({ + ...jest.requireActual('@rocket.chat/ui-contexts'), + useToastMessageDispatch: () => dispatchToastMessage, +})); + +const outsider = { _id: 'outsider-id', username: 'outsider', name: 'Outsider Person', nickname: '', status: 'online', avatarETag: '' }; +const memberUser = { _id: 'member-id', username: 'member', name: 'Room Member', nickname: '', status: 'online', avatarETag: '' }; + +const autocomplete = jest.fn((_params: { selector: string }) => ({ items: [outsider, memberUser] }) as any); +const channelMembers = jest.fn(() => ({ members: [{ _id: 'member-id', username: 'member' }] }) as any); +const addParticipants = jest.fn(() => ({ added: [outsider._id], success: true }) as any); + +const renderModal = (props: Partial<{ callId: string; rid: string }> = {}) => + render(, { + wrapper: mockAppRoot() + .withEndpoint('GET', '/v1/users.autocomplete', autocomplete) + .withEndpoint('GET', '/v1/channels.members', channelMembers) + .withEndpoint('POST', '/v1/video-conference.add-participants', addParticipants) + .withJohnDoe() + .build(), + }); + +const typeFilter = async (term: string) => { + await userEvent.type(screen.getByRole('combobox'), term); +}; + +// The shared picker labels an option with the username unless the workspace displays real names, which is the +// default this renders under. +const selectOutsider = async () => { + await typeFilter('outsider'); + await userEvent.click(await screen.findByRole('option', { name: outsider.username })); +}; + +beforeEach(() => { + autocomplete.mockClear(); + channelMembers.mockClear(); + addParticipants.mockClear(); + dispatchToastMessage.mockClear(); + // The room-absent scenario (a conference member with no chat access) must be genuinely absent, not + // left over from a previous test that seeded it. + Rooms.state.replaceAll([]); + // The ring preference outlives a test, being remembered in storage on purpose. + localStorage.clear(); +}); + +it('adds the selected user to the conference', async () => { + renderModal(); + + await selectOutsider(); + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + + await waitFor(() => expect(addParticipants).toHaveBeenCalledWith({ callId: 'call-id', users: ['outsider'], ring: true })); +}); + +it('disables the Add button until a user is selected', async () => { + renderModal(); + + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled(); + + await selectOutsider(); + + expect(screen.getByRole('button', { name: 'Add' })).toBeEnabled(); +}); + +it('excludes the room members from the autocomplete when the room is in the store', async () => { + Rooms.state.store(createFakeRoom({ _id: 'room-id', t: 'c' })); + + renderModal(); + + await typeFilter('outsider'); + + await waitFor(() => expect(autocomplete).toHaveBeenCalled()); + + const lastCall = autocomplete.mock.calls.at(-1); + expect(JSON.parse(lastCall![0].selector)).toMatchObject({ exceptions: ['member'] }); +}); + +// This is the regression that matters: a conference member added from outside the room has no room in +// this store, and the autocomplete used to be gated on `enabled: !!room`, which left it permanently +// empty for exactly the people this modal exists to serve. +it('still fetches and offers users when the room is not in the store', async () => { + renderModal(); + + await typeFilter('outsider'); + + await waitFor(() => expect(autocomplete).toHaveBeenCalled()); + expect(await screen.findByRole('option', { name: outsider.username })).toBeInTheDocument(); +}); + +// The server skips anyone already associated with the call, so a selection can come back having added +// nobody. Reporting that as success would claim people were called who never were. +it('says so when everyone selected was already in the call', async () => { + addParticipants.mockReturnValueOnce({ added: [], success: true } as any); + + renderModal(); + + await selectOutsider(); + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + + await waitFor(() => + expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'info', message: 'Selected_users_are_already_in_the_call' }), + ); +}); + +it('reports the users it did add', async () => { + renderModal(); + + await selectOutsider(); + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + + await waitFor(() => expect(dispatchToastMessage).toHaveBeenCalledWith({ type: 'success', message: 'Users_added' })); +}); + +// Taking a selection back is no longer this modal's doing: picking people is `UserAutoCompleteMultiple`, the +// same component the room's own "add users" flow uses, and chips are how it offers that. + +// Someone added so they can join later is not someone to interrupt now, so adding asks the same question the +// preflight does — and remembers the same answer, since it is one habit rather than two. +it('adds without ringing when ringing is turned off', async () => { + renderModal(); + + await selectOutsider(); + await userEvent.click(screen.getByRole('checkbox', { name: 'Ring_people' })); + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + + await waitFor(() => expect(addParticipants).toHaveBeenCalledWith({ callId: 'call-id', users: ['outsider'], ring: false })); +}); diff --git a/apps/meteor/client/views/conference/AddParticipantsModal.tsx b/apps/meteor/client/views/conference/AddParticipantsModal.tsx new file mode 100644 index 0000000000000..a65178761e4d4 --- /dev/null +++ b/apps/meteor/client/views/conference/AddParticipantsModal.tsx @@ -0,0 +1,110 @@ +import { Box, CheckBox, Field, FieldRow } from '@rocket.chat/fuselage'; +import { GenericModal } from '@rocket.chat/ui-client'; +import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useCallRingPreference } from './hooks/useCallPreferences'; +import UserAutoCompleteMultiple from '../../components/UserAutoCompleteMultiple'; +import { Rooms } from '../../stores'; + +type AddParticipantsModalProps = { + callId: string; + rid: string; + onClose: () => void; +}; + +const AddParticipantsModal = ({ callId, rid, onClose }: AddParticipantsModalProps) => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const [selected, setSelected] = useState([]); + const [adding, setAdding] = useState(false); + + // The same habit the preflight remembers, asked here for the same reason: a ring is an interruption, and + // someone added so they can join later is not someone to interrupt now. + const { ring, toggleRing } = useCallRingPreference(); + + // Present only for participants who can read the chat: a member added from outside the room has no room + // here, and must still be able to add people. + const room = Rooms.use((state) => state.get(rid)); + const isPrivate = room?.t === 'p'; + const isDirect = room?.t === 'd'; + + const addParticipants = useEndpoint('POST', '/v1/video-conference.add-participants'); + + // Members of the room are left out of the options: they can already join, so adding them would be a no-op. + // Everyone else is offerable — that is the point, since membership doesn't require room access. + // DMs expose their members on the room doc; other room types come 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]); + + // Adding makes them members of the *conference*, which is what lets them join the call — it deliberately + // puts them in no room. Whether they can read the chat is surfaced separately, once it matters, rather + // than being decided here. The server rings everyone added, unless told not to. + const handleAdd = async () => { + if (!selected.length) { + return; + } + setAdding(true); + try { + const { added } = await addParticipants({ callId, users: selected, ring }); + + // Anyone already associated with the call is skipped server-side, so a selection can come back empty. + // Reporting that as success would claim people were called who never were. + dispatchToastMessage( + added.length + ? { type: 'success', message: t('Users_added') } + : { type: 'info', message: t('Selected_users_are_already_in_the_call') }, + ); + onClose(); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } finally { + setAdding(false); + } + }; + + return ( + + + + {/* The product's own way of picking people, the same as adding them to a room — this used to be + hand-rolled here, down to the chips and the remove buttons. */} + + + + {/* Under the names, because it is a question about the people just chosen. */} + + + + + {t('Ring_people')} + + + + + ); +}; + +export default AddParticipantsModal; diff --git a/apps/meteor/client/views/conference/CallDeviceToggle.tsx b/apps/meteor/client/views/conference/CallDeviceToggle.tsx new file mode 100644 index 0000000000000..19ddc63c5b993 --- /dev/null +++ b/apps/meteor/client/views/conference/CallDeviceToggle.tsx @@ -0,0 +1,44 @@ +import { Icon, IconButton } from '@rocket.chat/fuselage'; + +type CallDeviceToggleProps = { + device: 'mic' | 'cam'; + /** Whether the device will be on. Off is the state worth shouting about, so off is the one that goes red. */ + on: boolean; + label: string; + onToggle: () => void; +}; + +const ICONS = { + mic: { on: 'mic', off: 'mic-off' }, + cam: { on: 'video', off: 'video-off' }, +} as const; + +/** + * A mic or camera toggle for the preflight, in the convention every call UI uses: **off is red**, because a + * muted mic or a dark camera is the state a user needs to notice at a glance. On is left as a ghost button — + * nothing to report. + * + * `mic-off` slashes the other way from `video-off`, so beside each other they read as two unrelated marks. The + * mic is mirrored to match, which flips its slash without visibly changing the mic itself — it is symmetric + * about that axis. + */ +const CallDeviceToggle = ({ device, on, label, onToggle }: CallDeviceToggleProps) => ( + + } + /> +); + +export default CallDeviceToggle; diff --git a/apps/meteor/client/views/conference/CallMemberItem.tsx b/apps/meteor/client/views/conference/CallMemberItem.tsx new file mode 100644 index 0000000000000..0ca8ad8ff528e --- /dev/null +++ b/apps/meteor/client/views/conference/CallMemberItem.tsx @@ -0,0 +1,78 @@ +import { isRingingVideoConferenceMember } from '@rocket.chat/core-typings'; +import { Box, Icon, IconButton, Option, OptionAvatar, OptionColumn, OptionContent } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { useSetting } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; + +import type { ConferenceMember } from './hooks/useConferenceEmbedded'; +import { getUserDisplayNames } from '../../../lib/getUserDisplayNames'; +import type { ConferenceMemberStatus } from '../../../lib/videoConference/memberStatus'; +import { canRingConferenceMember, getConferenceMemberStatus } from '../../../lib/videoConference/memberStatus'; +import { ReactiveUserStatus } from '../../components/UserStatus'; +import { useRingingExpiry } from '../../hooks/useRingingExpiry'; + +type CallMemberItemProps = { + member: ConferenceMember; + hasChatAccess: boolean; + onRing: (memberId: string) => void; +}; + +const statusLabel: Record, string> = { + left: 'Left', + declined: 'Declined', + invited: 'Waiting_for_answer', +}; + +const CallMemberItem = ({ member, hasChatAccess, onRing }: CallMemberItemProps) => { + const { t } = useTranslation(); + const useRealName = useSetting('UI_Use_Real_Name', false); + const [nameOrUsername, displayUsername] = getUserDisplayNames(member.name, member.username, useRealName); + const status = getConferenceMemberStatus(member); + + const ringing = isRingingVideoConferenceMember(member); + useRingingExpiry([ringing ? member.ringingAt : undefined]); + + return ( + + ); +}; + +export default CallMemberItem; diff --git a/apps/meteor/client/views/conference/CallMembersPanel.tsx b/apps/meteor/client/views/conference/CallMembersPanel.tsx new file mode 100644 index 0000000000000..0ebcc87fb96df --- /dev/null +++ b/apps/meteor/client/views/conference/CallMembersPanel.tsx @@ -0,0 +1,75 @@ +import { isInVideoConference } from '@rocket.chat/core-typings'; +import { Box, Button } from '@rocket.chat/fuselage'; +import { useEndpoint, useSetModal, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useMutation } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import AddParticipantsModal from './AddParticipantsModal'; +import CallMemberItem from './CallMemberItem'; +import CallPanelHeader from './CallPanelHeader'; +import type { ConferenceChatAccess, ConferenceMember } from './hooks/useConferenceEmbedded'; +import { hasConferenceChatAccess } from '../../../lib/videoConference/chatAccess'; +import { MembersListDivider } from '../room/contextualBar/RoomMembers/MembersListDivider'; + +type CallMembersPanelProps = { + callId: string; + rid?: string; + members: ConferenceMember[]; + chatAccess?: ConferenceChatAccess; + onClose: () => void; +}; + +const CallMembersPanel = ({ callId, rid, members, chatAccess, onClose }: CallMembersPanelProps) => { + const { t } = useTranslation(); + const setModal = useSetModal(); + const dispatchToastMessage = useToastMessageDispatch(); + const ring = useEndpoint('POST', '/v1/video-conference.ring'); + + const [present, absent] = useMemo( + () => [members.filter(isInVideoConference), members.filter((member) => !isInVideoConference(member))], + [members], + ); + + const { mutate: ringMember } = useMutation({ + mutationFn: (memberId: string) => ring({ callId, users: [memberId] }), + onError: (error) => dispatchToastMessage({ type: 'error', message: error }), + }); + + const renderMember = (member: ConferenceMember) => ( + + ); + + return ( + <> + + {rid && ( + + )} + + + + {present.length > 0 && ( + <> + + {present.map(renderMember)} + + )} + {absent.length > 0 && ( + <> + + {absent.map(renderMember)} + + )} + + + ); +}; + +export default CallMembersPanel; diff --git a/apps/meteor/client/views/conference/CallPanelHeader.tsx b/apps/meteor/client/views/conference/CallPanelHeader.tsx new file mode 100644 index 0000000000000..bdb21313d91be --- /dev/null +++ b/apps/meteor/client/views/conference/CallPanelHeader.tsx @@ -0,0 +1,28 @@ +import { ContextualbarActions, ContextualbarClose, ContextualbarHeader, ContextualbarTitle } from '@rocket.chat/ui-client'; +import type { ReactNode } from 'react'; + +type CallPanelHeaderProps = { + title: ReactNode; + /** Anything the panel offers about itself, sitting before the dismissal. */ + children?: ReactNode; + onClose: () => void; +}; + +/** + * The top of a panel docked beside the call — the chat, the members. + * + * The product's own contextual-bar header, so these panels agree with every other closable surface about where + * a title sits and where dismissal is, and the panels share this so two docked side by side don't disagree + * about their own edges. + */ +const CallPanelHeader = ({ title, children, onClose }: CallPanelHeaderProps) => ( + + {title} + + {children} + + + +); + +export default CallPanelHeader; diff --git a/apps/meteor/client/views/conference/CallTimer.tsx b/apps/meteor/client/views/conference/CallTimer.tsx new file mode 100644 index 0000000000000..011de3ee09427 --- /dev/null +++ b/apps/meteor/client/views/conference/CallTimer.tsx @@ -0,0 +1,50 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useEffect, useState } from 'react'; + +type CallTimerProps = { startAt?: Date }; + +const CallTimer = ({ startAt }: CallTimerProps) => { + const [start] = useState(() => { + if (!startAt) { + return Date.now(); + } + return startAt.getTime(); + }); + + const [ellapsedTime, setEllapsedTime] = useState(() => { + if (!start) { + return 0; + } + return Date.now() - start; + }); + + useEffect(() => { + const interval = setInterval(() => { + setEllapsedTime(() => { + const now = Date.now(); + return now - start; + }); + }, 1000); + + return () => clearInterval(interval); + }, [start]); + + const totalSeconds = Math.floor(ellapsedTime / 1000); + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = Math.floor(totalSeconds % 60); + + const hoursStr = hours.toString().padStart(2, '0'); + const minutesStr = minutes.toString().padStart(2, '0'); + const secondsStr = seconds.toString().padStart(2, '0'); + + return ( + + {hoursStr !== '00' ? `${hours}:` : ''} + {minutesStr}:{secondsStr} + + ); +}; + +export default CallTimer; diff --git a/apps/meteor/client/views/conference/ChatAccessModal.spec.tsx b/apps/meteor/client/views/conference/ChatAccessModal.spec.tsx new file mode 100644 index 0000000000000..61256073d6bc9 --- /dev/null +++ b/apps/meteor/client/views/conference/ChatAccessModal.spec.tsx @@ -0,0 +1,65 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import ChatAccessModal from './ChatAccessModal'; +import type { ConferenceChatAccess } from './hooks/useConferenceEmbedded'; + +const member = { _id: 'outsider-id', username: 'outsider', name: 'Outsider Person' }; + +const buildAccess = (overrides: Partial = {}): ConferenceChatAccess => ({ + rid: 'room-id', + name: 'general', + type: 'c', + membersWithoutAccess: [member._id], + canInvite: true, + members: [member], + ...overrides, +}); + +const shareChat = jest.fn(() => ({ rid: 'room-id', success: true })); + +const renderModal = (access: ConferenceChatAccess) => + render(, { + wrapper: mockAppRoot() + .withEndpoint('POST', '/v1/video-conference.share-chat', shareChat as any) + .build(), + }); + +beforeEach(() => { + shareChat.mockClear(); +}); + +it('names the members who cannot see the chat', () => { + renderModal(buildAccess()); + + expect(screen.getByText(member.username)).toBeInTheDocument(); +}); + +// Which of the two leads is `chatAccessLeadsWithDiscussion`, pinned on the function itself in +// `tests/unit/lib/videoConference/chatAccess.spec.ts`. What is worth asserting here is that the modal is wired +// to it at all — and that costs one case, not one per room type. +it('leads with the invite for a public room, whose history is already open', () => { + renderModal(buildAccess({ type: 'c' })); + + expect(screen.getByRole('button', { name: 'Add_to_room' })).toHaveClass('rcx-button--primary'); + expect(screen.getByRole('button', { name: 'Create_discussion' })).not.toHaveClass('rcx-button--primary'); +}); + +it('offers only the discussion when the room cannot take new members', () => { + renderModal(buildAccess({ type: 'd', canInvite: false })); + + expect(screen.getByRole('button', { name: 'Create_discussion' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Add_to_room' })).not.toBeInTheDocument(); +}); + +it.each([ + ['Add_to_room', 'invite'], + ['Create_discussion', 'discussion'], +])('asks the server for %s by mode', async (label, mode) => { + renderModal(buildAccess()); + + await userEvent.click(screen.getByRole('button', { name: label })); + + await waitFor(() => expect(shareChat).toHaveBeenCalledWith({ callId: 'call-id', mode })); +}); diff --git a/apps/meteor/client/views/conference/ChatAccessModal.tsx b/apps/meteor/client/views/conference/ChatAccessModal.tsx new file mode 100644 index 0000000000000..2cd63fd01856e --- /dev/null +++ b/apps/meteor/client/views/conference/ChatAccessModal.tsx @@ -0,0 +1,128 @@ +import type { VideoConferenceChatAccessMode } from '@rocket.chat/core-typings'; +import { + Box, + Button, + Modal, + ModalClose, + ModalContent, + ModalFooter, + ModalFooterControllers, + ModalHeader, + ModalHeaderText, + ModalTitle, +} from '@rocket.chat/fuselage'; +import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useId } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import ConferenceMemberRow from './ConferenceMemberRow'; +import type { ConferenceChatAccess } from './hooks/useConferenceEmbedded'; +import { chatAccessLeadsWithDiscussion } from '../../../lib/videoConference/chatAccess'; +import { videoConferenceQueryKeys } from '../../lib/queryKeys'; + +type ChatAccessModalProps = { + callId: string; + access: ConferenceChatAccess; + onClose: () => void; +}; + +/** + * Both ways out of "some members can't see the chat" give something away — the room's history, or the + * conversation's place in it — so neither can be applied on the user's behalf. The consequences are spelled + * out next to each action and the modal is dismissable, which is the whole point of asking here. + * + * Which one leads is a privacy call, shared with the server so the two can't drift — see + * `chatAccessLeadsWithDiscussion`. A DM can't take new members at all, so there the discussion is the only + * option offered. + */ +const ChatAccessModal = ({ callId, access, onClose }: ChatAccessModalProps) => { + const { t } = useTranslation(); + const titleId = useId(); + const dispatchToastMessage = useToastMessageDispatch(); + const shareChat = useEndpoint('POST', '/v1/video-conference.share-chat'); + const queryClient = useQueryClient(); + + // The server broadcasts the change to every participant, but don't make the one who asked for it wait for + // the round trip to see their own notice go away. + const { mutate, isPending, variables } = useMutation({ + mutationFn: (mode: VideoConferenceChatAccessMode) => shareChat({ callId, mode }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: videoConferenceQueryKeys.conference(callId) }); + onClose(); + }, + onError: (error) => dispatchToastMessage({ type: 'error', message: error }), + }); + + const roomName = access.name; + const discussionLeads = chatAccessLeadsWithDiscussion(access); + + const inviteButton = access.canInvite && ( + + ); + + const discussionButton = ( + + ); + + return ( + + + + {t('Chat_access')} + + + + + {t('These_participants_cannot_see_the_chat')} + {access.members.map((member) => ( + + ))} + + {access.canInvite && ( + + + {t('Add_to_room')} + + + }} + /> + + + )} + + + + {t('Create_discussion')} + + + }} + /> + + + + + + + {/* The leading action sits last, where the primary action is expected. */} + {discussionLeads ? inviteButton : discussionButton} + {discussionLeads ? discussionButton : inviteButton} + + + + ); +}; + +export default ChatAccessModal; diff --git a/apps/meteor/client/views/conference/ChatAccessNotice.spec.tsx b/apps/meteor/client/views/conference/ChatAccessNotice.spec.tsx new file mode 100644 index 0000000000000..2a947362ba85b --- /dev/null +++ b/apps/meteor/client/views/conference/ChatAccessNotice.spec.tsx @@ -0,0 +1,62 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import ChatAccessNotice from './ChatAccessNotice'; +import type { ConferenceChatAccess } from './hooks/useConferenceEmbedded'; +import { buildChatAccess } from './testFixtures'; + +// `withJohnDoe` fixes the logged-in id, so the member without access has to be that same user to test self-exclusion. +const uid = 'john.doe'; + +const buildAccess = (membersWithoutAccess: string[], joined = true) => buildChatAccess({ membersWithoutAccess, joined }); + +const renderNotice = (access: ConferenceChatAccess) => + render(, { + wrapper: mockAppRoot().withJohnDoe().build(), + }); + +it('shows the count and a Review button when members are missing chat access', () => { + renderNotice(buildAccess(['someone-else'])); + + expect(screen.getByText('__count__participants_cannot_see_the_chat')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Review' })).toBeInTheDocument(); +}); + +it('renders nothing when no member is missing access', () => { + const { container } = renderNotice(buildAccess([])); + + expect(container).toBeEmptyDOMElement(); +}); + +it('renders nothing to a member who is themselves missing access, since they cannot share what they cannot read', () => { + const { container } = renderNotice(buildAccess([uid])); + + expect(container).toBeEmptyDOMElement(); +}); + +it('opens the chat access modal when Review is clicked', async () => { + renderNotice(buildAccess(['someone-else'])); + + await userEvent.click(screen.getByRole('button', { name: 'Review' })); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Chat_access')).toBeInTheDocument(); +}); + +// Someone merely invited may never turn up. Telling everyone else about a person who isn't there is noise, and +// it would have them resolving a situation that hasn't happened. +it('says nothing about a member who was invited but has not joined', () => { + const { container } = renderNotice(buildAccess(['someone-else'], false)); + + expect(container).toBeEmptyDOMElement(); +}); + +it('counts only the members who are actually in the call', () => { + const access = buildAccess(['present', 'absent']); + access.members = access.members.map((member) => (member._id === 'absent' ? { ...member, joined: false } : member)); + + renderNotice(access); + + expect(screen.getByRole('button', { name: 'Review' })).toBeInTheDocument(); +}); diff --git a/apps/meteor/client/views/conference/ChatAccessNotice.tsx b/apps/meteor/client/views/conference/ChatAccessNotice.tsx new file mode 100644 index 0000000000000..9bb982a5eee19 --- /dev/null +++ b/apps/meteor/client/views/conference/ChatAccessNotice.tsx @@ -0,0 +1,61 @@ +import { hasJoinedVideoConference } from '@rocket.chat/core-typings'; +import { css } from '@rocket.chat/css-in-js'; +import { Box, Button, IconButton } from '@rocket.chat/fuselage'; +import { AnnouncementBanner } from '@rocket.chat/ui-client'; +import { useSetModal, useUserId } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; + +import ChatAccessModal from './ChatAccessModal'; +import type { ConferenceChatAccess } from './hooks/useConferenceEmbedded'; +import { hasConferenceChatAccess } from '../../../lib/videoConference/chatAccess'; + +type ChatAccessNoticeProps = { + callId: string; + access: ConferenceChatAccess; + onDismiss?: () => void; +}; + +// The banner itself isn't the control here — the Review button is — so undo the affordances +// `AnnouncementBanner` shows for the clickable case. +const notInteractive = css` + cursor: default; + &:hover { + text-decoration: none; + } +`; + +/** + * Being added to a conference grants no room access, so some members can be in the call without being able + * to read its chat. Rather than forcing that choice on whoever adds them, it is surfaced here once it + * matters, with the ways to resolve it and their consequences a click away. + */ +const ChatAccessNotice = ({ callId, access, onDismiss }: ChatAccessNoticeProps) => { + const { t } = useTranslation(); + const setModal = useSetModal(); + const uid = useUserId(); + + // Someone merely invited may never turn up, and telling everyone else about a person who isn't there is + // noise. The situation only exists once they are in the call and can't read what is being said. + const present = access.members.filter(hasJoinedVideoConference); + + // Only shown to participants who can act on it: a member who can't read the chat can't share it either. + if (!present.length || !hasConferenceChatAccess(access, uid)) { + return null; + } + + return ( + + + {t('__count__participants_cannot_see_the_chat', { count: present.length })} + + + {onDismiss && } + + + + ); +}; + +export default ChatAccessNotice; diff --git a/apps/meteor/client/views/conference/ConferenceChat.spec.tsx b/apps/meteor/client/views/conference/ConferenceChat.spec.tsx new file mode 100644 index 0000000000000..2e2d7a62a24a6 --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceChat.spec.tsx @@ -0,0 +1,46 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; + +import ConferenceChat from './ConferenceChat'; +import type { ConferenceChatAccess } from './hooks/useConferenceEmbedded'; +import { buildChatAccess } from './testFixtures'; + +// The room UI underneath needs the whole store-seeding apparatus, which isn't what these assertions are about. +jest.mock('./ConferenceStoresReady', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +jest.mock('./ConferenceRoom', () => ({ __esModule: true, default: () => null })); +jest.mock('./ConferenceThread', () => ({ __esModule: true, default: () => null })); + +// `withJohnDoe` fixes the logged-in id, so the member without access has to be that same user. +const uid = 'john.doe'; + +const buildAccess = (membersWithoutAccess: string[]) => buildChatAccess({ membersWithoutAccess }); + +const renderChat = (chatAccess: ConferenceChatAccess) => + render(, { + wrapper: mockAppRoot().withJohnDoe().build(), + }); + +it('tells a member whose chat was never shared what the situation is', () => { + renderChat(buildAccess([uid])); + + expect(screen.getByText('Chat_not_shared_with_you')).toBeInTheDocument(); + expect(screen.queryByTestId('chat-room')).not.toBeInTheDocument(); +}); + +it('shows the chat to a member who can read it', () => { + renderChat(buildAccess(['someone-else'])); + + expect(screen.getByTestId('chat-room')).toBeInTheDocument(); + expect(screen.queryByText('Chat_not_shared_with_you')).not.toBeInTheDocument(); +}); + +// The banner about members who can't see the chat lives above the call, not in this panel — it is about the +// call rather than about whichever panel is open, and it must not move as panels change. +it('does not carry the chat-access notice', () => { + renderChat(buildAccess(['someone-else'])); + + expect(screen.queryByRole('button', { name: 'Review' })).not.toBeInTheDocument(); +}); diff --git a/apps/meteor/client/views/conference/ConferenceChat.tsx b/apps/meteor/client/views/conference/ConferenceChat.tsx new file mode 100644 index 0000000000000..007bdde4e5aea --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceChat.tsx @@ -0,0 +1,99 @@ +import type { IRoom } from '@rocket.chat/core-typings'; +import { hasJoinedVideoConference } from '@rocket.chat/core-typings'; +import { Box, Icon, IconButton } from '@rocket.chat/fuselage'; +import { useSetModal, useUserId } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; + +import CallPanelHeader from './CallPanelHeader'; +import ChatAccessModal from './ChatAccessModal'; +import ConferenceChatNotShared from './ConferenceChatNotShared'; +import ConferenceRoom from './ConferenceRoom'; +import ConferenceStoresReady from './ConferenceStoresReady'; +import ConferenceThread from './ConferenceThread'; +import type { ConferenceChatAccess } from './hooks/useConferenceEmbedded'; +import { hasConferenceChatAccess } from '../../../lib/videoConference/chatAccess'; +import NotFoundPage from '../notFound/NotFoundPage'; +import PageLoading from '../root/PageLoading'; + +const roomTypeIcon = (t?: IRoom['t']): 'hash' | 'hashtag-lock' | 'at' | 'baloons' => { + switch (t) { + case 'p': + return 'hashtag-lock'; + case 'd': + return 'at'; + default: + return 'hash'; + } +}; + +type ConferenceChatProps = { + callId: string; + rid?: string; + tmid?: string; + roomName?: string; + roomType?: IRoom['t']; + loading: boolean; + chatAccess?: ConferenceChatAccess; + onClose: () => void; +}; + +const ConferenceChat = ({ callId, rid, tmid, roomName, roomType, loading, chatAccess, onClose }: ConferenceChatProps) => { + const { t } = useTranslation(); + const uid = useUserId(); + const setModal = useSetModal(); + + if (loading) { + return ; + } + + if (!rid) { + return ; + } + + // Membership grants no room access, so the chat may be a room this user can't read. The server already + // worked out who those members are, which beats letting the room fetch fail and calling it a missing page. + const shared = hasConferenceChatAccess(chatAccess, uid); + const presentWithoutAccess = shared && chatAccess ? chatAccess.members.filter(hasJoinedVideoConference).length : 0; + + const headerLabel = tmid ? t('Thread') : t('Chat'); + const title = roomName ? ( + <> + {tmid ? t('Thread_in') : t('Chat_in')} {roomName} + + ) : ( + headerLabel + ); + + return ( + + + {presentWithoutAccess > 0 && chatAccess && ( + setModal( setModal(null)} />)} + /> + )} + + + {!shared && } + + {shared && tmid && ( + + + + )} + + {shared && !tmid && ( + + + + )} + + ); +}; + +export default ConferenceChat; diff --git a/apps/meteor/client/views/conference/ConferenceChatNotShared.tsx b/apps/meteor/client/views/conference/ConferenceChatNotShared.tsx new file mode 100644 index 0000000000000..d1e5392a91dda --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceChatNotShared.tsx @@ -0,0 +1,23 @@ +import { Box, States, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +/** + * Conference membership grants no room access, so being in a call doesn't mean being in its chat. That isn't + * an error and it isn't this user's to fix — any participant who can read the chat is shown the same situation + * from the other side, with the actions to resolve it. Say so, rather than reporting a missing page. + */ +const ConferenceChatNotShared = () => { + const { t } = useTranslation(); + + return ( + + + + {t('Chat_not_shared_with_you')} + {t('Chat_not_shared_with_you_description')} + + + ); +}; + +export default ConferenceChatNotShared; diff --git a/apps/meteor/client/views/conference/ConferenceEmbeddedPage.tsx b/apps/meteor/client/views/conference/ConferenceEmbeddedPage.tsx new file mode 100644 index 0000000000000..1213b0ca7c093 --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceEmbeddedPage.tsx @@ -0,0 +1,289 @@ +import { isInVideoConference, isRingingVideoConferenceMember } from '@rocket.chat/core-typings'; +import { css } from '@rocket.chat/css-in-js'; +import { Badge, Box, Icon } from '@rocket.chat/fuselage'; +import { useBreakpoints } from '@rocket.chat/fuselage-hooks'; +import { useCustomSound, useUser, useUserSubscription } from '@rocket.chat/ui-contexts'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import CallMembersPanel from './CallMembersPanel'; +import CallTimer from './CallTimer'; +import ChatAccessNotice from './ChatAccessNotice'; +import ConferenceChat from './ConferenceChat'; +import ConferenceIframe from './ConferenceIframe'; +import ConferencePageError from './ConferencePageError'; +import ConferencePreflight from './ConferencePreflight'; +import ConferenceStatePage from './ConferenceStatePage'; +import ConferenceThreadModal from './ConferenceThreadModal'; +import ConferenceUnauthorizedPage from './ConferenceUnauthorizedPage'; +import CallTopBar from './components/CallBar/CallTopBar'; +import CallPanel from './components/CallPanel/CallPanel'; +import { useConferenceEmbedded } from './hooks/useConferenceEmbedded'; +import { useConferencePresenceLease } from './hooks/useConferencePresenceLease'; +import { useConferenceSubscription } from './hooks/useConferenceSubscription'; +import { useConfinedNavigation } from './hooks/useConfinedNavigation'; +import { useLeaveConferenceOnClose } from './hooks/useLeaveConferenceOnClose'; +import { PREFLIGHT_FACES_SHOWN } from '../../../lib/videoConference/constants'; +import { useRingingExpiry } from '../../hooks/useRingingExpiry'; +import { useUnreadDisplay } from '../../sidebar/hooks/useUnreadDisplay'; +import PageLoading from '../root/PageLoading'; + +type ConferenceEmbeddedPageProps = { + callId: string; +}; + +type ConferencePanel = 'members' | 'chat'; + +const emptyUnreadData = { alert: false, userMentions: 0, unread: 0, groupMentions: 0 } as const; + +const membersIndicatorStyles = css` + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border: none; + border-radius: 20px; + background: transparent; + color: rgba(255, 255, 255, 0.85); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 80ms ease; + line-height: 1; + + &:hover { + background-color: rgba(255, 255, 255, 0.12); + } + + &[aria-pressed='true'] { + background-color: rgba(255, 255, 255, 0.2); + } +`; + +const callHeaderTimerStyles = css` + display: inline-flex; + align-items: center; + min-width: 0; + color: rgba(255, 255, 255, 0.85); + font-variant-numeric: tabular-nums; +`; + +const topBarActionStyles = css` + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 8px; + background: transparent; + color: rgba(255, 255, 255, 0.85); + cursor: pointer; + transition: background-color 80ms ease; + + &:hover { + background-color: rgba(255, 255, 255, 0.12); + } + + &[aria-pressed='true'] { + background-color: rgba(255, 255, 255, 0.2); + } +`; + +const ConferenceEmbeddedPage = ({ callId }: ConferenceEmbeddedPageProps) => { + const { room, conference, call } = useConferenceEmbedded(callId); + const { t } = useTranslation(); + const [threadTmid, setThreadTmid] = useState(null); + + const handleOpenThread = useCallback( + (tmid: string) => { + if (!room.rid) { + return; + } + setThreadTmid(tmid); + }, + [room.rid], + ); + + useConfinedNavigation({ onOpenThread: room.tmid ? undefined : handleOpenThread }); + + const { leaveNow } = useLeaveConferenceOnClose(callId); + + useConferencePresenceLease(callId, conference.joined); + + const user = useUser(); + + const [bannerDismissed, setBannerDismissed] = useState(false); + + const [activePanel, setActivePanel] = useState(); + const togglePanel = useCallback((panel: ConferencePanel) => setActivePanel((current) => (current === panel ? undefined : panel)), []); + const chatVisible = activePanel === 'chat'; + + const breakpoints = useBreakpoints(); + const overlayPanel = !breakpoints.includes('md'); + + useConferenceSubscription(room.rid); + + const subscription = useUserSubscription(room.rid ?? ''); + const { showUnread, unreadCount, unreadVariant, unreadTitle } = useUnreadDisplay(subscription ?? emptyUnreadData); + const unread = !chatVisible && showUnread ? unreadCount.total : 0; + const hasUnseenActivity = !chatVisible && !unread && Boolean(subscription?.alert); + + const present = useMemo(() => call.members.filter(isInVideoConference), [call.members]); + const presentCount = present.length; + + const { callSounds } = useCustomSound(); + const otherMembers = call.canRing && conference.joined ? call.members.filter((m) => m._id !== user?._id && !isInVideoConference(m)) : []; + useRingingExpiry(otherMembers.map((m) => m.ringingAt)); + const someoneRinging = otherMembers.some((m) => isRingingVideoConferenceMember(m)); + useEffect(() => { + if (someoneRinging) { + callSounds.playDialer(); + } else { + callSounds.stopDialer(); + } + return () => callSounds.stopDialer(); + }, [someoneRinging, callSounds]); + + const membersAction = ( + togglePanel('members')} + > + + {presentCount} + + ); + + const chatAction = ( + togglePanel('chat')} + > + + {unread > 0 && ( + + + {unread} + + + )} + {unread === 0 && hasUnseenActivity && ( + + + + )} + + ); + + if (room.error) { + return ; + } + + if (conference.error) { + return ; + } + + if (conference.loading) { + return ; + } + + if (call.ended && !conference.joined) { + return ; + } + + if (!conference.joined) { + if (room.loading) { + return ; + } + + return ( + conference.join({ state: preferences, name })} + onCancel={leaveNow} + /> + ); + } + + if (!conference.url) { + return ; + } + + return ( + + {room.chatAccess && !bannerDismissed && ( + setBannerDismissed(true)} /> + )} + + + + {call.name && ( + <> + + | + + + {call.name} + + + )} + + } + > + {membersAction} + {chatAction} + + + + + + + + + {activePanel === 'members' && ( + togglePanel('members')} + /> + )} + {activePanel === 'chat' && ( + togglePanel('chat')} + /> + )} + + + + {threadTmid && room.rid && setThreadTmid(null)} />} + + ); +}; + +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..a405c3702099b --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceIframe.tsx @@ -0,0 +1,29 @@ +import type { Ref } from 'react'; +import { useTranslation } from 'react-i18next'; + +type ConferenceIframeProps = { + url: string; + /** Exposes the provider's window, so messages it posts back can be attributed to this frame. */ + ref?: Ref; +}; + +const ConferenceIframe = ({ url, ref }: ConferenceIframeProps) => { + const { t } = useTranslation(); + + return ( + // `aria-label` names the frame instead of `title`. A `title` on a full-viewport iframe also renders + // as a hover tooltip, floating a label over the call for as long as the pointer is inside it. + // eslint-disable-next-line jsx-a11y/iframe-has-title +