diff --git a/.changeset/native-livekit-video-conference.md b/.changeset/native-livekit-video-conference.md new file mode 100644 index 0000000000000..7d4453540ade0 --- /dev/null +++ b/.changeset/native-livekit-video-conference.md @@ -0,0 +1,17 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/models': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/jwt': minor +'@rocket.chat/ui-voip': minor +'@rocket.chat/meteor': minor +--- + +Adds LiveKit as a native video conference provider, so a call can run inside Rocket.Chat instead of handing the user to someone else's page. + +Every provider until now has been a URL: the workspace knows a call is open and nothing else — not who is in it, not what was said, and certainly not how to record it. A native provider changes what the server can know. Conferences now carry the people actually connected, so the call itself can be the source of who is present rather than an inference from who clicked join. + +On top of that: multi-party calls with a grid and spotlight, screen sharing, hand-raise and reactions, and a floating widget that keeps the call with the user when they navigate to another room. Recording runs through LiveKit egress and lands as an ordinary upload posted in a thread under the call's message. Live captions are opt-in per user and come from a worker that joins each room as a hidden participant; with note-taking on, the transcript is kept and an AI summary is posted alongside it when the call ends. + +The provider is enterprise, off unless configured, and sits beside the existing URL-based providers rather than replacing them — a workspace with no LiveKit deployment behaves exactly as before. diff --git a/.changeset/videoconf-persistent-chat.md b/.changeset/videoconf-persistent-chat.md new file mode 100644 index 0000000000000..28d2122fb2e3f --- /dev/null +++ b/.changeset/videoconf-persistent-chat.md @@ -0,0 +1,27 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/core-services': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/models': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/ddp-client': minor +'@rocket.chat/fuselage-ui-kit': minor +'@rocket.chat/ui-client': minor +'@rocket.chat/ui-kit': minor +'@rocket.chat/ui-voip': minor +'@rocket.chat/i18n': minor +'@rocket.chat/mock-providers': minor +'@rocket.chat/meteor': minor +--- + +Gives a video conference a chat that outlives it, and a window of its own to hold both. + +Joining a conference now opens a dedicated call window at `/conference/:id` — the provider's call beside the conference's chat, with the people on the call in a panel of their own — instead of handing the user off to the provider's page. A preflight screen opens first: it is where the camera and microphone are chosen, where whoever started a group call can name it, and where confirming is what actually creates the call, so a call nobody confirmed leaves no message, no ring and no history behind. Closing the window reports leaving, and a call nobody is left in ends by itself. + +Adding someone to a conference makes them a member of the **conference** rather than putting them in a room. Membership authorizes joining the call alongside room access, so a person from outside the conference's room can join without being handed the room's history — and whether they can read the chat becomes a separate question, surfaced once it matters with a choice of how to resolve it: bring them into the room, or move the chat to a discussion. `video-conference.info` reports the members who can't read it and `POST /v1/video-conference.share-chat` applies the remedy; `video-conference.add-participants` takes just the users and returns the ids it added. + +An incoming call is no longer a popup demanding an answer. It is the first item of a list of the calls running now — docked in the sidebar — where it can be accepted, turned down, or silenced and left ringing while the user finishes what they were doing. That list is also how a call is reached when its ring was missed entirely, which a one-shot ring in a room of more than ten people always is (`GET /v1/video-conference.joinable`). + +Conferences appear in the personal Call History from the moment they start, as `ongoing`, settling per member into `ended` or `not-answered` when the call stops — so a call that was declined or never answered is still in the log, and still joinable from it. Conference discussions carry a banner back into the ongoing call, and the room's own call list stops counting members who were added but never joined. + +New endpoints: `video-conference.decline` (recorded against the caller's own membership, never ending the call for anyone else), `.leave`, `.ring` (to try someone again — a ring is one-shot, so there was previously no second attempt), `.rename` and `.share-chat`. A single `video-conference.updated` stream event tells an open call window that the conference it is showing has changed. diff --git a/apps/meteor/app/ui/client/lib/UserAction.ts b/apps/meteor/app/ui/client/lib/UserAction.ts index 061aacd739738..cf1929ee0cbfa 100644 --- a/apps/meteor/app/ui/client/lib/UserAction.ts +++ b/apps/meteor/app/ui/client/lib/UserAction.ts @@ -23,7 +23,10 @@ const activityTimeouts = new Map(); const activityRenews = new Map(); const continuingIntervals = new Map(); const roomActivities = new Map>(); -const rooms = new Map void>(); +const rooms = new Map< + string, + { handler: (username: string, activityType: string[], extras?: object | undefined) => void; stop: () => void; refs: number } +>(); const performingUsers = new Map(); const performingUsersEmitter = new Emitter<{ changed: void }>(); @@ -69,8 +72,16 @@ function handleStreamAction(rid: string, username: string, activityTypes: string } 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 () => { + existing.refs--; + if (existing.refs === 0) { + existing.stop(); + rooms.delete(rid); + } + }; } const handler = function (username: string, activityType: string[], extras?: object): void { @@ -82,15 +93,17 @@ export const UserAction = new (class { } handleStreamAction(rid, username, activityType, extras); }; - rooms.set(rid, handler); const { stop } = sdk.stream('notify-room', [`${rid}/${USER_ACTIVITY}`], handler); + const entry = { handler, stop, refs: 1 }; + rooms.set(rid, entry); + return () => { - if (!rooms.get(rid)) { - return; + entry.refs--; + if (entry.refs === 0) { + stop(); + rooms.delete(rid); } - stop(); - rooms.delete(rid); }; } diff --git a/apps/meteor/client/components/CallParticipants.spec.tsx b/apps/meteor/client/components/CallParticipants.spec.tsx new file mode 100644 index 0000000000000..05a755cf339b6 --- /dev/null +++ b/apps/meteor/client/components/CallParticipants.spec.tsx @@ -0,0 +1,78 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; + +import CallParticipants from './CallParticipants'; + +const person = (username: string) => ({ _id: username, username }); + +const renderParticipants = (props: Parameters[0], avatars = true) => + render(, { + wrapper: mockAppRoot().withJohnDoe().withUserPreference('displayAvatars', avatars).build(), + }); + +// Faces say *who* is in the call, which is usually what decides whether to join. The count alone never did. +it('shows a face for each of the people it was given', () => { + const { container } = renderParticipants({ people: [person('alice'), person('bob')], total: 2 }); + + expect(container.querySelectorAll('img')).toHaveLength(2); +}); + +// Said the way the call's own message block says it, so a call reads the same in the sidebar as in its room. +it('follows the faces with how many more there are', () => { + renderParticipants({ people: [person('alice'), person('bob'), person('carol')], total: 12 }); + + expect(screen.getByText('plus__usersCount__joined')).toBeInTheDocument(); +}); + +it('says just "joined" when every one of them is shown', () => { + renderParticipants({ people: [person('alice'), person('bob')], total: 2 }); + + expect(screen.getByText('joined')).toBeInTheDocument(); + expect(screen.queryByText('plus__usersCount__joined')).not.toBeInTheDocument(); +}); + +it('still says how many there are, for anyone who cannot see the faces', () => { + renderParticipants({ people: [person('alice')], total: 4 }); + + expect(screen.getByTitle('__count__people_in_the_call')).toBeInTheDocument(); +}); + +// A single face reads as a mistake rather than as a call, so an empty second place stands beside it — decorative, +// which is why it is hidden from anyone listening rather than looking. +it('gives a lone face an empty place beside it', () => { + const { container } = renderParticipants({ people: [person('alice')], total: 1 }); + + expect(container.querySelectorAll('img')).toHaveLength(1); + expect(container.querySelectorAll('div[aria-hidden="true"]')).toHaveLength(1); +}); + +it('leaves the empty place out once there are two', () => { + const { container } = renderParticipants({ people: [person('alice'), person('bob')], total: 2 }); + + expect(container.querySelectorAll('div[aria-hidden="true"]')).toHaveLength(0); +}); + +// Side by side rather than overlapped, so nothing has to be stacked and no face is half hidden behind another. +it('sets the faces beside each other', () => { + const people = [person('alice'), person('bob'), person('carol')]; + const { container } = renderParticipants({ people, total: 3 }); + + expect(container.querySelectorAll('img')).toHaveLength(people.length); + expect(container.querySelector('[style*="z-index"]')).toBeNull(); +}); + +// An older server, or a call whose members did not travel with it. +it('falls back to the number when there are no faces to show', () => { + const { container } = renderParticipants({ people: [], total: 3 }); + + expect(screen.getByText('__usersCount__joined')).toBeInTheDocument(); + expect(container.querySelectorAll('img')).toHaveLength(0); +}); + +// Avatars are a preference, and the message block honours it by saying the count in words instead. +it('says it in words when the reader has avatars turned off', () => { + const { container } = renderParticipants({ people: [person('alice'), person('bob')], total: 5 }, false); + + expect(screen.getByText('__usersCount__joined')).toBeInTheDocument(); + expect(container.querySelectorAll('img')).toHaveLength(0); +}); diff --git a/apps/meteor/client/components/CallParticipants.tsx b/apps/meteor/client/components/CallParticipants.tsx new file mode 100644 index 0000000000000..a4a5c1ca6a479 --- /dev/null +++ b/apps/meteor/client/components/CallParticipants.tsx @@ -0,0 +1,73 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { css } from '@rocket.chat/css-in-js'; +import { Box } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { useUserPreference } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; + +/** + * A little definition under each face, so a row of them reads as faces rather than as a strip of colour. + * `drop-shadow` rather than `box-shadow` because it follows the avatar's own rounded shape — the radius belongs to + * the avatar, and guessing it here would leave a square shadow behind a rounded picture. + */ +const facesStyles = css` + filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.24)) drop-shadow(0 1px 2px rgba(0, 0, 0, 0.32)); +`; + +type CallParticipantsProps = { + /** A few of the people in the call — whoever is to get a face. Capped by the caller. */ + people: (Pick & Partial>)[]; + /** How many are in the call altogether, which is what the count after the faces is worked out from. */ + total: number; + /** Avatar size, since a sidebar row and a full screen don't want the same one. */ + size?: 'x18' | 'x24'; +}; + +/** + * Who is already in a call: their faces, then how many more there are. + * + * Says it the way the call's own message block says it — the faces, then `+ 3 joined`, or just `joined` when they + * are all shown. Same arrangement and the same phrases (`plus__usersCount__joined`, `joined`), because a call the + * user meets in the sidebar and again in its room should read the same both times. + * + * Faces answer *who* is in there, which is usually what decides whether to walk in. With avatars turned off there + * is nobody to show, so it falls back to the count in words, as the message block does. + */ +const CallParticipants = ({ people, total, size = 'x18' }: CallParticipantsProps) => { + const { t } = useTranslation(); + const displayAvatars = useUserPreference('displayAvatars'); + + // The whole count, as the group's label: it is what a screen reader gets instead of the faces, and "+ 3" only + // means something next to a total. + const label = t('__count__people_in_the_call', { count: total }); + + // Faces switched off, or a call whose members didn't travel with it — an older server, say. + if (!displayAvatars || !people.length) { + return ( + + {t('__usersCount__joined', { count: total })} + + ); + } + + const remaining = total - people.length; + + return ( + + {/* Side by side with a little air between them, rather than overlapped: there are only ever a few, and + a face half behind another face is a worse picture of who is in the call. */} + + {people.map(({ _id, username }) => ( + + + + ))} + + + {remaining > 0 ? t('plus__usersCount__joined', { count: remaining }) : t('joined')} + + + ); +}; + +export default CallParticipants; diff --git a/apps/meteor/client/components/OngoingCalls/CallListItem.tsx b/apps/meteor/client/components/OngoingCalls/CallListItem.tsx new file mode 100644 index 0000000000000..17871baa11663 --- /dev/null +++ b/apps/meteor/client/components/OngoingCalls/CallListItem.tsx @@ -0,0 +1,43 @@ +import type { JoinableVideoConference } from '@rocket.chat/core-typings'; +import { Box, Icon } from '@rocket.chat/fuselage'; +import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; + +import Extended from '../../sidebar/Item/Extended'; + +type CallListItemProps = { + call: JoinableVideoConference; + timeLabel?: ReactNode; + actions: ReactNode; + onOpen: () => void; +}; + +const CallListItem = ({ call, timeLabel, actions, onOpen }: CallListItemProps) => { + const { t } = useTranslation(); + + return ( + { + event.preventDefault(); + + if ((event.target as HTMLElement).closest('button')) { + return; + } + + onOpen(); + }} + icon={} + title={call.name} + time={call.createdAt} + timeLabel={timeLabel} + subtitle={ + + {t('__count__people_joined', { count: call.usersCount })} + + } + actions={actions} + /> + ); +}; + +export default CallListItem; diff --git a/apps/meteor/client/components/OngoingCalls/DeclinedCallsToggle.tsx b/apps/meteor/client/components/OngoingCalls/DeclinedCallsToggle.tsx new file mode 100644 index 0000000000000..74f28b69a516f --- /dev/null +++ b/apps/meteor/client/components/OngoingCalls/DeclinedCallsToggle.tsx @@ -0,0 +1,29 @@ +import { Box, Button } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +type DeclinedCallsToggleProps = { + count: number; + expanded: boolean; + onToggle: () => void; +}; + +/** + * The way back to a call that was turned down, at the foot of the Ongoing calls group. + * + * Declining quiets a call rather than losing it: the row drops out of the list and waits under this, so a call + * turned down by accident — or turned down and then wanted after all — is one click away rather than a trip to the + * call history. It only exists while there is something behind it. + */ +const DeclinedCallsToggle = ({ count, expanded, onToggle }: DeclinedCallsToggleProps) => { + const { t } = useTranslation(); + + return ( + + + + ); +}; + +export default DeclinedCallsToggle; diff --git a/apps/meteor/client/components/OngoingCalls/OngoingCallRow.spec.tsx b/apps/meteor/client/components/OngoingCalls/OngoingCallRow.spec.tsx new file mode 100644 index 0000000000000..d38156a45b14c --- /dev/null +++ b/apps/meteor/client/components/OngoingCalls/OngoingCallRow.spec.tsx @@ -0,0 +1,55 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import OngoingCallRow from './OngoingCallRow'; +import { buildJoinableCall } from '../../views/conference/testFixtures'; + +const onJoin = jest.fn(); +const onDecline = jest.fn(); + +const renderRow = (name = 'Standup') => + render(, { + wrapper: mockAppRoot().withJohnDoe().withUserPreference('displayAvatars', true).build(), + }); + +beforeEach(() => { + onJoin.mockClear(); + onDecline.mockClear(); +}); + +it('says what the call is and how many people are in it', () => { + renderRow('Sprint planning'); + + expect(screen.getByText('Sprint planning')).toBeInTheDocument(); + expect(screen.getByText('__count__people_joined')).toBeInTheDocument(); +}); + +it('opens the call when the row is clicked', async () => { + const { container } = renderRow(); + const row = container.querySelector('.rcx-sidebar-v2-item') as HTMLElement; + + const click = new MouseEvent('click', { bubbles: true, cancelable: true }); + row.dispatchEvent(click); + + expect(click.defaultPrevented).toBe(true); + expect(onJoin).toHaveBeenCalledWith('call-1'); + expect(onDecline).not.toHaveBeenCalled(); +}); + +it('turns the call down without opening it', async () => { + renderRow(); + + await userEvent.click(screen.getByRole('button', { name: 'Decline' })); + + expect(onDecline).toHaveBeenCalledWith('call-1'); + expect(onJoin).not.toHaveBeenCalled(); +}); + +it('is the room item with no avatar', () => { + const { container } = renderRow('Standup'); + + expect(container.querySelector('.rcx-sidebar-v2-item')).not.toBeNull(); + expect(container.querySelector('.rcx-sidebar-v2-item__timestamp')).not.toBeNull(); + expect(container.querySelector('.rcx-sidebar-v2-item__avatar')).toBeNull(); +}); diff --git a/apps/meteor/client/components/OngoingCalls/OngoingCallRow.tsx b/apps/meteor/client/components/OngoingCalls/OngoingCallRow.tsx new file mode 100644 index 0000000000000..17d5afaa15c8f --- /dev/null +++ b/apps/meteor/client/components/OngoingCalls/OngoingCallRow.tsx @@ -0,0 +1,35 @@ +import type { JoinableVideoConference } from '@rocket.chat/core-typings'; +import { Box, IconButton } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +import CallListItem from './CallListItem'; + +type OngoingCallRowProps = { + call: JoinableVideoConference; + onJoin: (callId: string) => void; + onDecline?: (callId: string) => void; +}; + +const callActions = (call: JoinableVideoConference, onDecline: ((callId: string) => void) | undefined, t: (key: string) => string) => { + if (onDecline) { + return onDecline(call.callId)} />; + } + + if (call.declined) { + return ( + + ({t('Declined_call')}) + + ); + } + + return undefined; +}; + +const OngoingCallRow = ({ call, onJoin, onDecline }: OngoingCallRowProps) => { + const { t } = useTranslation(); + + return onJoin(call.callId)} actions={callActions(call, onDecline, t)} />; +}; + +export default OngoingCallRow; diff --git a/apps/meteor/client/components/OngoingCalls/OngoingCallsList.tsx b/apps/meteor/client/components/OngoingCalls/OngoingCallsList.tsx new file mode 100644 index 0000000000000..1f27ff7be8556 --- /dev/null +++ b/apps/meteor/client/components/OngoingCalls/OngoingCallsList.tsx @@ -0,0 +1,64 @@ +import { isRingingVideoConferenceMember } from '@rocket.chat/core-typings'; +import { Box, Button, Divider } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +import OngoingCallRow from './OngoingCallRow'; +import RingingCallItem from './RingingCallItem'; +import { canDeclineCall, useOngoingCalls } from './useOngoingCalls'; + +const MAX_VISIBLE = 5; + +const OngoingCallsList = () => { + const { t } = useTranslation(); + const { ringing, ongoing, declined, joinCall, decline, silence, silencedCalls, showAll, toggleShowAll } = useOngoingCalls(); + + const active = [...ringing, ...ongoing]; + const total = active.length + declined.length; + + const visibleActive = showAll ? active : active.slice(0, MAX_VISIBLE); + const remainingSlots = Math.max(0, MAX_VISIBLE - visibleActive.length); + const visibleDeclined = showAll ? declined : declined.slice(0, remainingSlots); + + const hasMore = total > MAX_VISIBLE && !showAll; + const hiddenActive = active.length - visibleActive.length; + + const showAllLabel = hiddenActive > 0 ? t('Show_all_count_new', { count: hiddenActive }) : t('Show_all'); + + return ( + + {visibleActive.map((item) => + isRingingVideoConferenceMember({ ringingAt: item.ringingAt }) ? ( + + ) : ( + + ), + )} + + {visibleDeclined.length > 0 && ( + <> + {visibleActive.length > 0 && } + {visibleDeclined.map((item) => ( + + ))} + + )} + + {(hasMore || showAll) && ( + + + + )} + + ); +}; + +export default OngoingCallsList; diff --git a/apps/meteor/client/components/OngoingCalls/RingingCallItem.spec.tsx b/apps/meteor/client/components/OngoingCalls/RingingCallItem.spec.tsx new file mode 100644 index 0000000000000..3a748d73f6111 --- /dev/null +++ b/apps/meteor/client/components/OngoingCalls/RingingCallItem.spec.tsx @@ -0,0 +1,91 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import RingingCallItem from './RingingCallItem'; +import { buildJoinableCall } from '../../views/conference/testFixtures'; + +const onAccept = jest.fn(); +const onReject = jest.fn(); +const onSilence = jest.fn(); + +let incomingCalls: { callId: string; dismissed: boolean }[] = []; + +jest.mock('@rocket.chat/ui-video-conf', () => ({ + ...jest.requireActual('@rocket.chat/ui-video-conf'), + useVideoConfIncomingCalls: () => incomingCalls, +})); + +const renderItem = (silenced = false) => + render( + , + { wrapper: mockAppRoot().withJohnDoe().withUserPreference('displayAvatars', true).build() }, + ); + +beforeEach(() => { + onAccept.mockClear(); + onReject.mockClear(); + onSilence.mockClear(); + incomingCalls = [{ callId: 'ringing', dismissed: false }]; +}); + +it('accepts when the row is clicked', async () => { + renderItem(); + + await userEvent.click(screen.getByText('Alice')); + + expect(onAccept).toHaveBeenCalledWith('ringing'); +}); + +it('offers both silence and decline while it is still sounding', async () => { + renderItem(); + + expect(screen.getByRole('button', { name: 'Silence' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Decline' })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Silence' })); + + expect(onSilence).toHaveBeenCalledWith('ringing'); + expect(onAccept).not.toHaveBeenCalled(); + expect(onReject).not.toHaveBeenCalled(); +}); + +it('replaces silence with a silenced icon once muted, keeping decline', async () => { + renderItem(true); + + expect(screen.queryByRole('button', { name: 'Silence' })).not.toBeInTheDocument(); + expect(screen.getByTitle('Incoming_call_silenced')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Decline' })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Decline' })); + + expect(onReject).toHaveBeenCalledWith('ringing'); +}); + +it('keeps both buttons at the end of the row', () => { + const { container } = renderItem(); + const buttons = [...container.querySelectorAll('button')].map((button) => button.getAttribute('aria-label')); + + expect(buttons).toEqual(['Silence', 'Decline']); +}); + +it('says it is ringing where the time would be', () => { + renderItem(); + + expect(screen.getByText(/Ringing/)).toBeInTheDocument(); +}); + +it('offers the decline straight away for a ring it never heard', () => { + incomingCalls = []; + + renderItem(); + + expect(screen.queryByRole('button', { name: 'Silence' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Decline' })).toBeInTheDocument(); +}); diff --git a/apps/meteor/client/components/OngoingCalls/RingingCallItem.tsx b/apps/meteor/client/components/OngoingCalls/RingingCallItem.tsx new file mode 100644 index 0000000000000..84b5390ac587c --- /dev/null +++ b/apps/meteor/client/components/OngoingCalls/RingingCallItem.tsx @@ -0,0 +1,54 @@ +import type { JoinableVideoConference } from '@rocket.chat/core-typings'; +import { Box, Icon, IconButton } from '@rocket.chat/fuselage'; +import { useVideoConfIncomingCalls } from '@rocket.chat/ui-video-conf'; +import { useTranslation } from 'react-i18next'; + +import CallListItem from './CallListItem'; +import { fakeIncomingCalls, fakeOngoingCallsEnabled } from './fakeOngoingCalls'; + +type RingingCallItemProps = { + call: JoinableVideoConference; + silenced: boolean; + onAccept: (callId: string) => void; + onReject: (callId: string) => void; + onSilence: (callId: string) => void; +}; + +const RingingCallItem = ({ call, silenced, onAccept, onReject, onSilence }: RingingCallItemProps) => { + const { t } = useTranslation(); + + const incomingCalls = useVideoConfIncomingCalls(); + const heard = fakeOngoingCallsEnabled() ? [...incomingCalls, ...fakeIncomingCalls()] : incomingCalls; + const heardHere = heard.some(({ callId, dismissed }) => callId === call.callId && !dismissed); + const audible = heardHere && !silenced; + + return ( + onAccept(call.callId)} + timeLabel={ + + {t('Ringing')}… + + } + actions={ + <> + {silenced && } + {audible && ( + onSilence(call.callId)} + /> + )} + onReject(call.callId)} /> + + } + /> + ); +}; + +export default RingingCallItem; diff --git a/apps/meteor/client/components/OngoingCalls/fakeOngoingCalls.ts b/apps/meteor/client/components/OngoingCalls/fakeOngoingCalls.ts new file mode 100644 index 0000000000000..82115c9201180 --- /dev/null +++ b/apps/meteor/client/components/OngoingCalls/fakeOngoingCalls.ts @@ -0,0 +1,90 @@ +import type { JoinableVideoConference } from '@rocket.chat/core-typings'; + +/** + * Scaffolding for working on the ongoing-calls layout: a few calls that are always there, so the list can be + * looked at without arranging real ones between three browser windows. + * + * **Off unless asked for.** Set the flag below and reload: + * + * ```js + * localStorage.rcFakeOngoingCalls = '1'; + * ``` + * + * Delete this file and its three call sites — two in `useOngoingCalls`, one in `RingingCallItem` — once the layout + * is settled. It exists to be thrown away, which is why it is a separate file rather than a condition inside them. + */ +export const fakeOngoingCallsEnabled = () => { + try { + return localStorage.getItem('rcFakeOngoingCalls') === '1'; + } catch { + // Storage can be denied outright (private windows, embedded contexts). Scaffolding is not worth an error. + return false; + } +}; + +const person = (username: string, name: string) => ({ _id: username, username, name }); + +/** + * A long name, a short one, one that is ringing, and two that were turned down — the cases the row has to survive: + * truncation, the `joined` wording when every face is shown, the ringing treatment, and the toggle at the foot of + * the group that the declined ones wait behind. + * + * `ringingAt` is in the *future* on purpose. A ring is only live for `VIDEO_CONF_RINGING_WINDOW_MS` (15s), so a + * timestamp of "now" would settle into an ordinary row while you were still looking at it. + */ +export const fakeOngoingCalls = (): JoinableVideoConference[] => [ + { + callId: 'fake-long', + name: 'Meeting in "20 August planning session"', + createdAt: new Date(Date.now() - 12 * 60_000), + usersCount: 5, + participants: [person('alice', 'Alice'), person('cleiton', 'Cleiton'), person('bob', 'bob')], + joined: false, + declined: false, + }, + { + callId: 'fake-short', + name: 'Standup', + createdAt: new Date(Date.now() - 3 * 60_000), + usersCount: 2, + participants: [person('john', 'john'), person('don', 'don')], + joined: false, + declined: false, + }, + { + callId: 'fake-ringing', + name: 'Cleiton', + createdAt: new Date(), + usersCount: 1, + participants: [person('cleiton', 'Cleiton')], + joined: false, + declined: false, + ringingAt: new Date(Date.now() + 60 * 60_000), + }, + { + callId: 'fake-declined-one', + name: 'Design review', + createdAt: new Date(Date.now() - 25 * 60_000), + usersCount: 4, + participants: [person('alice', 'Alice'), person('john', 'john')], + joined: false, + declined: true, + }, + { + callId: 'fake-declined-two', + name: 'bob', + createdAt: new Date(Date.now() - 40 * 60_000), + usersCount: 1, + participants: [person('bob', 'bob')], + joined: false, + declined: true, + }, +]; + +/** + * The ring the manager would remember if this call were real. + * + * `RingingCallItem` only offers to silence a call whose ring *this client actually heard*, which a fake call never + * did — so without this the mute button is the one part of that row you cannot look at. + */ +export const fakeIncomingCalls = () => [{ callId: 'fake-ringing', dismissed: false }]; diff --git a/apps/meteor/client/components/OngoingCalls/useOngoingCalls.spec.ts b/apps/meteor/client/components/OngoingCalls/useOngoingCalls.spec.ts new file mode 100644 index 0000000000000..47854b99cf86d --- /dev/null +++ b/apps/meteor/client/components/OngoingCalls/useOngoingCalls.spec.ts @@ -0,0 +1,78 @@ +import type { JoinableVideoConference } from '@rocket.chat/core-typings'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { canDeclineCall, useOngoingCallsList } from './useOngoingCalls'; +import { buildJoinableCall } from '../../views/conference/testFixtures'; + +const renderList = (calls: JoinableVideoConference[]) => + renderHook(() => useOngoingCallsList(), { + wrapper: mockAppRoot() + .withJohnDoe() + .withEndpoint('GET', '/v1/video-conference.joinable', () => ({ calls, success: true }) as any) + .build(), + }); + +// The three states a call can be in for this list, and each belongs somewhere different: one is asking, one is +// simply there, and one was turned down and waits under the rest. +it('splits the calls into ringing, running and declined', async () => { + const { result } = renderList([ + buildJoinableCall({ callId: 'ringing', ringingAt: new Date() }), + buildJoinableCall({ callId: 'running' }), + buildJoinableCall({ callId: 'refused', declined: true }), + ]); + + await waitFor(() => expect(result.current.ongoing).toHaveLength(1)); + + expect(result.current.ringing.map(({ callId }) => callId)).toEqual(['ringing']); + expect(result.current.ongoing.map(({ callId }) => callId)).toEqual(['running']); + expect(result.current.declined.map(({ callId }) => callId)).toEqual(['refused']); +}); + +// Declining quiets a call rather than losing it: it leaves the list proper, and the way back to it is the toggle +// under the group rather than a trip to the call history. +it('keeps a declined call out of the list proper', async () => { + const { result } = renderList([buildJoinableCall({ callId: 'refused', declined: true })]); + + await waitFor(() => expect(result.current.declined).toHaveLength(1)); + + expect(result.current.ringing).toHaveLength(0); + expect(result.current.ongoing).toHaveLength(0); +}); + +// The call the reader is in stays listed, because leaving one is easy to do by accident and a call that vanished on +// being joined left no way back into it. +it('keeps the call the reader is already in, as one simply running', async () => { + const { result } = renderList([buildJoinableCall({ callId: 'here', joined: true }), buildJoinableCall({ callId: 'elsewhere' })]); + + await waitFor(() => expect(result.current.ongoing).toHaveLength(2)); + + expect(result.current.ongoing.map(({ callId }) => callId)).toEqual(['here', 'elsewhere']); + expect(result.current.declined).toHaveLength(0); +}); + +// Joining answers the ring, so the row stops asking — it is listed as running even while the record of the ring is +// still on the call. +it('does not treat a call it has joined as ringing', async () => { + const { result } = renderList([buildJoinableCall({ callId: 'answered', joined: true, ringingAt: new Date() })]); + + await waitFor(() => expect(result.current.ongoing).toHaveLength(1)); + + expect(result.current.ringing).toHaveLength(0); +}); + +// A call turned down and then joined anyway is a call the reader is in, not one waiting under the list. +it('lists a call it has joined even if it was declined first', async () => { + const { result } = renderList([buildJoinableCall({ callId: 'rejoined', joined: true, declined: true })]); + + await waitFor(() => expect(result.current.ongoing).toHaveLength(1)); + + expect(result.current.declined).toHaveLength(0); +}); + +// Nothing to turn down for a call the reader is in: the way out is to leave it. +it('offers no decline for a call the reader is in', () => { + expect(canDeclineCall(buildJoinableCall({ callId: 'here', joined: true }))).toBe(false); + expect(canDeclineCall(buildJoinableCall({ callId: 'gone', declined: true }))).toBe(false); + expect(canDeclineCall(buildJoinableCall({ callId: 'fresh' }))).toBe(true); +}); diff --git a/apps/meteor/client/components/OngoingCalls/useOngoingCalls.ts b/apps/meteor/client/components/OngoingCalls/useOngoingCalls.ts new file mode 100644 index 0000000000000..bde3ed6453f97 --- /dev/null +++ b/apps/meteor/client/components/OngoingCalls/useOngoingCalls.ts @@ -0,0 +1,63 @@ +import type { JoinableVideoConference } from '@rocket.chat/core-typings'; +import { isRingingVideoConferenceMember } from '@rocket.chat/core-typings'; +import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useVideoConfDismissCall } from '@rocket.chat/ui-video-conf'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useMemo, useState } from 'react'; + +import { fakeOngoingCalls, fakeOngoingCallsEnabled } from './fakeOngoingCalls'; +import { useRingingExpiry } from '../../hooks/useRingingExpiry'; +import { videoConferenceQueryKeys } from '../../lib/queryKeys'; +import { useJoinCall } from '../../views/conference/hooks/useJoinCall'; +import { useJoinableCalls } from '../../views/conference/hooks/useJoinableCalls'; + +export const useOngoingCallsList = () => { + const { calls: realCalls } = useJoinableCalls(); + + const calls = useMemo(() => (fakeOngoingCallsEnabled() ? [...fakeOngoingCalls(), ...realCalls] : realCalls), [realCalls]); + + // Not memoized: `isRingingVideoConferenceMember` is time-dependent (uses Date.now()), and the re-render + // triggered by `useRingingExpiry` must see a fresh evaluation to move a call from ringing to ongoing. + const isRinging = (call: JoinableVideoConference) => isRingingVideoConferenceMember({ ringingAt: call.ringingAt }); + const asked = calls.filter((call) => call.joined || !call.declined); + + const ringing = asked.filter((call) => !call.joined && isRinging(call)); + const ongoing = asked.filter((call) => call.joined || !isRinging(call)); + const declined = calls.filter((call) => !call.joined && call.declined); + + useRingingExpiry(ringing.map(({ ringingAt }) => ringingAt)); + + return { ringing, ongoing, declined }; +}; + +export const useOngoingCalls = () => { + const { ringing, ongoing, declined } = useOngoingCallsList(); + const joinCall = useJoinCall(); + const declineCall = useEndpoint('POST', '/v1/video-conference.decline'); + const dispatchToastMessage = useToastMessageDispatch(); + const queryClient = useQueryClient(); + + const { mutate: decline } = useMutation({ + mutationFn: (callId: string) => declineCall({ callId }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: videoConferenceQueryKeys.joinable() }), + onError: (error) => dispatchToastMessage({ type: 'error', message: error }), + }); + + const dismissCall = useVideoConfDismissCall(); + const [silencedCalls, setSilencedCalls] = useState([]); + + const silence = useCallback( + (callId: string) => { + dismissCall(callId); + setSilencedCalls((silenced) => (silenced.includes(callId) ? silenced : [...silenced, callId])); + }, + [dismissCall], + ); + + const [showAll, setShowAll] = useState(false); + const toggleShowAll = useCallback(() => setShowAll((v) => !v), []); + + return { ringing, ongoing, declined, joinCall, decline, silence, silencedCalls, showAll, toggleShowAll }; +}; + +export const canDeclineCall = (call: JoinableVideoConference): boolean => !call.declined && !call.joined; 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 1f1072ec6313b..dce8aead14a8e 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; /** @deprecated use `window.RTCPeerConnection` */ mozRTCPeerConnection?: RTCPeerConnection; diff --git a/apps/meteor/client/hooks/notification/useNotification.ts b/apps/meteor/client/hooks/notification/useNotification.ts index 88296e3334323..4ff42eba25925 100644 --- a/apps/meteor/client/hooks/notification/useNotification.ts +++ b/apps/meteor/client/hooks/notification/useNotification.ts @@ -2,6 +2,7 @@ import type { INotificationDesktop } from '@rocket.chat/core-typings'; import { useStableCallback } from '@rocket.chat/fuselage-hooks'; import { Random } from '@rocket.chat/random'; import { useRouter, useUserPreference } from '@rocket.chat/ui-contexts'; +import { useVideoConfJoinCall } from '@rocket.chat/ui-video-conf'; import { useNotificationAllowed } from './useNotificationAllowed'; import { getUserAvatarURL } from '../../../app/utils/client'; @@ -10,8 +11,9 @@ import { stripTags } from '../../../lib/utils/stringUtils'; import { onClientMessageReceived } from '../../lib/onClientMessageReceived'; export const useNotification = () => { - const requireInteraction = useUserPreference('desktopNotificationRequireInteraction'); + const requireInteractionPreference = useUserPreference('desktopNotificationRequireInteraction'); const router = useRouter(); + const joinCall = useVideoConfJoinCall(); const notificationAllowed = useNotificationAllowed(); const notify = useStableCallback(async (notification: INotificationDesktop) => { @@ -22,6 +24,9 @@ export const useNotification = () => { return; } + // A notification can opt into staying until interacted with, on top of the user preference. + const requireInteraction = Boolean(notification.requireInteraction || requireInteractionPreference); + const { rid, name: roomName, _id: msgId } = notification.payload; if (!rid) { return; @@ -39,6 +44,7 @@ export const useNotification = () => { canReply: true, silent: true, requireInteraction, + ...(window.RocketChatDesktop && notification.actions?.length ? { actions: notification.actions } : {}), } as NotificationOptions & { canReply?: boolean; }); @@ -59,6 +65,16 @@ export const useNotification = () => { }, }), ); + + // "Join" action (desktop app): join the call the same way the ongoing-call banner does. + const { conferenceId } = notification.payload; + if (conferenceId) { + n.addEventListener('action', () => { + n.close(); + window.focus(); + joinCall(conferenceId); + }); + } } n.onclick = () => { diff --git a/apps/meteor/client/hooks/roomActions/useVideoCallRoomAction.spec.tsx b/apps/meteor/client/hooks/roomActions/useVideoCallRoomAction.spec.tsx new file mode 100644 index 0000000000000..ebcc58c9acdbc --- /dev/null +++ b/apps/meteor/client/hooks/roomActions/useVideoCallRoomAction.spec.tsx @@ -0,0 +1,69 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { act, renderHook } from '@testing-library/react'; + +import { useVideoCallRoomAction } from './useVideoCallRoomAction'; +import { createFakeRoom } from '../../../tests/mocks/data'; + +const startCall = jest.fn(); +const dispatchOutgoing = jest.fn(); +const loadCapabilities = jest.fn(() => Promise.resolve()); + +jest.mock('@rocket.chat/ui-video-conf', () => ({ + ...jest.requireActual('@rocket.chat/ui-video-conf'), + useVideoConfStartCall: () => startCall, + useVideoConfDispatchOutgoing: () => dispatchOutgoing, + useVideoConfLoadCapabilities: () => loadCapabilities, + useVideoConfIsCalling: () => false, + useVideoConfIsRinging: () => false, +})); + +const fakeRoom = createFakeRoom({ t: 'c' }); + +jest.mock('../../views/room/contexts/RoomContext', () => ({ + useRoom: () => fakeRoom, +})); + +const renderAction = (preflight: boolean) => + renderHook(() => useVideoCallRoomAction(), { + wrapper: mockAppRoot() + .withJohnDoe() + .withPermission('call-management') + .withSetting('VideoConf_Enable_Persistent_Chat', preflight) + .build(), + }); + +beforeEach(() => { + startCall.mockClear(); + dispatchOutgoing.mockClear(); + loadCapabilities.mockClear(); +}); + +// The call window asks how to arrive before it starts anything, so a popup asking the same thing first is one +// confirmation too many. +it('opens the call window straight away when it will ask for itself', async () => { + const { result } = renderAction(true); + + await act(() => result.current?.action?.()); + + expect(startCall).toHaveBeenCalledWith(fakeRoom._id); + expect(dispatchOutgoing).not.toHaveBeenCalled(); +}); + +// Without a preflight, the popup is still where mic and camera are chosen. +it('asks in the room when there is no call window to ask', async () => { + const { result } = renderAction(false); + + await act(() => result.current?.action?.()); + + expect(dispatchOutgoing).toHaveBeenCalledWith({ rid: fakeRoom._id }); + expect(startCall).not.toHaveBeenCalled(); +}); + +// Whatever opens, the provider being unavailable has to surface before a window does. +it('checks the provider first either way', async () => { + const { result } = renderAction(true); + + await act(() => result.current?.action?.()); + + expect(loadCapabilities).toHaveBeenCalled(); +}); diff --git a/apps/meteor/client/hooks/roomActions/useVideoCallRoomAction.ts b/apps/meteor/client/hooks/roomActions/useVideoCallRoomAction.ts index c4e4fdb0d3589..03154b4e0f11b 100644 --- a/apps/meteor/client/hooks/roomActions/useVideoCallRoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useVideoCallRoomAction.ts @@ -7,6 +7,7 @@ import { useVideoConfIsCalling, useVideoConfIsRinging, useVideoConfLoadCapabilities, + useVideoConfStartCall, } from '@rocket.chat/ui-video-conf'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -27,6 +28,8 @@ export const useVideoCallRoomAction = () => { const dispatchWarning = useVideoConfWarning(); const dispatchPopup = useVideoConfDispatchOutgoing(); + const startCall = useVideoConfStartCall(); + const preflight = useSetting('VideoConf_Enable_Persistent_Chat', false); const loadCapabilities = useVideoConfLoadCapabilities(); const isCalling = useVideoConfIsCalling(); const isRinging = useVideoConfIsRinging(); @@ -59,7 +62,17 @@ export const useVideoCallRoomAction = () => { } try { + // Still asked for, because it is what fails when no provider is available — that error belongs here, + // before a window opens, not inside one. await loadCapabilities(); + + // The call window asks before it starts anything, so a popup asking the same thing first is one + // confirmation too many. Without a preflight to ask, the popup is still where mic and camera are set. + if (preflight) { + startCall(room._id); + return; + } + dispatchPopup({ rid: room._id }); } catch (error: any) { dispatchWarning(error.error); 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/VideoConfManager.spec.ts b/apps/meteor/client/lib/VideoConfManager.spec.ts new file mode 100644 index 0000000000000..54e4e32785adf --- /dev/null +++ b/apps/meteor/client/lib/VideoConfManager.spec.ts @@ -0,0 +1,281 @@ +import { VideoConfManager } from './VideoConfManager'; +import { sdk } from '../../app/utils/client/lib/SDKClient'; + +jest.mock('../../app/utils/client/lib/SDKClient', () => ({ + sdk: { + rest: { post: jest.fn(() => Promise.resolve({})), get: jest.fn(() => Promise.resolve({})) }, + publish: jest.fn(), + stream: jest.fn(() => ({ stop: jest.fn(), ready: () => Promise.resolve() })), + }, +})); + +const manager = VideoConfManager as unknown as { + onVideoConfNotification(data: { action: string; params: { callId: string; uid: string; rid: string } }): Promise; + currentCallData: { callId: string; uid?: string; rid?: string; joined?: boolean } | undefined; + currentCallHandler: ReturnType | undefined; + userId: string | undefined; +}; + +const notify = (action: string, callId = 'call-1', uid = 'caller-1') => + manager.onVideoConfNotification({ action, params: { callId, uid, rid: 'room-1' } }); + +// acceptIncomingCall/rejectIncomingCall kick off fire-and-forget promises (joinCall, the decline POST); give +// them a turn of the microtask queue to settle before asserting on the mocks they touch. +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + +const publishedActions = () => (sdk.publish as jest.Mock).mock.calls.map((call) => (call[1] as [string, { action: string }])[1].action); + +describe('VideoConfManager', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Not calling anyone: the state a user is in when they are simply a member of an ongoing conference. + manager.currentCallData = undefined; + if (manager.currentCallHandler) { + clearInterval(manager.currentCallHandler); + manager.currentCallHandler = undefined; + } + manager.userId = undefined; + }); + + describe('declining a conference', () => { + // The whole point of a conference decline: it must not tear the call down for everyone else. The + // teardown lives behind `currentCallData`, which is only set while *we* are placing a 1:1 call — so a + // `rejected` for a conference must find nothing to cancel. Locked in here because widening that guard + // would silently let one person's decline end everybody's call. + it('should not cancel the call when a rejection arrives for a call we are not placing', async () => { + await notify('rejected'); + + expect(sdk.rest.post).not.toHaveBeenCalledWith('/v1/video-conference.cancel', expect.anything()); + }); + + it('should not cancel the call when the rejection is for a different call than the one we are placing', async () => { + manager.currentCallData = { callId: 'call-other', uid: 'callee-1', rid: 'room-1' }; + + await notify('rejected', 'call-1'); + + expect(sdk.rest.post).not.toHaveBeenCalledWith('/v1/video-conference.cancel', expect.anything()); + }); + }); + + describe('ringing', () => { + // The server broadcasts `ring` for conferences; before it was handled, the action fell through the + // switch and an added user was never rung. + it('should register an incoming call from a server-originated ring', async () => { + await notify('ring'); + + expect(VideoConfManager.isRinging()).toBe(true); + }); + + it('should register an incoming call from a caller-published call', async () => { + await notify('call'); + + expect(VideoConfManager.isRinging()).toBe(true); + }); + }); + + describe('accepting an incoming call', () => { + beforeEach(() => { + // notifyUser is a no-op unless we're "logged in" as someone, and accepting is what we're pinning down here. + manager.userId = 'my-user'; + (sdk.rest.post as jest.Mock).mockImplementation((endpoint: string) => + endpoint === '/v1/video-conference.join' + ? Promise.resolve({ url: 'https://call.example', providerName: 'test' }) + : Promise.resolve({}), + ); + }); + + // A ring means the conference already exists and membership is what authorizes joining it — there's no + // caller waiting on a handshake, so accepting must join straight away. + it('joins outright for a ring, without publishing an accepted notification', async () => { + await notify('ring', 'call-ring-accept', 'ring-accept-caller'); + VideoConfManager.acceptIncomingCall('call-ring-accept'); + await flushPromises(); + + expect(sdk.rest.post).toHaveBeenCalledWith('/v1/video-conference.join', expect.objectContaining({ callId: 'call-ring-accept' })); + expect(publishedActions()).not.toContain('accepted'); + }); + + // A call means the caller's client is out there repeating `call` and waiting for our answer. Accepting must + // negotiate with it (publish `accepted`) rather than join immediately, and only join once they confirm. + it('publishes accepted for a call and joins only once confirmed arrives', async () => { + await notify('call', 'call-handshake-accept', 'handshake-accept-caller'); + VideoConfManager.acceptIncomingCall('call-handshake-accept'); + await flushPromises(); + + expect(publishedActions()).toContain('accepted'); + expect(sdk.rest.post).not.toHaveBeenCalledWith('/v1/video-conference.join', expect.anything()); + + await notify('confirmed', 'call-handshake-accept', 'handshake-accept-caller'); + await flushPromises(); + + expect(sdk.rest.post).toHaveBeenCalledWith('/v1/video-conference.join', expect.objectContaining({ callId: 'call-handshake-accept' })); + }); + }); + + describe('declining an incoming call', () => { + beforeEach(() => { + manager.userId = 'my-user'; + }); + + // A ring has no caller waiting on the other end; publishing `rejected` would read as us ending the call for + // whoever added us to it, so only the server-side decline record should happen. + it('records the decline for a ring without publishing a rejected notification', async () => { + await notify('ring', 'call-ring-decline', 'ring-decline-caller'); + VideoConfManager.rejectIncomingCall('call-ring-decline'); + await flushPromises(); + + expect(sdk.rest.post).toHaveBeenCalledWith('/v1/video-conference.decline', { callId: 'call-ring-decline' }); + expect(publishedActions()).not.toContain('rejected'); + }); + + // A call's caller is waiting on `rejected` to know we turned them down, so it must be published in addition + // to recording the decline server-side. + it('publishes rejected for a call, in addition to recording the decline', async () => { + await notify('call', 'call-handshake-decline', 'handshake-decline-caller'); + VideoConfManager.rejectIncomingCall('call-handshake-decline'); + await flushPromises(); + + expect(sdk.rest.post).toHaveBeenCalledWith('/v1/video-conference.decline', { callId: 'call-handshake-decline' }); + expect(publishedActions()).toContain('rejected'); + }); + }); +}); + +describe('starting a call', () => { + beforeEach(() => { + manager.userId = 'my-user'; + (sdk.rest.post as jest.Mock).mockImplementation((endpoint: string) => { + if (endpoint === '/v1/video-conference.start') { + return Promise.resolve({ data: { type: 'direct', callId: 'new-call', calleeId: 'callee-1' } }); + } + if (endpoint === '/v1/video-conference.join') { + return Promise.resolve({ url: 'https://call.example', providerName: 'test' }); + } + return Promise.resolve({}); + }); + }); + + afterEach(() => { + if (manager.currentCallHandler) { + clearInterval(manager.currentCallHandler); + manager.currentCallHandler = undefined; + } + manager.currentCallData = undefined; + }); + + // A direct call used to open its window only once the callee answered, from a stream event — too far from + // the click to count as user activation, which is what browsers refuse. Every other call type opens on the + // click, and now this one does too. + it('opens the call window for a direct call without waiting for the answer', async () => { + const joined = jest.fn(); + VideoConfManager.on('call/join', joined); + + await VideoConfManager.startCall('room-1'); + + expect(sdk.rest.post).toHaveBeenCalledWith('/v1/video-conference.join', expect.objectContaining({ callId: 'new-call' })); + expect(joined).toHaveBeenCalledWith(expect.objectContaining({ callId: 'new-call' })); + + VideoConfManager.off('call/join', joined); + }); + + // Opening the window early must not stop the callee's phone ringing. + it('still rings the callee', async () => { + await VideoConfManager.startCall('room-1'); + + expect(publishedActions()).toContain('call'); + }); + + // The wait now happens in the call window, so the room must stop showing an outgoing popup for a call the + // user is already sitting in — even though the ringing interval is still running on the callee's behalf. + it('stops reporting the room as calling once the caller is in the call', async () => { + await VideoConfManager.startCall('room-1'); + + expect(VideoConfManager.isCalling()).toBe(false); + }); +}); + +describe('ringing again after a dismissal', () => { + // The manager is a singleton and earlier tests leave their own calls in its list, so ask about this call + // rather than about whether anything at all is ringing. + const incoming = (callId: string) => VideoConfManager.getIncomingCalls().find((call) => call.callId === callId); + + beforeEach(() => { + manager.userId = 'my-user'; + }); + + // Dismissal exists to stop the caller's client re-ringing us with the `call` it publishes on a loop, and it + // outlives the call. A deliberate second ring from the server must not be swallowed by it — that is what made + // "Ring again" arrive at the callee silently once they had declined. + it('rings again for a server ring after the call was declined', async () => { + await notify('ring', 'call-rering', 'rering-caller'); + VideoConfManager.rejectIncomingCall('call-rering'); + expect(incoming('call-rering')).toBeUndefined(); + + await notify('ring', 'call-rering', 'rering-caller'); + + expect(incoming('call-rering')?.dismissed).toBe(false); + }); + + // The caller's own repeats are exactly what dismissal is for, so those must still stay silent. + it('stays silent for a caller repeating `call` after we dismissed it', async () => { + await notify('call', 'call-repeat', 'repeat-caller'); + VideoConfManager.dismissIncomingCall('call-repeat'); + + await notify('call', 'call-repeat', 'repeat-caller'); + + expect(incoming('call-repeat')?.dismissed).toBe(true); + }); +}); + +// The conference page runs a preflight — mic, camera, and for a group call its name — and joins from there once +// the user says how they want to arrive. Joining on the way to that screen would both throw away the URL it +// returns and mark the user as present in a call they have not chosen to enter yet. +describe('when the call window joins for itself', () => { + beforeEach(() => { + // The manager is a singleton and these mocks carry every earlier test's calls, which is what an assertion + // about something *not* being posted would otherwise read. + jest.clearAllMocks(); + manager.userId = 'my-user'; + (sdk.rest.post as jest.Mock).mockImplementation((endpoint: string) => { + if (endpoint === '/v1/video-conference.start') { + return Promise.resolve({ data: { type: 'direct', callId: 'new-call', calleeId: 'callee-1' } }); + } + return Promise.resolve({ url: 'https://call.example', providerName: 'test' }); + }); + VideoConfManager.setPersistentChat(true); + }); + + afterEach(() => { + VideoConfManager.setPersistentChat(false); + if (manager.currentCallHandler) { + clearInterval(manager.currentCallHandler); + manager.currentCallHandler = undefined; + } + manager.currentCallData = undefined; + }); + + it('does not join on its behalf', async () => { + await VideoConfManager.joinCall('some-call'); + + expect(sdk.rest.post).not.toHaveBeenCalledWith('/v1/video-conference.join', expect.anything()); + }); + + it('still opens the window for the call', async () => { + const joined = jest.fn(); + VideoConfManager.on('call/join', joined); + + await VideoConfManager.joinCall('some-call'); + + expect(joined).toHaveBeenCalledWith(expect.objectContaining({ callId: 'some-call' })); + + VideoConfManager.off('call/join', joined); + }); + + // The callee is rung by the server once the caller has actually entered the call. Ringing from here would ring + // them while the caller is still choosing a camera, which means answering into an empty room. + it('leaves the ringing until the caller has arrived in the call', async () => { + await VideoConfManager.startCall('room-1'); + + expect(publishedActions()).not.toContain('call'); + }); +}); diff --git a/apps/meteor/client/lib/VideoConfManager.ts b/apps/meteor/client/lib/VideoConfManager.ts index 13d9f5d6b1ca3..93b5cb4e8b54c 100644 --- a/apps/meteor/client/lib/VideoConfManager.ts +++ b/apps/meteor/client/lib/VideoConfManager.ts @@ -18,14 +18,33 @@ const ACCEPT_TIMEOUT = 5000; type IncomingDirectCall = DirectCallParams & { timeout: ReturnType | undefined; acceptTimeout?: ReturnType | undefined; + /** + * Whether accepting has to be negotiated with the caller's client, which is repeating the call and waiting + * to confirm we may join. A server-originated ring has nobody waiting, so it is joined outright. + */ + handshake: boolean; }; type CurrentCallParams = { callId: string; - url: string; + /** Absent when the call window joins for itself — see `joinCall`. */ + url?: string; providerName?: string; }; +// Emitted for embedded-SFU providers (e.g. LiveKit) — there's no URL to open, +// the call is rendered inline by the embedded provider's React tree. Consumers +// of this event dispatch the join into the corresponding provider context. +type CurrentEmbeddedCallParams = { + callId: string; + rid: string; + providerName: string; + // Preflight mic/cam preferences from the start-call popup so the + // embedded provider can connect with the right initial track state + // instead of always defaulting to "mic on, cam off". + preferences?: { mic?: boolean; cam?: boolean }; +}; + type VideoConfEvents = { // We gave up on calling a remote user or they rejected our call 'direct/cancel': DirectCallParams; @@ -61,6 +80,10 @@ type VideoConfEvents = { // When join call 'call/join': CurrentCallParams; + // When join call for an embedded (no-URL) provider like LiveKit. Consumers + // route this to the provider's React context (e.g. LiveKitVideoConf). + 'call/joinEmbedded': CurrentEmbeddedCallParams; + 'error': { error: string }; 'capabilities/changed': void; @@ -77,7 +100,7 @@ export const VideoConfManager = new (class VideoConfManager extends Emitter void)[] = []; - private incomingDirectCalls: Map; + private incomingCalls: Map; private directCalls: DirectCallData[] = []; @@ -85,6 +108,8 @@ export const VideoConfManager = new (class VideoConfManager extends Emitter(); + this.incomingCalls = new Map(); this.dismissedCalls = new Set(); this._preferences = { mic: true, cam: false }; this._capabilities = {}; this.on('incoming/changed', () => { - this.directCalls = [...this.incomingDirectCalls.values()] + this.directCalls = [...this.incomingCalls.values()] // Filter out any calls that we're in the process of accepting, so they're already hidden from the UI .filter((call) => !call.acceptTimeout) .map(({ timeout: _, acceptTimeout: _t, ...call }) => ({ ...call, dismissed: this.isCallDismissed(call.callId) })); @@ -122,18 +147,20 @@ export const VideoConfManager = new (class VideoConfManager extends Emitter !this.isCallDismissed(callId)); + return [...this.incomingCalls.values()].some(({ callId }) => !this.isCallDismissed(callId)); } public isCalling(): boolean { - if (this.currentCallHandler || (this.currentCallData && !this.currentCallData.joined)) { - return true; + // Once joined, the wait belongs to the call window — the room is not "calling" any more, even though the + // ringing interval is still running there on the callee's behalf. + if (this.currentCallData?.joined) { + return false; } - return false; + return Boolean(this.currentCallHandler || this.currentCallData); } - public getIncomingDirectCalls(): DirectCallData[] { + public getIncomingCalls(): DirectCallData[] { return this.directCalls; } @@ -165,7 +192,18 @@ export const VideoConfManager = new (class VideoConfManager extends Emitter { - const updatedCallData = this.incomingDirectCalls.get(callId); + const updatedCallData = this.incomingCalls.get(callId); if (!updatedCallData?.acceptTimeout) { return; } @@ -219,18 +264,28 @@ export const VideoConfManager = new (class VideoConfManager extends Emitter this.dismissedIncomingCallHelper(callId))) { + if ([...this.incomingCalls.keys()].some((callId) => this.dismissedIncomingCallHelper(callId))) { this.emit('ringing/changed'); this.emit('incoming/changed'); } @@ -253,7 +308,7 @@ export const VideoConfManager = new (class VideoConfManager extends Emitter this.dismissedCalls.delete(callId), CALL_TIMEOUT * 20); // Only change the state if this call is actually in our list - return this.incomingDirectCalls.has(callId); + return this.incomingCalls.has(callId); } public dismissIncomingCall(callId: string): boolean { @@ -300,6 +355,24 @@ export const VideoConfManager = new (class VideoConfManager extends Emitter { this.debugLog(`[VideoConf] Joining call ${callId}.`); - if (this.incomingDirectCalls.has(callId)) { - const data = this.incomingDirectCalls.get(callId); + if (this.incomingCalls.has(callId)) { + const data = this.incomingCalls.get(callId); if (data?.acceptTimeout) { this.debugLog('[VideoConf] Clearing acceptance timeout'); clearTimeout(data.acceptTimeout); @@ -351,6 +432,14 @@ export const VideoConfManager = new (class VideoConfManager extends Emitter { + const { url, providerName, rid } = await sdk.rest.post('/v1/video-conference.join', params).catch((e) => { console.error(`[VideoConf] Failed to join call ${callId}`, e); this.emitError(e?.xhr?.responseJSON?.error || 'error-videoconf-join-failed'); return Promise.reject(e); }); + // Embedded providers (e.g. LiveKit) intentionally return an empty + // url + a rid — the call is mounted inline by their React provider + // instead of opened in a popup. Dispatch to that provider via a + // distinct event so the URL-handling code path doesn't run. + if (!url && providerName && rid) { + this.debugLog(`[VideoConf] Joining embedded ${providerName} call ${callId} in room ${rid}.`); + this.emit('call/joinEmbedded', { + callId, + rid, + providerName, + // Forward the same prefs the server received so the + // embedded provider can publish/skip mic + camera tracks + // according to the preflight popup's toggle state. + preferences: { ...this._preferences }, + }); + return; + } + if (!url) { this.emitError('error-videoconf-missing-url'); throw new Error('Failed to get video conference URL.'); } + this.markCurrentCallJoined(callId); + this.debugLog(`[VideoConf] Opening ${url}.`); this.emit('call/join', { url, callId, providerName }); } + /** + * A caller who joins while still ringing is in the call, not waiting for it. Recording that is what stops the + * room from showing an outgoing popup for a call the user is already sitting in. + */ + private markCurrentCallJoined(callId: string): void { + if (this.currentCallData?.callId !== callId) { + return; + } + + this.currentCallData.joined = true; + this.emit('calling/changed'); + } + public abortCall(): void { if (!this.currentCallData) { return; @@ -396,7 +518,7 @@ export const VideoConfManager = new (class VideoConfManager extends Emitter { + this.incomingCalls.forEach((call) => { if (call.timeout) { clearTimeout(call.timeout); } @@ -483,7 +605,7 @@ export const VideoConfManager = new (class VideoConfManager extends Emitter this.abortIncomingCall(callId), CALL_TIMEOUT); } - private startNewIncomingCall({ callId, uid, rid }: DirectCallParams): void { + private startNewIncomingCall({ callId, uid, rid }: DirectCallParams, handshake: boolean): void { if (this.isCallDismissed(callId)) { this.debugLog(`[VideoConf] Ignoring dismissed call.`); return; @@ -593,11 +721,12 @@ export const VideoConfManager = new (class VideoConfManager extends Emitter { 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/navbar/NavBarControls/NavBarControlsSection.tsx b/apps/meteor/client/navbar/NavBarControls/NavBarControlsSection.tsx index 5b37dc7c1e9e7..80ab8fab07c70 100644 --- a/apps/meteor/client/navbar/NavBarControls/NavBarControlsSection.tsx +++ b/apps/meteor/client/navbar/NavBarControls/NavBarControlsSection.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import NavBarControlsWithData from './NavBarControlsWithData'; import { useOmnichannelEnabled } from '../../views/omnichannel/hooks/useOmnichannelEnabled'; +import NavBarItemOngoingCalls from '../NavBarItemOngoingCalls'; import NavBarOmnichannelGroup from '../NavBarOmnichannelGroup'; import { NavBarItemLoginPage, NavBarItemAdministrationMenu, UserMenu } from '../NavBarSettingsToolbar'; import NavBarVoipGroup from '../NavBarVoipGroup'; @@ -20,6 +21,7 @@ const NavBarControlsSection = () => { if (isMobile) { return ( + {(showOmnichannel || callAction) && } @@ -31,6 +33,7 @@ const NavBarControlsSection = () => { return ( + {callAction && } {showOmnichannel && } diff --git a/apps/meteor/client/navbar/NavBarItemOngoingCalls.spec.tsx b/apps/meteor/client/navbar/NavBarItemOngoingCalls.spec.tsx new file mode 100644 index 0000000000000..564679adae12b --- /dev/null +++ b/apps/meteor/client/navbar/NavBarItemOngoingCalls.spec.tsx @@ -0,0 +1,83 @@ +import type { JoinableVideoConference } from '@rocket.chat/core-typings'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import NavBarItemOngoingCalls from './NavBarItemOngoingCalls'; +import { buildJoinableCall as call } from '../views/conference/testFixtures'; + +const joinCall = jest.fn(); + +jest.mock('@rocket.chat/ui-video-conf', () => ({ + ...jest.requireActual('@rocket.chat/ui-video-conf'), + useVideoConfJoinCall: () => joinCall, +})); + +jest.mock('@rocket.chat/ui-contexts', () => ({ + ...jest.requireActual('@rocket.chat/ui-contexts'), +})); + +const renderButton = (calls: JoinableVideoConference[]) => + render(, { + wrapper: mockAppRoot() + .withJohnDoe() + .withUserPreference('displayAvatars', true) + .withEndpoint('GET', '/v1/video-conference.joinable', () => ({ calls, success: true }) as any) + .withEndpoint('POST', '/v1/video-conference.decline', () => ({ success: true }) as any) + .build(), + }); + +beforeEach(() => { + joinCall.mockClear(); +}); + +it('shows the button when there are calls', async () => { + renderButton([call({ callId: 'one' })]); + + expect(await screen.findByRole('button', { name: /Ongoing_calls/ })).toBeInTheDocument(); +}); + +it('says nothing when there are no calls to reach', async () => { + const { container } = renderButton([]); + + await waitFor(() => expect(container).toBeEmptyDOMElement()); +}); + +it('opens the list on click', async () => { + renderButton([call({ callId: 'one', name: 'Standup' })]); + + await userEvent.click(await screen.findByRole('button', { name: /Ongoing_calls/ })); + + expect(await screen.findByText('Standup')).toBeInTheDocument(); +}); + +describe('when something is ringing', () => { + const ringing = [call({ callId: 'ringing', name: 'Alice', ringingAt: new Date() })]; + + it('is red', async () => { + renderButton(ringing); + + expect((await screen.findByRole('button', { name: /Ongoing_calls/ })).className).toMatch(/rcx-button--icon-secondary-danger/); + }); + + it('opens itself without being asked', async () => { + renderButton(ringing); + + expect(await screen.findByText('Alice')).toBeInTheDocument(); + }); +}); + +it('is blue while something is merely running', async () => { + renderButton([call({ callId: 'running' })]); + + expect((await screen.findByRole('button', { name: /Ongoing_calls/ })).className).toMatch(/rcx-button--icon-secondary-info/); +}); + +it('keeps a declined call behind a separator', async () => { + renderButton([call({ callId: 'one', name: 'Standup' }), call({ callId: 'refused', name: 'Design review', declined: true })]); + + await userEvent.click(await screen.findByRole('button', { name: /Ongoing_calls/ })); + + expect(await screen.findByText('Standup')).toBeInTheDocument(); + expect(screen.getByText('Design review')).toBeInTheDocument(); +}); diff --git a/apps/meteor/client/navbar/NavBarItemOngoingCalls.tsx b/apps/meteor/client/navbar/NavBarItemOngoingCalls.tsx new file mode 100644 index 0000000000000..406b6bbf6d1d4 --- /dev/null +++ b/apps/meteor/client/navbar/NavBarItemOngoingCalls.tsx @@ -0,0 +1,71 @@ +import { Badge, Box, Dropdown, IconButton } from '@rocket.chat/fuselage'; +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import OngoingCallsList from '../components/OngoingCalls/OngoingCallsList'; +import { useOngoingCallsList } from '../components/OngoingCalls/useOngoingCalls'; +import { useDropdownVisibility } from '../views/room/Header/Omnichannel/QuickActions/hooks/useDropdownVisibility'; + +const NavBarItemOngoingCalls = () => { + const { t } = useTranslation(); + const { ringing, ongoing, declined } = useOngoingCallsList(); + + const reference = useRef(null); + const target = useRef(null); + const { isVisible, toggle } = useDropdownVisibility({ reference, target }); + + const isRinging = ringing.length > 0; + const isOffering = isRinging || ongoing.length > 0; + + const ringingCount = ringing.length; + const prevRingingCount = useRef(0); + + useEffect(() => { + if (ringingCount > prevRingingCount.current) { + toggle(true); + } + + prevRingingCount.current = ringingCount; + }, [ringingCount, toggle]); + + const active = ringing.length + ongoing.length; + const total = active + declined.length; + + if (total === 0) { + return null; + } + + const name = t('Ongoing_calls'); + + return ( + <> + + toggle()} + title={name} + aria-label={name} + icon='video' + /> + {active > 0 && ( + + {active} + + )} + + {isVisible && ( + + + + + + )} + + ); +}; + +export default NavBarItemOngoingCalls; diff --git a/apps/meteor/client/providers/MeteorProvider.tsx b/apps/meteor/client/providers/MeteorProvider.tsx index 4bf5bbc42c943..f76ef3f27abf2 100644 --- a/apps/meteor/client/providers/MeteorProvider.tsx +++ b/apps/meteor/client/providers/MeteorProvider.tsx @@ -21,6 +21,8 @@ import UserPresenceProvider from './UserPresenceProvider'; import UserProvider from './UserProvider'; import VideoConfProvider from './VideoConfProvider'; import { OmnichannelRoomIconProvider } from '../components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider'; +import { LiveKitVideoConfProvider } from '../views/videoConference/livekit/LiveKitVideoConfContext'; +import LiveKitVideoConfBridge from '../views/videoConference/livekit/LiveKitVideoConfProvider'; export type MeteorProviderProps = { children?: ReactNode; @@ -46,11 +48,15 @@ const MeteorProvider = ({ children }: MeteorProviderProps) => ( - - - {children} - - + + + + + {children} + + + + diff --git a/apps/meteor/client/providers/VideoConfProvider.spec.tsx b/apps/meteor/client/providers/VideoConfProvider.spec.tsx new file mode 100644 index 0000000000000..dd23601b7ff1c --- /dev/null +++ b/apps/meteor/client/providers/VideoConfProvider.spec.tsx @@ -0,0 +1,77 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { useVideoConfStartCall } from '@rocket.chat/ui-video-conf'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import VideoConfProvider from './VideoConfProvider'; +import { VideoConfManager } from '../lib/VideoConfManager'; + +const openCall = jest.fn(() => null); + +jest.mock('../views/room/contextualBar/VideoConference/hooks/useVideoConfOpenCall', () => ({ + useVideoConfOpenCall: () => openCall, +})); + +// The mocked router builds no paths, and the path is what this is about: which window the click opens. +jest.mock('@rocket.chat/ui-contexts', () => ({ + ...jest.requireActual('@rocket.chat/ui-contexts'), + useRouter: () => ({ + buildRoutePath: ({ params, search }: { params: { id: string }; search: { rid: string } }) => + `/conference/${params.id}?rid=${search.rid}`, + }), +})); + +jest.mock('../views/room/contextualBar/VideoConference/VideoConfPopups', () => ({ + __esModule: true, + default: () => null, +})); + +const StartCallButton = () => { + const startCall = useVideoConfStartCall(); + + return ( + + ); +}; + +const renderProvider = (persistentChat: boolean) => + render( + + + , + { wrapper: mockAppRoot().withJohnDoe().withSetting('VideoConf_Enable_Persistent_Chat', persistentChat).build() }, + ); + +const startCall = jest.spyOn(VideoConfManager, 'startCall').mockResolvedValue(undefined); + +beforeEach(() => { + openCall.mockClear(); + startCall.mockClear(); +}); + +afterAll(() => { + startCall.mockRestore(); +}); + +// The reported bug: clicking *call* created the conference — a message in the room, a ring, a call in everyone's +// history — for a call the user hadn't agreed to yet. The click may only open the window. +it('creates no conference when the call window will ask first', async () => { + renderProvider(true); + + await userEvent.click(screen.getByRole('button', { name: 'call' })); + + expect(startCall).not.toHaveBeenCalled(); + expect(openCall).toHaveBeenCalledWith(expect.stringContaining('/conference/new')); + expect(openCall).toHaveBeenCalledWith(expect.stringContaining('rid=room-1')); +}); + +// Without persistent chat there is no preflight to wait for, so the conference is started here as it always was. +it('starts the conference itself when there is no call window to ask', async () => { + renderProvider(false); + + await userEvent.click(screen.getByRole('button', { name: 'call' })); + + expect(startCall).toHaveBeenCalledWith('room-1', undefined); +}); diff --git a/apps/meteor/client/providers/VideoConfProvider.tsx b/apps/meteor/client/providers/VideoConfProvider.tsx index bbcfad32a7d03..1def0e0dfe22a 100644 --- a/apps/meteor/client/providers/VideoConfProvider.tsx +++ b/apps/meteor/client/providers/VideoConfProvider.tsx @@ -1,31 +1,64 @@ -import { useToastMessageDispatch, useSetting } from '@rocket.chat/ui-contexts'; +import { useRouter, useToastMessageDispatch, useSetting } from '@rocket.chat/ui-contexts'; import type { VideoConfPopupPayload, VideoConfContextValue } from '@rocket.chat/ui-video-conf'; import { VideoConfContext } from '@rocket.chat/ui-video-conf'; import type { ReactNode } from 'react'; -import { useState, useMemo, useEffect } from 'react'; +import { useState, useMemo, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { VideoConfManager } from '../lib/VideoConfManager'; +import { absoluteUrl } from '../lib/absoluteUrl'; +import { NEW_CONFERENCE_ID } from '../views/conference/lib/callWindow'; import VideoConfPopups from '../views/room/contextualBar/VideoConference/VideoConfPopups'; +import { useLeaveCallOnWindowClose } from '../views/room/contextualBar/VideoConference/hooks/useLeaveCallOnWindowClose'; import { useVideoConfOpenCall } from '../views/room/contextualBar/VideoConference/hooks/useVideoConfOpenCall'; +import { useOptionalLiveKitVideoConf } from '../views/videoConference/livekit/LiveKitVideoConfContext'; export type VideoConfContextProviderProps = { children: ReactNode }; const VideoConfContextProvider = ({ children }: VideoConfContextProviderProps) => { const [outgoing, setOutgoing] = useState(); const handleOpenCall = useVideoConfOpenCall(); + const watchCallWindow = useLeaveCallOnWindowClose(); const dispatchToastMessage = useToastMessageDispatch(); + const router = useRouter(); const { t } = useTranslation(); const logLevel = useSetting('Log_Level', 0); + const persistentChatEnabled = useSetting('VideoConf_Enable_Persistent_Chat', false); useEffect(() => VideoConfManager.setLogLevel(logLevel), [logLevel]); + // The conference page joins for itself, after its preflight, so the manager must not do it on the way there. + useEffect(() => VideoConfManager.setPersistentChat(persistentChatEnabled), [persistentChatEnabled]); + + useEffect( + () => + VideoConfManager.on('call/join', ({ url, callId, providerName }) => { + // With persistent chat on, open the in-product conference page — the provider's call embedded + // beside the conference's chat — instead of handing the user off to the provider's own URL. + const target = persistentChatEnabled + ? handleOpenCall(absoluteUrl(router.buildRoutePath({ name: 'conference', params: { id: callId } })), providerName) + : handleOpenCall(url ?? '', providerName); + + // Whoever posts the join — this window for a provider URL, the conference page after its preflight — + // the user then counts as being in the call. If that window goes away before it can report its own + // departure, this is what does it for them. + watchCallWindow(callId, target); + }), + [handleOpenCall, router, persistentChatEnabled, watchCallWindow], + ); + + // Embedded providers (LiveKit) don't open a URL — they mount their UI + // inline. We forward the join into the embedded provider's React context + // here so the manager singleton doesn't need a direct dependency on it. + const joinEmbeddedCall = useOptionalLiveKitVideoConf()?.joinCall; useEffect( () => - VideoConfManager.on('call/join', (props) => { - handleOpenCall(props.url, props.providerName); + VideoConfManager.on('call/joinEmbedded', ({ callId, rid, providerName, preferences }) => { + if (providerName === 'livekit') { + joinEmbeddedCall?.({ callId, rid, preferences }); + } }), - [handleOpenCall], + [joinEmbeddedCall], ); useEffect( @@ -42,11 +75,31 @@ const VideoConfContextProvider = ({ children }: VideoConfContextProviderProps) = VideoConfManager.on('calling/ended', () => setOutgoing(undefined)); }, []); + /** + * Placing a call, once the user has asked for one. + * + * With persistent chat on, this only opens the call window: the conference is created by the preflight in it, + * because creating one posts a message in the room, rings people and writes a call into everyone's history — + * none of which should happen for a call the user may still walk away from. Without it there is no preflight + * to wait for, so the manager starts the conference here as it always has. + */ + const startCall = useCallback( + (rid: string, confTitle?: string) => { + if (!persistentChatEnabled) { + void VideoConfManager.startCall(rid, confTitle); + return; + } + + handleOpenCall(absoluteUrl(router.buildRoutePath({ name: 'conference', params: { id: NEW_CONFERENCE_ID }, search: { rid } }))); + }, + [handleOpenCall, persistentChatEnabled, router], + ); + const contextValue = useMemo( () => ({ dispatchOutgoing: (option) => setOutgoing({ ...option, id: option.rid }), dismissOutgoing: () => setOutgoing(undefined), - startCall: (rid, confTitle) => VideoConfManager.startCall(rid, confTitle), + startCall, acceptCall: (callId) => VideoConfManager.acceptIncomingCall(callId), joinCall: (callId) => VideoConfManager.joinCall(callId), dismissCall: (callId) => VideoConfManager.dismissIncomingCall(callId), @@ -54,13 +107,13 @@ const VideoConfContextProvider = ({ children }: VideoConfContextProviderProps) = abortCall: () => VideoConfManager.abortCall(), setPreferences: (prefs) => VideoConfManager.setPreferences(prefs), loadCapabilities: () => VideoConfManager.loadCapabilities(), - queryIncomingCalls: () => [(cb) => VideoConfManager.on('incoming/changed', cb), () => VideoConfManager.getIncomingDirectCalls()], + queryIncomingCalls: () => [(cb) => VideoConfManager.on('incoming/changed', cb), () => VideoConfManager.getIncomingCalls()], queryRinging: () => [(cb) => VideoConfManager.on('ringing/changed', cb), () => VideoConfManager.isRinging()], queryCalling: () => [(cb) => VideoConfManager.on('calling/changed', cb), () => VideoConfManager.isCalling()], queryCapabilities: () => [(cb) => VideoConfManager.on('capabilities/changed', cb), () => VideoConfManager.capabilities], queryPreferences: () => [(cb) => VideoConfManager.on('preference/changed', cb), () => VideoConfManager.preferences], }), - [], + [startCall], ); return ( diff --git a/apps/meteor/client/sidebar/Item/Extended.tsx b/apps/meteor/client/sidebar/Item/Extended.tsx index 170f0f84e2b9a..d1ec6dcfac145 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/sidebar/RoomList/RoomList.tsx b/apps/meteor/client/sidebar/RoomList/RoomList.tsx index 6f69ab47603de..5d7d15fb0cf14 100644 --- a/apps/meteor/client/sidebar/RoomList/RoomList.tsx +++ b/apps/meteor/client/sidebar/RoomList/RoomList.tsx @@ -24,6 +24,7 @@ const RoomList = () => { const isAnonymous = !userId; const { collapsedGroups, handleClick, handleKeyDown } = useCollapsedGroups(); + const { groupsCount, groupsList, roomList, groupedUnreadInfo } = useRoomList({ collapsedGroups }); const avatarTemplate = useAvatarTemplate(); const sideBarItemTemplate = useTemplateByViewMode(); @@ -64,7 +65,15 @@ const RoomList = () => { /> )} {...(roomList.length > 0 && { - itemContent: (index) => roomList[index] && , + itemContent: (index) => { + const item = roomList[index]; + + if (!item) { + return null; + } + + return ; + }, })} components={{ Item: RoomListRowWrapper, List: RoomListWrapper }} /> diff --git a/apps/meteor/client/sidebar/RoomList/RoomListRow.tsx b/apps/meteor/client/sidebar/RoomList/RoomListRow.tsx index 8a7e595805c77..68756783da250 100644 --- a/apps/meteor/client/sidebar/RoomList/RoomListRow.tsx +++ b/apps/meteor/client/sidebar/RoomList/RoomListRow.tsx @@ -1,7 +1,6 @@ import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; -import { useVideoConfAcceptCall, useVideoConfRejectIncomingCall, useVideoConfIncomingCalls } from '@rocket.chat/ui-video-conf'; import type { TFunction } from 'i18next'; -import { memo, useMemo } from 'react'; +import { memo } from 'react'; import SidebarItemTemplateWithData from './SidebarItemTemplateWithData'; import type { useAvatarTemplate } from '../hooks/useAvatarTemplate'; @@ -24,20 +23,6 @@ export type RoomListRowProps = { const RoomListRow = ({ data, item }: RoomListRowProps) => { const { extended, t, SidebarItemTemplate, AvatarTemplate, openedRoom, sidebarViewMode, userId } = data; - const acceptCall = useVideoConfAcceptCall(); - const rejectCall = useVideoConfRejectIncomingCall(); - const incomingCalls = useVideoConfIncomingCalls(); - const currentCall = incomingCalls.find((call) => call.rid === item.rid); - - const videoConfActions = useMemo( - () => - currentCall && { - acceptCall: (): void => acceptCall(currentCall.callId), - rejectCall: (): void => rejectCall(currentCall.callId), - }, - [acceptCall, rejectCall, currentCall], - ); - return ( { extended={extended} SidebarItemTemplate={SidebarItemTemplate} AvatarTemplate={AvatarTemplate} - videoConfActions={videoConfActions} userId={userId} /> ); diff --git a/apps/meteor/client/sidebar/hooks/useRoomList.spec.tsx b/apps/meteor/client/sidebar/hooks/useRoomList.spec.tsx index ea8ca667ec079..e7643455c0ecf 100644 --- a/apps/meteor/client/sidebar/hooks/useRoomList.spec.tsx +++ b/apps/meteor/client/sidebar/hooks/useRoomList.spec.tsx @@ -237,7 +237,7 @@ it('should not include unread room in unread group if hideUnreadStatus is enable }).build(), }); const unreadIndex = result.current.groupsList.indexOf('Unread'); - const roomListUnread = result.current.roomList.filter((room) => room.unread); + const roomListUnread = result.current.roomList.filter((item) => item.unread); expect(result.current.groupsCount[unreadIndex]).toEqual(unreadChannels.length); expect(roomListUnread.length).not.toEqual(unreadChannels.length); diff --git a/apps/meteor/client/sidebar/hooks/useRoomList.ts b/apps/meteor/client/sidebar/hooks/useRoomList.ts index 2fb1bfc3366b3..5b4d4f9b5a620 100644 --- a/apps/meteor/client/sidebar/hooks/useRoomList.ts +++ b/apps/meteor/client/sidebar/hooks/useRoomList.ts @@ -2,7 +2,6 @@ import type { ILivechatInquiryRecord } from '@rocket.chat/core-typings'; import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; import type { SubscriptionWithRoom, TranslationKey } from '@rocket.chat/ui-contexts'; import { useUserPreference, useUserSubscriptions, useSetting } from '@rocket.chat/ui-contexts'; -import { useVideoConfIncomingCalls } from '@rocket.chat/ui-video-conf'; import { useMemo } from 'react'; import { useSortQueryOptions } from '../../hooks/useSortQueryOptions'; @@ -14,7 +13,6 @@ const query = { open: { $ne: false } }; const emptyQueue: ILivechatInquiryRecord[] = []; const order = [ - 'Incoming_Calls', 'Incoming_Livechats', 'Open_Livechats', 'On_Hold_Chats', @@ -27,8 +25,10 @@ const order = [ 'Conversations', ] as const; +export type SidebarListItem = SubscriptionWithRoom; + type useRoomListReturnType = { - roomList: Array; + roomList: Array; groupsCount: number[]; groupsList: TranslationKey[]; groupedUnreadInfo: Pick< @@ -50,15 +50,12 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) const inquiries = useQueuedInquiries(); - const incomingCalls = useVideoConfIncomingCalls(); - const queue = inquiries.enabled ? inquiries.queue : emptyQueue; const { groupsCount, groupsList, roomList, groupedUnreadInfo } = useDebouncedValue( useMemo(() => { const isCollapsed = (groupTitle: string) => collapsedGroups?.includes(groupTitle); - const incomingCall = new Set(); const favorite = new Set(); const team = new Set(); const omnichannel = new Set(); @@ -74,10 +71,6 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) return; } - if (incomingCalls.find((call) => call.rid === room.rid)) { - return incomingCall.add(room); - } - if (sidebarShowUnread && (room.alert || room.unread || room.tunread?.length) && !room.hideUnreadStatus) { return unread.add(room); } @@ -114,7 +107,6 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) }); const groups = new Map>(); - incomingCall.size && groups.set('Incoming_Calls', incomingCall); showOmnichannel && inquiries.enabled && queue.length && groups.set('Incoming_Livechats', new Set(queue)); showOmnichannel && omnichannel.size && groups.set('Open_Livechats', omnichannel); @@ -142,7 +134,7 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) return acc; } - acc.groupsList.push(key as TranslationKey); + acc.groupsList.push(key); const groupedUnreadInfoAcc = { userMentions: 0, @@ -200,7 +192,6 @@ export const useRoomList = ({ collapsedGroups }: { collapsedGroups?: string[] }) isDiscussionEnabled, sidebarOrder, collapsedGroups, - incomingCalls, ]), 50, ); 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/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/CallDeviceMenu.tsx b/apps/meteor/client/views/conference/CallDeviceMenu.tsx new file mode 100644 index 0000000000000..993504b677c56 --- /dev/null +++ b/apps/meteor/client/views/conference/CallDeviceMenu.tsx @@ -0,0 +1,151 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, Button, Dropdown, Icon, Option, OptionColumn, OptionContent } from '@rocket.chat/fuselage'; +import type { Keys as IconName } from '@rocket.chat/icons'; +import { SYSTEM_DEFAULT_DEVICE_ID, deviceName, orderDevices } from '@rocket.chat/ui-voip'; +import { useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useDropdownVisibility } from '../room/Header/Omnichannel/QuickActions/hooks/useDropdownVisibility'; + +type Choice = { id: string; name: string; note?: string }; + +type CallDeviceMenuProps = { + icon: IconName; + label: string; + /** The devices to choose between. Ignored when `choices` is given. */ + devices?: MediaDeviceInfo[]; + selectedId?: string; + onSelect: (deviceId: string) => void; + /** + * Further groups of choices under the devices, in the same dropdown: what to do about noise, how much detail to + * send, how much to blur. They belong to the device they are about — noise is a fact about the microphone, blur + * about the camera — so they live behind the same control rather than in a row of their own, which is also where + * the call itself puts them. + */ + sections?: { title: string; choices: Choice[]; selectedId?: string; onSelect: (id: string) => void }[]; +}; + +/** + * The device on the left, its name beside it, the chevron pushed to the far right — a control that says what it + * is, what it is set to, and that there is more behind it, read left to right. + * + * Built on a plain button rather than `GenericMenu` because that one clones its trigger: it injects its own + * chevron as a *leading* icon and replaces the button's `className`, so neither the icon's place nor the name's + * alignment was ours to set. Owning the open state is also what lets the chevron turn over when it opens. + */ +const triggerStyles = css` + width: 100%; + min-width: 0; + + & > .rcx-button--content { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + justify-content: flex-start; + gap: 6px; + } +`; + +const nameStyles = css` + overflow: hidden; + flex-grow: 1; + text-align: left; + white-space: nowrap; + text-overflow: ellipsis; +`; + +/** + * Picks which camera, microphone or speaker to arrive on, from the preflight. + * + * Separate from `ui-voip`'s in-call pickers on purpose: those dispatch through the call's own view context to + * switch a device mid-call, and there is no call here yet. This one only records a choice for the join to carry. + */ +const CallDeviceMenu = ({ icon, label, devices, selectedId, onSelect, sections }: CallDeviceMenuProps) => { + const { t } = useTranslation(); + + const reference = useRef(null); + const target = useRef(null); + const { isVisible, toggle } = useDropdownVisibility({ reference, target }); + + // Devices and plain choices are reduced to the same three fields, so everything below draws one kind of row. + // Devices go through `orderDevices` first, shared with the in-call pickers, so a device is named and ordered the + // same way before a call and inside one. + const rows = useMemo((): Choice[] => { + return orderDevices(devices ?? []).map((device) => ({ + id: device.deviceId, + // A device the browser hasn't named yet — permission was granted after it was enumerated. + name: deviceName(device.label), + ...(device.deviceId === SYSTEM_DEFAULT_DEVICE_ID && { note: 'system-default' }), + })); + }, [devices]); + + const currentId = selectedId ?? rows[0]?.id; + const current = rows.find(({ id }) => id === currentId); + + const renderRow = (row: Choice, isCurrent: boolean, choose: () => void) => ( + + ); + + return ( + + + + {isVisible && ( + + {rows.map((row) => renderRow(row, row.id === currentId, () => onSelect(row.id)))} + + {sections?.map((section) => ( + + {/* A heading, because a list that runs from microphones straight into "no blur" reads as one + list of increasingly strange devices. */} + + {section.title} + + {section.choices.map((choice) => + renderRow(choice, choice.id === (section.selectedId ?? section.choices[0]?.id), () => section.onSelect(choice.id)), + )} + + ))} + + )} + + ); +}; + +export default CallDeviceMenu; 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/CallDiagnosticsPanel.tsx b/apps/meteor/client/views/conference/CallDiagnosticsPanel.tsx new file mode 100644 index 0000000000000..e0747eade554f --- /dev/null +++ b/apps/meteor/client/views/conference/CallDiagnosticsPanel.tsx @@ -0,0 +1,163 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, Divider } from '@rocket.chat/fuselage'; +import type { CallDiagnosticsData } from '@rocket.chat/ui-voip'; +import { useTranslation } from 'react-i18next'; + +import CallDiagnosticsParticipantCard from './CallDiagnosticsParticipantCard'; +import CallDiagnosticsStatRow from './CallDiagnosticsStatRow'; +import CallPanelHeader from './CallPanelHeader'; + +type CallDiagnosticsPanelProps = { + // ui-voip's source type carries this field. Keep the intersection while Meteor typechecks against a previously + // built workspace-package declaration, which can lag behind that source until the package is rebuilt. + diagnostics?: CallDiagnosticsData & { + backgroundBlur?: { + fps?: number; + frameMs?: number; + compositorMs?: number; + segmentationMs?: number; + segmentIntervalMs: number; + qualityReduction: 0 | 1 | 2; + }; + }; + onClose: () => void; +}; + +const sectionStyles = css` + padding-block: 8px; + padding-inline: 16px; +`; + +const labelStyles = css` + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--rcx-color-font-secondary-info); + margin-block-end: 8px; +`; + +const qualityDotStyles = (quality: string) => { + const colors: Record = { + excellent: '#2de0a5', + good: '#2de0a5', + poor: '#f5a623', + lost: '#f44336', + }; + return css` + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: ${colors[quality.toLowerCase()] || 'var(--rcx-color-font-secondary-info)'}; + margin-inline-end: 6px; + `; +}; + +const formatBytes = (bytes?: number): string => { + if (bytes == null) { + return '—'; + } + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}; + +const fmt = (value: number | undefined, suffix: string): string => (value != null ? `${value} ${suffix}` : '—'); +const fmtDecimal = (value: number | undefined, suffix = ''): string => (value != null ? `${Math.round(value * 10) / 10}${suffix}` : '—'); +const fmtKbps = (value: number | undefined): string => (value != null ? `${Math.round(value / 10) * 10} kbps` : '—'); + +const CallDiagnosticsPanel = ({ diagnostics, onClose }: CallDiagnosticsPanelProps) => { + const { t } = useTranslation(); + + return ( + <> + + + {!diagnostics ? ( + + {t('Waiting_for_data')} + + ) : ( + <> + + {t('Connection')} + + + + + {diagnostics.connectionQuality} + + } + /> + + + + + + + {t('Bandwidth')} + + + + + + + + + + {t('Video_sending')} + + + + + + + {diagnostics.backgroundBlur && ( + <> + + + Background blur + + + + + + + + + )} + + {diagnostics.participants.length > 0 && ( + <> + + + + {t('Receiving')} ({diagnostics.participants.length}) + + {diagnostics.participants.map((p) => ( + + ))} + + + )} + + )} + + + ); +}; + +export default CallDiagnosticsPanel; diff --git a/apps/meteor/client/views/conference/CallDiagnosticsParticipantCard.tsx b/apps/meteor/client/views/conference/CallDiagnosticsParticipantCard.tsx new file mode 100644 index 0000000000000..f3760807a9699 --- /dev/null +++ b/apps/meteor/client/views/conference/CallDiagnosticsParticipantCard.tsx @@ -0,0 +1,35 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box } from '@rocket.chat/fuselage'; +import type { ParticipantTrackStats } from '@rocket.chat/ui-voip'; + +import CallDiagnosticsStatRow from './CallDiagnosticsStatRow'; + +const participantCardStyles = css` + padding: 8px 12px; + border-radius: 8px; + background: var(--rcx-color-surface-hover); + margin-block-end: 8px; +`; + +const fmt = (value: number | undefined, suffix: string): string => (value != null ? `${value} ${suffix}` : '—'); +const fmtKbps = (value: number | undefined): string => (value != null ? `${Math.round(value / 10) * 10} kbps` : '—'); + +const CallDiagnosticsParticipantCard = ({ participant }: { participant: ParticipantTrackStats }) => ( + + + {participant.displayName} + + + + + + + + + +); + +export default CallDiagnosticsParticipantCard; diff --git a/apps/meteor/client/views/conference/CallDiagnosticsStatRow.tsx b/apps/meteor/client/views/conference/CallDiagnosticsStatRow.tsx new file mode 100644 index 0000000000000..b152f077a02de --- /dev/null +++ b/apps/meteor/client/views/conference/CallDiagnosticsStatRow.tsx @@ -0,0 +1,25 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box } from '@rocket.chat/fuselage'; +import type { ReactNode } from 'react'; + +const rowStyles = css` + display: flex; + justify-content: space-between; + align-items: center; + padding-block: 4px; + font-size: 14px; +`; + +const valueStyles = css` + font-variant-numeric: tabular-nums; + font-weight: 500; +`; + +const CallDiagnosticsStatRow = ({ label, value }: { label: string; value: ReactNode }) => ( + + {label} + {value ?? '—'} + +); + +export default CallDiagnosticsStatRow; diff --git a/apps/meteor/client/views/conference/CallMemberItem.tsx b/apps/meteor/client/views/conference/CallMemberItem.tsx new file mode 100644 index 0000000000000..9c4b819862f90 --- /dev/null +++ b/apps/meteor/client/views/conference/CallMemberItem.tsx @@ -0,0 +1,133 @@ +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, useUserId } from '@rocket.chat/ui-contexts'; +import { VoiceActivity } from '@rocket.chat/ui-voip'; +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; + /** Membership grants no room access, so a member can be in the call and unable to read its chat. */ + hasChatAccess: boolean; + /** Whether they are waiting to speak. The queue's order is stated by the call's own header, not here. */ + handRaised?: boolean; + /** Whether their microphone is already off, in which case there is nothing to ask for. */ + muted?: boolean; + /** Their microphone, so the row can show it moving. Absent for anyone the call has no audio from. */ + audioStream?: MediaStream | null; + /** + * Asks them to mute themselves. Absent where the transport cannot carry the request; the row decides for + * itself whether there is anyone here to ask. + */ + onMute?: (memberId: string) => void; + onRing: (memberId: string) => void; +}; + +/** Only shown for members who aren't in the call — for those, presence in the call is the whole story. */ +const statusLabel: Record, string> = { + left: 'Left', + declined: 'Declined', + invited: 'Waiting_for_answer', +}; + +const CallMemberItem = ({ member, hasChatAccess, handRaised, muted, audioStream, onRing, onMute }: CallMemberItemProps) => { + const { t } = useTranslation(); + const ownUserId = useUserId(); + 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); + + // So the "ring again" button comes back the moment this ring lapses, rather than on the next unrelated change. + useRingingExpiry([ringing ? member.ringingAt : undefined]); + + return ( + + ); +}; + +export default CallMemberItem; diff --git a/apps/meteor/client/views/conference/CallMembersPanel.spec.tsx b/apps/meteor/client/views/conference/CallMembersPanel.spec.tsx new file mode 100644 index 0000000000000..66399d72a1195 --- /dev/null +++ b/apps/meteor/client/views/conference/CallMembersPanel.spec.tsx @@ -0,0 +1,217 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import CallMembersPanel from './CallMembersPanel'; +import type { ConferenceMember } from './hooks/useConferenceEmbedded'; +import { buildChatAccess, buildConferenceMember } from './testFixtures'; + +const ring = jest.fn(() => ({ rang: [], success: true }) as any); + +const onMute = jest.fn(); + +const renderPanel = ( + members: ConferenceMember[], + membersWithoutAccess: string[] = [], + extras: { raisedHands?: Set; mutedMembers?: Set } = {}, +) => + render( + , + { + wrapper: mockAppRoot() + .withJohnDoe() + .withEndpoint('POST', '/v1/video-conference.ring', ring) + .withEndpoint('GET', '/v1/channels.members', () => ({ members: [] }) as any) + .withEndpoint('GET', '/v1/users.autocomplete', () => ({ items: [] }) as any) + .build(), + }, + ); + +const rowFor = (username: string) => screen.getByText(username).closest('[role="listitem"], li') as HTMLElement; + +// Each voice indicator is three bars inside an `aria-hidden` row — see `VoiceActivity`. +const voiceIndicatorCount = (container: HTMLElement) => container.querySelectorAll('div[aria-hidden="true"] > div').length / 3; + +beforeEach(() => { + ring.mockClear(); + onMute.mockClear(); +}); + +it('lists every member', () => { + renderPanel([buildConferenceMember({ _id: 'joiner', joined: true }), buildConferenceMember({ _id: 'invitee', joined: false })]); + + expect(screen.getByText('joiner')).toBeInTheDocument(); + expect(screen.getByText('invitee')).toBeInTheDocument(); +}); + +// The two halves answer different questions — who is here, and who still isn't — so they are counted separately +// rather than as one list the reader has to sort themselves. +it('splits into who is in the call and who is not, with counts', () => { + renderPanel([ + buildConferenceMember({ _id: 'joiner', joined: true }), + buildConferenceMember({ _id: 'invitee', joined: false }), + buildConferenceMember({ _id: 'leaver', joined: true, leftAt: new Date() }), + ]); + + expect(screen.getByText('In_call').parentElement).toHaveTextContent('1'); + expect(screen.getByText('Not_in_the_call').parentElement).toHaveTextContent('2'); +}); + +it('leaves out a section nobody is in', () => { + renderPanel([buildConferenceMember({ _id: 'joiner', joined: true })]); + + expect(screen.getByText('In_call')).toBeInTheDocument(); + expect(screen.queryByText('Not_in_the_call')).not.toBeInTheDocument(); +}); + +describe('status', () => { + // Only for members who aren't in the call — for those, the section they are in already says it. + it.each([ + ['invitee', { joined: false }, 'Waiting_for_answer'], + ['decliner', { joined: false, declined: true }, 'Declined'], + ['leaver', { joined: true, leftAt: new Date() }, 'Left'], + ])('labels %s', (id, state, label) => { + renderPanel([buildConferenceMember({ _id: id, ...state })]); + + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + it('says a member is ringing while their phone still is', () => { + renderPanel([buildConferenceMember({ _id: 'ringing', joined: false, ringingAt: new Date() })]); + + expect(screen.getByText('Ringing')).toBeInTheDocument(); + }); +}); + +// Membership grants no room access, so a member can be in the call and unable to read its chat. It is the one +// thing about a member the other participants can act on, so it is worth showing against their name — as an +// icon, which is why this asserts on the label rather than on visible text. +it('flags a member who cannot read the chat, and only that member', () => { + renderPanel( + [buildConferenceMember({ _id: 'outsider', joined: true }), buildConferenceMember({ _id: 'insider', joined: true })], + ['outsider'], + ); + + const flags = screen.getAllByLabelText('No_chat_access'); + expect(flags).toHaveLength(1); + expect(rowFor('outsider')).toContainElement(flags[0]); +}); + +describe('ringing a single member', () => { + it('rings only the member asked for', async () => { + renderPanel([buildConferenceMember({ _id: 'invitee', joined: false })]); + + await userEvent.click(screen.getByRole('button', { name: 'Ring__name__' })); + + await waitFor(() => expect(ring).toHaveBeenCalledWith({ callId: 'call-id', users: ['invitee'] })); + }); + + it.each([ + ['a member who declined', { joined: false, declined: true }], + ['a member who left', { joined: true, leftAt: new Date() }], + ])('offers to ring %s back', (_case, state) => { + renderPanel([buildConferenceMember({ _id: 'absentee', ...state })]); + + expect(screen.getByRole('button', { name: 'Ring__name__' })).toBeInTheDocument(); + }); + + it('does not offer to ring someone already in the call', () => { + renderPanel([buildConferenceMember({ _id: 'joiner', joined: true })]); + + expect(screen.queryByRole('button', { name: 'Ring__name__' })).not.toBeInTheDocument(); + }); + + // There is nothing to ask for while their phone is ringing; the offer returns once the ring has run out. + it('does not offer to ring a member who is being rung right now', () => { + renderPanel([buildConferenceMember({ _id: 'ringing', joined: false, ringingAt: new Date() })]); + + expect(screen.queryByRole('button', { name: 'Ring__name__' })).not.toBeInTheDocument(); + }); + + it('offers to ring a member whose ring has run out', () => { + renderPanel([buildConferenceMember({ _id: 'ignored', joined: false, ringingAt: new Date(Date.now() - 60_000) })]); + + expect(screen.getByRole('button', { name: 'Ring__name__' })).toBeInTheDocument(); + }); +}); + +// Adding people belongs with the list of who is already here, rather than with the chat. +it('offers to add people', async () => { + renderPanel([buildConferenceMember({ _id: 'joiner', joined: true })]); + + await userEvent.click(screen.getByRole('button', { name: 'Add_people' })); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); +}); + +// Asking someone else for silence, which is a request their own client honours — this list is where the people in +// the call are, so it is where the asking belongs. +describe('asking a member to mute', () => { + it('asks the member who is in the call', async () => { + renderPanel([buildConferenceMember({ _id: 'joiner', joined: true })]); + + await userEvent.click(screen.getByRole('button', { name: 'Mute__name__' })); + + expect(onMute).toHaveBeenCalledWith('joiner'); + }); + + // Nothing to mute for someone who isn't there, and a button that does nothing is worse than no button. + it('offers nothing for a member who has not joined', () => { + renderPanel([buildConferenceMember({ _id: 'invitee', joined: false })]); + + expect(screen.queryByRole('button', { name: 'Mute__name__' })).not.toBeInTheDocument(); + }); + + // A muted member's row says nothing about their microphone. Everyone in the call already hears the silence, so + // stating it once per row would repeat it for exactly the rows there is least to say about. + it('says nothing at all about a muted member', () => { + const { container } = renderPanel([buildConferenceMember({ _id: 'quiet', joined: true })], [], { + mutedMembers: new Set(['quiet']), + }); + + expect(screen.queryByRole('button', { name: 'Mute__name__' })).not.toBeInTheDocument(); + expect(voiceIndicatorCount(container)).toBe(0); + }); + + // The useful case: a mic that is on, where whether it is picking anything up is worth seeing and asking for + // silence is a thing someone might want to do. + it('shows a live mic, with the way to quiet it beside it', () => { + const { container } = renderPanel([buildConferenceMember({ _id: 'talker', joined: true })]); + + expect(voiceIndicatorCount(container)).toBe(1); + expect(screen.getByRole('button', { name: 'Mute__name__' })).toBeInTheDocument(); + }); + + // The reader gets the level and no button: muting yourself is what the call's own bar is for. + it('shows the reader their own level without offering to mute them', () => { + const { container } = renderPanel([buildConferenceMember({ _id: 'john.doe', joined: true })]); + + expect(voiceIndicatorCount(container)).toBe(1); + expect(screen.queryByRole('button', { name: 'Mute__name__' })).not.toBeInTheDocument(); + }); + + // Muting yourself is what the control on the call's own bar is for. `withJohnDoe` is the reader here. + it('offers nothing against the reader themselves', () => { + renderPanel([buildConferenceMember({ _id: 'john.doe', joined: true }), buildConferenceMember({ _id: 'someone', joined: true })]); + + expect(screen.getAllByRole('button', { name: 'Mute__name__' })).toHaveLength(1); + }); +}); + +// The queue's order is the call header's to state; here it is only who is waiting. +it('marks the members who have their hand up', () => { + renderPanel([buildConferenceMember({ _id: 'waiting', joined: true }), buildConferenceMember({ _id: 'quiet', joined: true })], [], { + raisedHands: new Set(['waiting']), + }); + + expect(screen.getAllByTitle('Raised_hand')).toHaveLength(1); +}); diff --git a/apps/meteor/client/views/conference/CallMembersPanel.tsx b/apps/meteor/client/views/conference/CallMembersPanel.tsx new file mode 100644 index 0000000000000..802498ab69b78 --- /dev/null +++ b/apps/meteor/client/views/conference/CallMembersPanel.tsx @@ -0,0 +1,115 @@ +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[]; + /** Where the chat lives and who among the members can't read it — membership grants no room access. */ + chatAccess?: ConferenceChatAccess; + /** Who currently has their hand up, so the list says it too rather than leaving it to the tiles. */ + raisedHands?: Set; + /** Whose microphone is already off. There is nothing to ask of them, so they are not asked. */ + mutedMembers?: Set; + /** Each member's microphone, by id, so a row can show it moving. */ + audioStreams?: Map; + /** Asks a member to mute themselves. Absent where the transport cannot carry the request. */ + onMute?: (memberId: string) => void; + onClose: () => void; +}; + +/** + * Who is on the call and where each of them stands, shaped like the room's own members list so the two read the + * same way. + * + * This is where the membership model finally becomes visible: until now a decline was recorded and a member + * added from outside the room was flagged in aggregate, but there was nowhere to see either against a name. It + * also carries "add people", which belongs with the list of who is already here rather than with the chat. + * + * Split in two, because the two halves answer different questions: who is here, and who still isn't. + */ +const CallMembersPanel = ({ + callId, + rid, + members, + chatAccess, + raisedHands, + mutedMembers, + audioStreams, + onMute, + 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], + ); + + // The conference stream tells every participant when membership moves, so the list refreshes itself and + // there is nothing to refetch here on success. + 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/CallPresenting.tsx b/apps/meteor/client/views/conference/CallPresenting.tsx new file mode 100644 index 0000000000000..d263fa3cd6ec6 --- /dev/null +++ b/apps/meteor/client/views/conference/CallPresenting.tsx @@ -0,0 +1,77 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Avatar, Box, Icon } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +export type Presenter = { + name: string; + avatarUrl?: string; + isLocal?: boolean; +}; + +const pillStyles = css` + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: none; + border-radius: 16px; + background-color: rgba(255, 255, 255, 0.15); + color: #fff; + font-size: 12px; + line-height: 16px; + font-weight: 500; + white-space: nowrap; +`; + +const stopButtonStyles = css` + flex-shrink: 0; + padding: 2px 8px; + border: none; + border-radius: 12px; + background-color: rgb(187 57 51); + color: #fff; + font-size: 11px; + line-height: 16px; + font-weight: 600; + cursor: pointer; + + &:hover { + background-color: rgb(213 67 60); + } +`; + +type CallPresentingProps = { + presenters: Presenter[]; + onStopPresenting?: () => void; +}; + +const CallPresenting = ({ presenters, onStopPresenting }: CallPresentingProps) => { + const { t } = useTranslation(); + + if (!presenters.length) { + return null; + } + + const [first, ...rest] = presenters; + const qualifier = first.isLocal ? t('You_presenting') : t('Presenting'); + const label = `${first.name} (${qualifier})`; + + return ( + + {first.isLocal ? : } + {label} + {rest.length > 0 && ( + + +{rest.length} + + )} + {first.isLocal && onStopPresenting && ( + + {t('Stop_presenting')} + + )} + + ); +}; + +export default CallPresenting; diff --git a/apps/meteor/client/views/conference/CallRaisedHands.tsx b/apps/meteor/client/views/conference/CallRaisedHands.tsx new file mode 100644 index 0000000000000..3893796073d12 --- /dev/null +++ b/apps/meteor/client/views/conference/CallRaisedHands.tsx @@ -0,0 +1,123 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box } from '@rocket.chat/fuselage'; +import { GenericMenu } from '@rocket.chat/ui-client'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import type { ComponentProps } from 'react'; +import { forwardRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +export type RaisedHand = { + id: string; + /** Who they are. Falls back to whatever the call knows; never blank, or the label would say nothing. */ + name: string; +}; + +const buttonStyles = css` + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 220px; + padding: 4px 10px; + border: none; + border-radius: 16px; + background-color: var(--rcx-color-button-background-success-default, #148660); + color: #fff; + font-size: 12px; + line-height: 16px; + font-weight: 500; + cursor: pointer; + + &:hover, + &:focus-visible { + background-color: var(--rcx-color-button-background-success-hover, #106d4f); + } +`; + +// GenericMenu clones whatever it is given as `button` and stamps its own props onto it: `small` and `icon`, neither +// of which belongs on a label, and a `className` of its own. That last one is why this takes the class apart and +// puts it back together — spread over the top, it replaced the pill's styling wholesale and left the label as bare +// text with no background, no padding and nothing between the hand and the name. +type RaisedHandsButtonProps = Omit, 'is' | 'className'> & { + small?: boolean; + icon?: unknown; + className?: string; +}; + +const RaisedHandsButton = forwardRef(function RaisedHandsButton( + { small: _small, icon: _icon, className, children, ...props }, + ref, +) { + return ( + + {children} + + ); +}); + +/** + * Who is waiting to speak, next in line first. + * + * A raised hand used to be visible only as a badge on the raiser's own tile, which stops working the moment a + * call is bigger than the tiles it can show — the very calls where a queue matters most. So the front of the + * queue is stated next to the participants button, where it is legible however many people are in the call, and + * the rest of the line is a click away rather than spread across tiles that may not be on screen. + * + * Nothing is rendered when nobody has their hand up: an empty queue is not a thing to say, and a permanent + * control that is usually blank teaches people to stop reading it. + */ +// eslint-disable-next-line react/no-multi-comp +const CallRaisedHands = ({ hands }: { hands: RaisedHand[] }) => { + const { t } = useTranslation(); + + if (!hands.length) { + return null; + } + + const [next, ...waiting] = hands; + + const items: GenericMenuItemProps[] = hands.map(({ id, name }, index) => ({ + id, + textValue: name, + // Numbered, because the order is the point — this is a queue, not a set. + content: ( + + + {index + 1}. + + + {name} + + + ), + })); + + // Reads as a sentence for anyone who can't see the layout: the name alone would be a name with no reason. + const label = t('__name__raised_their_hand', { name: next.name }); + + return ( + + + ✋ + + + {next.name} + + {/* How many more are behind them. Kept out of the truncation above, so a long name shortens + rather than hiding the fact that there is a queue at all. */} + {waiting.length > 0 && ( + + +{waiting.length} + + )} + + } + /> + ); +}; + +export default CallRaisedHands; 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..66432c09851fb --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceEmbeddedPage.tsx @@ -0,0 +1,490 @@ +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 { useUserDisplayName } from '@rocket.chat/ui-client'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { useCustomSound, useUser, useUserAvatarPath, useUserSubscription } from '@rocket.chat/ui-contexts'; +import { MediaCallRoomSection, useMediaCallView } from '@rocket.chat/ui-voip'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import CallDiagnosticsPanel from './CallDiagnosticsPanel'; +import CallMembersPanel from './CallMembersPanel'; +import CallPresenting from './CallPresenting'; +import type { Presenter } from './CallPresenting'; +import CallRaisedHands from './CallRaisedHands'; +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 { PREFLIGHT_FACES_SHOWN } from '../../../lib/videoConference/constants'; +import PageLoading from '../root/PageLoading'; +import CallBar from './components/CallBar/CallBar'; +import CallTopBar from './components/CallBar/CallTopBar'; +import CallPanel from './components/CallPanel/CallPanel'; +import { useCallPreferences } from './hooks/useCallPreferences'; +import { useConferenceEmbedded } from './hooks/useConferenceEmbedded'; +import { useConferencePresenceLease } from './hooks/useConferencePresenceLease'; +import { useConferenceSubscription } from './hooks/useConferenceSubscription'; +import { useConfinedNavigation } from './hooks/useConfinedNavigation'; +import { useEmbeddedConferenceCall } from './hooks/useEmbeddedConferenceCall'; +import { useLeaveConferenceOnClose } from './hooks/useLeaveConferenceOnClose'; +import { useRingingExpiry } from '../../hooks/useRingingExpiry'; +import { useUnreadDisplay } from '../../sidebar/hooks/useUnreadDisplay'; +import { useCallDiagnosticsContext } from '../videoConference/livekit/CallDiagnosticsContext'; + +type ConferenceEmbeddedPageProps = { + callId: string; +}; + +/** The things that can share the space beside the call. One at a time — two would leave the call a sliver. */ +type ConferencePanel = 'members' | 'chat' | 'diagnostics'; + +/** Stands in until the subscription loads, or for a member who has none because they can't read the 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 4px 4px; + 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 memberAvatarStyles = css` + width: 24px; + height: 24px; + border-radius: 50%; + border: 2px solid rgba(30, 30, 35, 1); + object-fit: cover; + flex-shrink: 0; +`; + +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); + } +`; + +/** + * Renders a conference as the call plus a bottom control bar, with the conference's persistent chat in a + * panel that opens beside the call — above the bar, so toggling it never reflows the controls. + */ +const ConferenceEmbeddedPage = ({ callId }: ConferenceEmbeddedPageProps) => { + const { room, conference, call } = useConferenceEmbedded(callId); + const { t } = useTranslation(); + const [threadTmid, setThreadTmid] = useState(null); + + // In "main room" chat mode the panel shows the full room, where thread indicators are visible but the + // conference route has no tab/context params to open them. Intercept those clicks and show the thread + // in a modal instead. + const handleOpenThread = useCallback( + (tmid: string) => { + if (!room.rid) { + return; + } + setThreadTmid(tmid); + }, + [room.rid], + ); + + // The chat panel is a full room UI, so a link/mention click would navigate this window away and tear + // down the call. Keep this window pinned to the conference — those go to the opener or a new tab. + useConfinedNavigation({ onOpenThread: room.tmid ? undefined : handleOpenThread }); + + // Closing this window is the only end-of-call signal a provider that doesn't report one leaves us, and the + // call has to end for its history to be written. + const { leaveNow } = useLeaveConferenceOnClose(callId); + + // What covers the times that signal can't get through — the workspace being down while the call carries on in + // the provider, or this window dying without a word. Leaving is inferred from these renewals stopping. + useConferencePresenceLease(callId, conference.joined); + + // How the user chose to arrive. Read from where the preflight put it rather than from this window's own + // join, because starting a call joins on the *start* screen — this window then finds the result in the + // cache, having never asked, and would otherwise hand the provider nothing and get its defaults. + const { preferences, devices } = useCallPreferences(call.capabilities); + + // A provider that runs the call in here is connected by a tree above this route, so joining has to tell it + // which call this window is showing. + useEmbeddedConferenceCall({ + callId, + rid: room.rid, + embedded: conference.embedded, + preferences, + devices, + // Hanging up ends what this window is for, so it reports leaving and closes — the same thing Cancel on + // the preflight does, and what closing the window would have done anyway. + onEnded: leaveNow, + }); + + // How the embedded call should name and picture the viewer — it has no room membership to read that from. + const user = useUser(); + const getUserAvatarPath = useUserAvatarPath(); + const selfDisplayName = useUserDisplayName({ name: user?.name, username: user?.username }); + const self = useMemo( + () => ({ + id: user?._id || 'local', + displayName: selfDisplayName || '', + avatarUrl: getUserAvatarPath({ userId: user?._id || '' }), + }), + [user?._id, selfDisplayName, getUserAvatarPath], + ); + + // Who is waiting to speak, in the order they asked. The transport reports the queue by user id — that is what + // a participant is to it — so the call's own membership is what turns those into names. Anyone the membership + // cannot name is still counted and still holds their place; they are just described by what is known. + const { raisedHands, remoteParticipants, streams, sessionState, onMuteParticipant, onToggleScreenSharing } = useMediaCallView(); + const diagnostics = useCallDiagnosticsContext(); + const handQueue = useMemo( + () => + (raisedHands ?? []).map(({ id }) => { + const member = call.members.find(({ _id }) => _id === id); + return { id, name: member?.name || member?.username || t('User') }; + }), + [raisedHands, call.members, t], + ); + const raisedHandIds = useMemo(() => new Set(handQueue.map(({ id }) => id)), [handQueue]); + + // Whose microphone is already off, so nobody is asked for silence they are already keeping. The call is what + // knows this — a member entry records who is in the call, not what their microphone is doing. The reader's own + // mic comes from the session, since they are not one of the *remote* participants. + const mutedMembers = useMemo(() => { + const ids = new Set((remoteParticipants ?? []).filter(({ muted }) => muted).map(({ id }) => id)); + if (sessionState?.muted && user?._id) { + ids.add(user._id); + } + return ids; + }, [remoteParticipants, sessionState?.muted, user?._id]); + + // The same list, as microphones, so a row can show one moving. Again the reader's own comes from the session. + const audioStreams = useMemo(() => { + const streamsById = new Map((remoteParticipants ?? []).map(({ id, audioStream }) => [id, audioStream])); + if (user?._id) { + streamsById.set(user._id, streams?.localMicrophone?.stream); + } + return streamsById; + }, [remoteParticipants, streams?.localMicrophone, user?._id]); + + const presenters = useMemo((): Presenter[] => { + const list: Presenter[] = []; + if (streams?.localScreen?.active) { + list.push({ name: selfDisplayName || '', avatarUrl: self.avatarUrl, isLocal: true }); + } + for (const p of remoteParticipants ?? []) { + if (p.screenStream) { + list.push({ name: p.displayName, avatarUrl: p.avatarUrl }); + } + } + return list; + }, [streams?.localScreen?.active, remoteParticipants, selfDisplayName, self.avatarUrl]); + + const [bannerDismissed, setBannerDismissed] = useState(false); + + const [activePanel, setActivePanel] = useState(); + const togglePanel = useCallback((panel: ConferencePanel) => setActivePanel((current) => (current === panel ? undefined : panel)), []); + const chatVisible = activePanel === 'chat'; + + // On narrow viewports the panel floats over the call instead of squeezing it. + const breakpoints = useBreakpoints(); + const overlayPanel = !breakpoints.includes('md'); + + // Owned by the page rather than the chat panel: the badge below needs it while that panel is closed, and the + // panel isn't mounted then. + useConferenceSubscription(room.rid); + + // The same rules the sidebar's room item uses, so a mention reads as urgent in both places and a room the + // user muted stays quiet in both. Nothing to show while the chat is the panel they are looking at. + const subscription = useUserSubscription(room.rid ?? ''); + const { showUnread, unreadCount, unreadVariant, unreadTitle } = useUnreadDisplay(subscription ?? emptyUnreadData); + const unread = !chatVisible && showUnread ? unreadCount.total : 0; + + // In channels and groups, `unread` only increments on mentions — regular messages just set `alert` (bold). + // Show a dot badge when the room has unseen activity but no counted unreads, so the chat button still + // signals new messages while the panel is closed. + const hasUnseenActivity = !chatVisible && !unread && Boolean(subscription?.alert); + + // Who is actually in the call — the faces worth glancing at, and how many there are altogether. + const present = useMemo(() => call.members.filter(isInVideoConference), [call.members]); + const presentCount = present.length; + + // A DM caller should hear a ringback tone while the other side's phone is still ringing. + // Not memoized: `isRingingVideoConferenceMember` is time-dependent (uses Date.now()), and the re-render + // triggered by `useRingingExpiry` must see a fresh evaluation — a useMemo whose deps are the members array + // would return its cached `true` because the array hasn't changed, only time has. + 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]); + + // Where the call puts its own controls — see `actionsContainer`. Created up front rather than captured from + // a ref, so it is non-null on the very first render: a ref would still be empty then, and the call would + // build a whole strip of its own before being told not to. + const controlsHost = useMemo(() => document.createElement('div'), []); + const mountControlsHost = useCallback( + (node: HTMLElement | null) => { + node?.appendChild(controlsHost); + }, + [controlsHost], + ); + + // The same arrangement for the call's header, which goes in this window's top bar. + const headerHost = useMemo(() => { + const node = document.createElement('div'); + // Ends apart, filling the bar: the call's header is a timer on one side and its own actions on the other. + node.style.cssText = 'display:flex;flex:1;min-width:0;align-items:center;justify-content:space-between'; + return node; + }, []); + const mountHeaderHost = useCallback( + (node: HTMLElement | null) => { + node?.appendChild(headerHost); + }, + [headerHost], + ); + + const participantAvatars = useMemo(() => { + const all = [ + { id: self.id, avatarUrl: self.avatarUrl }, + ...(remoteParticipants ?? []).map(({ id, avatarUrl }) => ({ id, avatarUrl: avatarUrl || '' })), + ].filter((p) => p.avatarUrl); + return all.slice(0, 4); + }, [self.id, self.avatarUrl, remoteParticipants]); + + const membersAction = ( + togglePanel('members')} + > + + {participantAvatars.map((p, i) => ( + 0 ? -6 : 0 }} + /> + ))} + + {presentCount} + + ); + + const chatAction = ( + togglePanel('chat')} + > + + {unread > 0 && ( + + + {unread} + + + )} + {unread === 0 && hasUnseenActivity && ( + + + + )} + + ); + + const extraMenuItems: GenericMenuItemProps[] = [ + { id: 'diagnostics', icon: 'info-circled', content: t('Connection_info'), onClick: () => togglePanel('diagnostics') }, + ]; + + // An iframe provider has no header of ours to put anything in, so both stay on the bar. + const embedded = !conference.url; + + // No access to the conference's room — show the unauthorized screen for the whole page rather than a + // broken split with a "not found" chat panel. + if (room.error) { + return ; + } + + if (conference.error) { + return ; + } + + if (conference.loading) { + return ; + } + + // The conference existed when the info query ran, but it may have ended since — or before the user opened + // this window. Show a clear "call ended" page instead of a preflight that would fail on join. + if (call.ended && !conference.joined) { + return ; + } + + // Not in the call yet: the user says how they want to arrive, and joining is what turns that into the + // provider's URL. Waiting for the conference to load first means the name and the devices on offer are the + // real ones. + // + // An embedded provider never produces a url — the join itself is what puts the user in the call — so for + // those it is having joined, not having a url, that says the preflight is done. + if (!conference.joined) { + if (room.loading) { + return ; + } + + return ( + conference.join({ state: preferences, name })} + onCancel={leaveNow} + /> + ); + } + + return ( + + {/* Above the call and both panels: the situation is about the call, not about whichever panel happens + to be open, and a banner that moved as panels changed would read as a different message each time. + Dismissing hides the banner, but the chat header keeps a persistent button for the same action. */} + {room.chatAccess && !bannerDismissed && ( + setBannerDismissed(true)} /> + )} + + {/* Only a provider that renders in here has a header to give; an iframe keeps its chrome inside the + frame. Above the row below, so it spans the side panels the way the bottom bar does. */} + {embedded && ( + }> + {/* Before the button rather than after it, so the queue reads as something about the people it + opens — and so it grows leftwards into the bar's own space instead of pushing the button. */} + + + {membersAction} + {chatAction} + + )} + + + + {/* A provider with a page of its own gets an iframe; one that runs the call in here renders it + directly, reading the connection from the bridge above this route. That one brings its own + control strip -- mic, camera, screen, hang up -- so the panel toggles join it there rather + than sitting in a second bar beneath it. */} + {conference.url ? ( + + ) : ( + togglePanel('chat')} + user={self} + hideChatToggle + actionsContainer={controlsHost} + headerContainer={headerHost} + callName={call.name} + extraMenuItems={extraMenuItems} + /> + )} + + + {/* One panel at a time: they share the same space, and two side panels would leave the call a + sliver. Which one is open is the single source of truth, so the bar can't disagree with it. */} + + {activePanel === 'members' && ( + togglePanel('members')} + /> + )} + {activePanel === 'chat' && ( + togglePanel('chat')} + /> + )} + {activePanel === 'diagnostics' && togglePanel('diagnostics')} />} + + + + }> + {!embedded && membersAction} + {!embedded && chatAction} + + + {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 +