diff --git a/.changeset/conference-window-groundwork.md b/.changeset/conference-window-groundwork.md new file mode 100644 index 0000000000000..9ac0f6b316fa4 --- /dev/null +++ b/.changeset/conference-window-groundwork.md @@ -0,0 +1,15 @@ +--- +'@rocket.chat/fuselage-ui-kit': patch +'@rocket.chat/mock-providers': patch +'@rocket.chat/desktop-api': patch +'@rocket.chat/ui-client': patch +'@rocket.chat/ui-kit': patch +'@rocket.chat/i18n': patch +'@rocket.chat/meteor': patch +--- + +Groundwork for the video conference window: no user-facing change. + +Adds the window's views and hooks, an ongoing-calls list, and the small shared-package additions they need. None of it is mounted into the application — no item appears in the navigation bar, the room header, the room list or the message list, no existing screen renders anything new, and no new request, poll or subscription starts. The window is reachable only by visiting a `/conference/...` URL directly, a route that already existed and until now rendered a placeholder. The feature that mounts all of this arrives separately, behind a setting that is off by default. + +Three shared behaviours changed along the way: `UserAction.addStream` reference-counts its subscribers instead of throwing when a room is already streaming, so the same room can be mounted twice; `AuthenticationCheck` and `UsernameCheck` accept a `loading` placeholder so a standalone route need not flash the app-shaped skeleton; and `GenericMenuItem` accepts a `textValue` so a menu item with rendered content is still announced and matched by typeahead. diff --git a/apps/meteor/.storybook/decorators.tsx b/apps/meteor/.storybook/decorators.tsx index 75d24df1195a4..1f51244c27de9 100644 --- a/apps/meteor/.storybook/decorators.tsx +++ b/apps/meteor/.storybook/decorators.tsx @@ -1,22 +1,34 @@ import { mockAppRoot } from '@rocket.chat/mock-providers'; import type { Decorator } from '@storybook/react'; +import { I18nextProvider } from 'react-i18next'; import ModalContextMock from '../client/stories/contexts/ModalContextMock'; import RouterContextMock from '../client/stories/contexts/RouterContextMock'; import ServerContextMock from '../client/stories/contexts/ServerContextMock'; import TranslationContextMock from '../client/stories/contexts/TranslationContextMock'; +import { storybookI18n } from '../client/stories/i18n'; const MockedAppRoot = mockAppRoot().build(); +/** + * Puts the real English copy in front of `mockAppRoot`'s empty i18next instance. + * + * Innermost on purpose: the nearest `I18nextProvider` is the one a component reads, and `mockAppRoot` installs + * one of its own. A story that builds its own `mockAppRoot` nests yet another provider inside this, so such + * stories have to apply the same instance themselves — `withCallProviders` in the conference stories is an + * example of doing that. + */ export const RocketChatDecorator: Decorator = (Story, { parameters }) => ( -
- -
+ +
+ +
+
diff --git a/apps/meteor/.storybook/main.ts b/apps/meteor/.storybook/main.ts index 1cbaa4b13cdae..ba1e3fe22f175 100644 --- a/apps/meteor/.storybook/main.ts +++ b/apps/meteor/.storybook/main.ts @@ -27,6 +27,14 @@ export default baseConfig({ 'swiper/swiper.css$': join(swiperRoot, 'swiper.css'), 'swiper/modules/zoom.css$': join(swiperRoot, 'modules/zoom.css'), }, + // Meteor's bundler shims Node's built-ins; webpack 5 stopped doing that. A page that reaches the + // message composer pulls in mime-type/micromatch, which want `path` and `process` — and without these + // the story renders "process is not defined" instead of the component. + fallback: { + ...config.resolve?.fallback, + path: require.resolve('path-browserify'), + process: require.resolve('process/browser'), + }, // This is only needed because of Rocket.Chat's icon font. roots: [...(config.resolve?.roots ?? []), resolve(__dirname, '../../../apps/meteor/public')], }; @@ -40,6 +48,9 @@ export default baseConfig({ } config.plugins?.push( + // `process.env` is read at module scope by some of those Node-oriented dependencies, so the shim has to + // be injected rather than merely resolvable. + new webpack.ProvidePlugin({ process: require.resolve('process/browser') }), new webpack.NormalModuleReplacementPlugin(/^meteor/, require.resolve('./mocks/meteor.ts')), new webpack.NormalModuleReplacementPlugin(/(app)\/*.*\/(server)\/*/, require.resolve('./mocks/empty.ts')), new webpack.NormalModuleReplacementPlugin(/rocketchat\.info$/, require.resolve('./mocks/rocketchat.info.ts')), diff --git a/apps/meteor/client/components/AppLayoutThemeWrapper.spec.tsx b/apps/meteor/client/components/AppLayoutThemeWrapper.spec.tsx new file mode 100644 index 0000000000000..ef17cd7932103 --- /dev/null +++ b/apps/meteor/client/components/AppLayoutThemeWrapper.spec.tsx @@ -0,0 +1,47 @@ +import { PaletteStyleTag } from '@rocket.chat/fuselage'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render } from '@testing-library/react'; + +import AppLayoutThemeWrapper from './AppLayoutThemeWrapper'; + +const APP_TAG_ID = 'app-layout-palette'; +const REFERENCE_TAG_ID = 'reference-palette'; + +const cssOf = (tagId: string) => document.getElementById(tagId)?.textContent; + +/** The palette Fuselage itself emits for a theme, so the assertions below aren't a copy of its colour values. */ +const paletteFor = (theme: 'light' | 'dark' | 'high-contrast') => { + const { unmount } = render(); + const css = cssOf(REFERENCE_TAG_ID); + unmount(); + return css; +}; + +const renderWrapper = (themeAppearence: string, theme?: 'dark') => + render({null}, { + wrapper: mockAppRoot().withUserPreference('themeAppearence', themeAppearence).build(), + }); + +describe('AppLayoutThemeWrapper', () => { + it('pins the palette it is given, whatever the reader prefers', () => { + renderWrapper('light', 'dark'); + + expect(cssOf(APP_TAG_ID)).toBe(paletteFor('dark')); + }); + + // The conference window pins dark because a call surface is dark. High contrast is not a look but a + // legibility need, so it is the one preference the pin gives way to. + it('gives way to high contrast', () => { + renderWrapper('high-contrast', 'dark'); + + expect(cssOf(APP_TAG_ID)).toBe(paletteFor('high-contrast')); + }); + + // Guards every other route from the change above: left unpinned this still follows the system's dark mode — + // jsdom reports light — rather than the appearance preference, which is what the app's content palette reads. + it('follows the system for a layout that pins nothing', () => { + renderWrapper('dark'); + + expect(cssOf(APP_TAG_ID)).toBe(paletteFor('light')); + }); +}); diff --git a/apps/meteor/client/components/AppLayoutThemeWrapper.tsx b/apps/meteor/client/components/AppLayoutThemeWrapper.tsx index e91f7712f3171..eccd8fb120ee9 100644 --- a/apps/meteor/client/components/AppLayoutThemeWrapper.tsx +++ b/apps/meteor/client/components/AppLayoutThemeWrapper.tsx @@ -1,14 +1,51 @@ import { PaletteStyleTag } from '@rocket.chat/fuselage'; +import type { PaletteStyleTagProps } from '@rocket.chat/fuselage'; import { useDarkMode } from '@rocket.chat/fuselage-hooks'; +import { useThemeMode } from '@rocket.chat/ui-client'; import type { ReactNode } from 'react'; -export type AppLayoutThemeWrapperProps = { children: ReactNode }; +type PinnedTheme = PaletteStyleTagProps['theme']; -const AppLayoutThemeWrapper = ({ children }: AppLayoutThemeWrapperProps) => { +export type AppLayoutThemeWrapperProps = { + children: ReactNode; + /** + * Pins the palette instead of following the reader's appearance preference — for a layout whose look is part + * of what it is rather than a matter of taste. The conference window is the one that pins: a call surface is + * dark in every product that has one, and light controls over a black video tile read as a bug rather than + * as a light theme. + * + * High contrast is the exception a pin never overrides. Unlike light and dark it answers a legibility need, + * so it outranks whatever look a layout wants. + */ + theme?: PinnedTheme; +}; + +/** + * Left unpinned this is exactly the light/dark the tag has always emitted, so every other route's palette is + * untouched: only a pinned layout consults the appearance preference at all, and only to let high contrast + * through. + */ +const resolveTheme = (pinned: PinnedTheme, mode: ReturnType, dark: boolean): PinnedTheme => { + if (!pinned) { + return dark ? 'dark' : 'light'; + } + + if (mode === 'high-contrast') { + return 'high-contrast'; + } + + return pinned; +}; + +const AppLayoutThemeWrapper = ({ children, theme: pinned }: AppLayoutThemeWrapperProps) => { const dark = useDarkMode(); + const mode = useThemeMode(); + + const theme = resolveTheme(pinned, mode, dark); + return ( <> - + {children} ); 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.stories.tsx b/apps/meteor/client/components/CallParticipants.stories.tsx new file mode 100644 index 0000000000000..572908d21522e --- /dev/null +++ b/apps/meteor/client/components/CallParticipants.stories.tsx @@ -0,0 +1,73 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { Meta, StoryObj } from '@storybook/react'; + +import CallParticipants from './CallParticipants'; +import { conferenceAppRoot, withCallProviders } from '../views/conference/storyFixtures'; + +/** + * Who is already in a call: their faces, then how many more there are. + * + * Faces answer *who* is in there, which is usually what decides whether to walk in — so the interesting states + * are the ones where there are no faces to show. + */ +const meta = { + component: CallParticipants, + parameters: { layout: 'centered' }, + decorators: [ + (Story) => ( + + + + ), + withCallProviders(conferenceAppRoot()), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const people = [ + { _id: 'ada', username: 'ada' }, + { _id: 'grace', username: 'grace' }, + { _id: 'alan', username: 'alan' }, +]; + +/** Everyone in the call has a face, so it ends with a plain "joined" rather than a count. */ +export const AllShown: Story = { + args: { people, total: 3 }, +}; + +/** More in the call than faces shown: the rest become "+ N joined". */ +export const WithOverflow: Story = { + args: { people, total: 12 }, +}; + +/** + * One person. A blank circle keeps its place, so a single face doesn't read as a lone dot against the text. + */ +export const SinglePerson: Story = { + args: { people: [people[0]], total: 1 }, +}; + +/** The larger size, for a full screen rather than a sidebar row. */ +export const LargerAvatars: Story = { + args: { people, total: 7, size: 'x24' }, +}; + +/** + * Avatars turned off in preferences. There is nobody to show, so it falls back to the count in words — the way + * the call's own message block says it. + */ +export const AvatarsDisabled: Story = { + args: { people, total: 7 }, + decorators: [withCallProviders(conferenceAppRoot().withUserPreference('displayAvatars', false))], +}; + +/** + * A call whose members didn't travel with it — an older server. Nobody has a username, so nobody can be drawn: + * they are counted, not drawn as empty circles. + */ +export const NoUsernames: Story = { + args: { people: [{ _id: 'ada' }, { _id: 'grace' }], total: 4 }, +}; diff --git a/apps/meteor/client/components/CallParticipants.tsx b/apps/meteor/client/components/CallParticipants.tsx new file mode 100644 index 0000000000000..06a82cd54ff9a --- /dev/null +++ b/apps/meteor/client/components/CallParticipants.tsx @@ -0,0 +1,81 @@ +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(); + // Defaulted, because the preference is `true` for everyone who hasn't said otherwise and it arrives with the + // user rather than with the render. Read bare, the first paint of a call would take `undefined` for "avatars + // off" and flash the count in words before the faces replace it. + const displayAvatars = useUserPreference('displayAvatars', true); + + // 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 }); + + // `UserAvatar` renders nothing without a username, so someone who arrived without one would take a place in + // the row and leave it empty — a gap and a drop shadow around nothing. They are counted, not drawn. + const faces = people.filter(({ username }) => !!username); + + // Faces switched off, or a call whose members didn't travel with it — an older server, say. + if (!displayAvatars || !faces.length) { + return ( + + {t('__usersCount__joined', { count: total })} + + ); + } + + const remaining = total - faces.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. */} + + {faces.map(({ _id, username }) => ( + + + + ))} + {faces.length === 1 && + + {remaining > 0 ? t('plus__usersCount__joined', { count: remaining }) : t('joined')} + + + ); +}; + +export default CallParticipants; diff --git a/apps/meteor/client/components/OngoingCalls/CallListItem.spec.tsx b/apps/meteor/client/components/OngoingCalls/CallListItem.spec.tsx new file mode 100644 index 0000000000000..a6a65c11299e7 --- /dev/null +++ b/apps/meteor/client/components/OngoingCalls/CallListItem.spec.tsx @@ -0,0 +1,190 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import CallListItem from './CallListItem'; +import { buildJoinableCall } from '../../views/conference/testFixtures'; + +const onJoin = jest.fn(); +const onDecline = 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 = (call: Parameters[0]['call'], { silenced = false } = {}) => + render(, { + wrapper: mockAppRoot() + .withJohnDoe() + .withUserPreference('displayAvatars', true) + // The row's claim is *how many* people are in the call, and the raw key carries no count at all. + .withTranslations('en', 'core', { + __count__people_joined_one: '{{count}} person joined', + __count__people_joined_other: '{{count}} people joined', + }) + .build(), + }); + +const ongoingCall = (name = 'Standup') => buildJoinableCall({ callId: 'call-1', name }); +const ringingCall = () => buildJoinableCall({ callId: 'ringing', name: 'Alice', ringingAt: new Date() }); + +beforeEach(() => { + onJoin.mockClear(); + onDecline.mockClear(); + onSilence.mockClear(); + incomingCalls = [{ callId: 'ringing', dismissed: false }]; +}); + +describe('any call in the list', () => { + it('says what the call is and how many people are in it', () => { + renderItem(ongoingCall('Sprint planning')); + + expect(screen.getByText('Sprint planning')).toBeInTheDocument(); + // `buildJoinableCall` puts two people in it. + expect(screen.getByText('2 people joined')).toBeInTheDocument(); + }); + + // Answering or rejoining is what the row is for, so it is a link to where it goes — which is also the only + // thing that gives it a role, a name and a place in the tab order. + it('is a link to the call, named after it', () => { + renderItem(ongoingCall('Sprint planning')); + + const row = screen.getByRole('link', { name: /Sprint planning/ }); + + expect(row).toHaveAttribute('href', '/conference/call-1'); + }); + + it('is the room item with no avatar', () => { + const { container } = renderItem(ongoingCall()); + + expect(container.querySelector('.rcx-sidebar-item')).not.toBeNull(); + expect(container.querySelector('.rcx-sidebar-item__timestamp')).not.toBeNull(); + expect(container.querySelector('.rcx-sidebar-item__avatar')).toBeNull(); + }); + + it('opens the call when the row is clicked', async () => { + const { container } = renderItem(ongoingCall()); + const row = container.querySelector('.rcx-sidebar-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(); + }); +}); + +describe('a call that is merely running', () => { + it('turns the call down without opening it', async () => { + renderItem(ongoingCall()); + + await userEvent.click(screen.getByRole('button', { name: 'Decline' })); + + expect(onDecline).toHaveBeenCalledWith('call-1'); + expect(onJoin).not.toHaveBeenCalled(); + }); + + it('says nothing about ringing, and offers nothing to silence', () => { + renderItem(ongoingCall()); + + expect(screen.queryByText(/Ringing/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Silence' })).not.toBeInTheDocument(); + }); + + // The ring window outlives the answer: declining a second into a 15s ring leaves `ringingAt` live, and the + // payload carries no `declinedAt` for the window check to compare it against. The answer is what counts. + it('stops reading as ringing the moment it is declined, window or no window', () => { + renderItem(buildJoinableCall({ callId: 'refused', name: 'Design review', declined: true, ringingAt: new Date() })); + + expect(screen.queryByText(/Ringing/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Silence' })).not.toBeInTheDocument(); + expect(screen.getByText('(Declined)')).toBeInTheDocument(); + }); + + // Same rule from the other side: answering by joining ends it too. + it('stops reading as ringing once joined', () => { + renderItem(buildJoinableCall({ callId: 'answered', name: 'Standup', joined: true, ringingAt: new Date() })); + + expect(screen.queryByText(/Ringing/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Silence' })).not.toBeInTheDocument(); + }); + + // There is nothing left to decline, so the button's place says what happened instead. + it('shows that a declined call was declined, with no button', () => { + renderItem(buildJoinableCall({ callId: 'refused', name: 'Design review', declined: true })); + + expect(screen.getByText('(Declined)')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Decline' })).not.toBeInTheDocument(); + }); + + // Leaving a call is not something a list row does. + it('offers no decline for a call this user is already in', () => { + renderItem(buildJoinableCall({ callId: 'joined', name: 'Pairing session', joined: true })); + + expect(screen.queryByRole('button', { name: 'Decline' })).not.toBeInTheDocument(); + expect(screen.queryByText('(Declined)')).not.toBeInTheDocument(); + }); +}); + +describe('a call that is ringing', () => { + it('accepts when the row is clicked', async () => { + renderItem(ringingCall()); + + await userEvent.click(screen.getByText('Alice')); + + expect(onJoin).toHaveBeenCalledWith('ringing'); + }); + + it('says it is ringing where the time would be', () => { + renderItem(ringingCall()); + + expect(screen.getByText(/Ringing/)).toBeInTheDocument(); + }); + + it('offers both silence and decline while it is still sounding', async () => { + renderItem(ringingCall()); + + 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(onJoin).not.toHaveBeenCalled(); + expect(onDecline).not.toHaveBeenCalled(); + }); + + it('keeps both buttons at the end of the row', () => { + const { container } = renderItem(ringingCall()); + const buttons = [...container.querySelectorAll('button')].map((button) => button.getAttribute('aria-label')); + + expect(buttons).toEqual(['Silence', 'Decline']); + }); + + it('replaces silence with a silenced icon once muted, keeping decline', async () => { + renderItem(ringingCall(), { silenced: 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(onDecline).toHaveBeenCalledWith('ringing'); + }); + + // A ring can be sounding on another of this user's sessions: there is nothing to silence here. + it('offers the decline straight away for a ring it never heard', () => { + incomingCalls = []; + + renderItem(ringingCall()); + + expect(screen.queryByRole('button', { name: 'Silence' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Decline' })).toBeInTheDocument(); + }); +}); diff --git a/apps/meteor/client/components/OngoingCalls/CallListItem.stories.tsx b/apps/meteor/client/components/OngoingCalls/CallListItem.stories.tsx new file mode 100644 index 0000000000000..80477dfc729b3 --- /dev/null +++ b/apps/meteor/client/components/OngoingCalls/CallListItem.stories.tsx @@ -0,0 +1,95 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from 'storybook/actions'; + +import CallListItem from './CallListItem'; +import { conferenceAppRoot, withCallProviders } from '../../views/conference/storyFixtures'; +import { buildJoinableCall } from '../../views/conference/testFixtures'; + +/** + * One call in the list: the sidebar's room item with no avatar, a video mark instead, and how many people are + * in it underneath. What sits at its edges — "Ringing…" where the time goes, and the buttons at the end — is + * read off the call, so these stories differ in the call rather than in what is passed alongside it. + * + * The exception is whether a ring can be silenced, which is not the call's to say: the row asks whether *this* + * client is the one making the noise, so those stories differ in what the video-conf context reports. + */ +const meta = { + component: CallListItem, + parameters: { layout: 'centered' }, + args: { + call: buildJoinableCall({ callId: 'standup', name: 'Daily standup', usersCount: 4 }), + onJoin: action('onJoin'), + onDecline: action('onDecline'), + onSilence: action('onSilence'), + }, + decorators: [ + (Story) => ( + + + + ), + withCallProviders(conferenceAppRoot().withIncomingCalls([{ callId: 'ringing', dismissed: false }] as any)), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** A call this user has been asked about and not answered: joinable, and turn-downable. */ +export const Joinable: Story = {}; + +/** + * A call turned down. It keeps its place in the list as the way back in, and says "(Declined)" where the + * Decline button was — there is nothing left to decline. + */ +export const Declined: Story = { + args: { call: buildJoinableCall({ callId: 'refused', name: 'Design review', declined: true, usersCount: 3 }) }, +}; + +/** + * A call this user is already in. No decline offered — leaving a call is not something a list row does. + */ +export const Joined: Story = { + args: { call: buildJoinableCall({ callId: 'joined', name: 'Pairing session', joined: true, usersCount: 2 }) }, +}; + +/** One person in it, which is the singular the count has to get right. */ +export const SinglePerson: Story = { + args: { + call: buildJoinableCall({ + callId: 'alone', + name: 'Ada Lovelace', + usersCount: 1, + participants: [{ _id: 'ada', username: 'ada', name: 'Ada Lovelace' }], + }), + }, +}; + +const ringing = buildJoinableCall({ + callId: 'ringing', + name: 'Ada Lovelace', + ringingAt: new Date(), + usersCount: 1, + participants: [{ _id: 'ada', username: 'ada', name: 'Ada Lovelace' }], +}); + +/** Sounding here: Silence and Decline, and "Ringing…" in place of the time. */ +export const Ringing: Story = { + args: { call: ringing }, +}; + +/** Quietened. The Silence button becomes a struck-through bell; Decline is still the way to turn it down. */ +export const RingingSilenced: Story = { + args: { call: ringing, silenced: true }, +}; + +/** + * A ring this client never heard, so there is no noise of its own to stop — only Decline. The row still says + * it is ringing, because it is: somewhere else. + */ +export const RingingNotHeardHere: Story = { + args: { call: ringing }, + decorators: [withCallProviders(conferenceAppRoot())], +}; diff --git a/apps/meteor/client/components/OngoingCalls/CallListItem.tsx b/apps/meteor/client/components/OngoingCalls/CallListItem.tsx new file mode 100644 index 0000000000000..518e6491b04fe --- /dev/null +++ b/apps/meteor/client/components/OngoingCalls/CallListItem.tsx @@ -0,0 +1,119 @@ +import type { JoinableVideoConference } from '@rocket.chat/core-typings'; +import { isRingingVideoConferenceMember } 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 { canDeclineCall } from './useOngoingCalls'; +import Extended from '../../sidebar/Item/Extended'; + +type CallListItemProps = { + call: JoinableVideoConference; + /** Whether this client has already been asked to stop making noise about this call. */ + silenced?: boolean; + onJoin: (callId: string) => void; + onDecline: (callId: string) => void; + onSilence?: (callId: string) => void; +}; + +/** + * One call in the list, whatever state it is in: ringing, merely running, already joined, or turned down. + * + * The state is read off the call rather than chosen by the caller, because it is the same row either way — the + * sidebar's room item with a video mark instead of an avatar — and only the two slots at its edges differ. A + * list that had to pick a component per state ended up re-deriving that state to do the picking. + */ +const CallListItem = ({ call, silenced = false, onJoin, onDecline, onSilence }: CallListItemProps) => { + const { t } = useTranslation(); + + // Answering ends the ringing presentation, whichever way it was answered — the same rule the list buckets + // by, and the reason a declined call keeps its place saying so rather than appearing to ring on. + // + // The window alone can't tell: `isRingingVideoConferenceMember` suppresses a ring the member declined by + // comparing `declinedAt` against `ringingAt`, and the joinable payload carries no `declinedAt` — so a call + // declined a second into its ring would otherwise still read as ringing here for the rest of the window. + const ringing = !call.declined && !call.joined && isRingingVideoConferenceMember({ ringingAt: call.ringingAt }); + + // Whether *this* client is the one making the noise — a ring can be sounding on another of the user's + // sessions, and there is nothing to silence here if it isn't sounding here. + const incomingCalls = useVideoConfIncomingCalls(); + const audible = ringing && !silenced && incomingCalls.some(({ callId, dismissed }) => callId === call.callId && !dismissed); + + const decline = ( + onDecline(call.callId)} /> + ); + + const actions = (() => { + if (ringing) { + return ( + <> + {silenced && } + {audible && onSilence && ( + onSilence(call.callId)} + /> + )} + {decline} + + ); + } + + if (canDeclineCall(call)) { + return decline; + } + + // Turned down, and keeping its place in the list as the way back in: there is nothing left to decline, + // so the button's place says what happened instead. + if (call.declined) { + return ( + + ({t('Declined')}) + + ); + } + + return undefined; + })(); + + return ( + { + event.preventDefault(); + + if ((event.target as HTMLElement).closest('button')) { + return; + } + + onJoin(call.callId); + }} + icon={} + title={call.name} + time={call.createdAt} + timeLabel={ + ringing ? ( + + {t('Ringing')}… + + ) : undefined + } + subtitle={ + + {t('__count__people_joined', { count: call.usersCount })} + + } + actions={actions} + /> + ); +}; + +export default CallListItem; diff --git a/apps/meteor/client/components/OngoingCalls/OngoingCallsList.stories.tsx b/apps/meteor/client/components/OngoingCalls/OngoingCallsList.stories.tsx new file mode 100644 index 0000000000000..80e42a1e0ab5f --- /dev/null +++ b/apps/meteor/client/components/OngoingCalls/OngoingCallsList.stories.tsx @@ -0,0 +1,140 @@ +import type { JoinableVideoConference } from '@rocket.chat/core-typings'; +import { Box } from '@rocket.chat/fuselage'; +import type { Meta, StoryObj } from '@storybook/react'; +import { userEvent, within } from 'storybook/test'; + +import OngoingCallsList from './OngoingCallsList'; +import { conferenceAppRoot, withCallProviders } from '../../views/conference/storyFixtures'; +import { buildJoinableCall } from '../../views/conference/testFixtures'; + +/** + * The list drives itself from the joinable-calls endpoint, so each story is a different answer from the server + * rather than a different set of props. + */ +const withCalls = (calls: JoinableVideoConference[], incoming: { callId: string; dismissed: boolean }[] = []) => + withCallProviders( + conferenceAppRoot() + .withEndpoint( + 'GET', + '/v1/video-conference.joinable', + () => + ({ + // A ring lasts 15 seconds, so a `ringingAt` fixed when this module loaded would quietly turn every + // ringing story into an ongoing one while somebody looked at it. Stamped per request instead, and + // the list refetches, so a ringing story stays ringing. + calls: calls.map((call) => (call.ringingAt ? { ...call, ringingAt: new Date() } : call)), + success: true, + }) as any, + ) + .withEndpoint('POST', '/v1/video-conference.decline', () => ({ success: true }) as any) + // What is audibly ringing *here*. A ring the client never heard gets no Silence button, because there + // is nothing to silence. + .withIncomingCalls(incoming as any), + ); + +const ringing = buildJoinableCall({ + callId: 'ringing', + name: 'Ada Lovelace', + ringingAt: new Date(), + usersCount: 1, + participants: [{ _id: 'ada', username: 'ada', name: 'Ada Lovelace' }], +}); + +const ongoing = buildJoinableCall({ callId: 'standup', name: 'Daily standup', usersCount: 4 }); + +const meta = { + component: OngoingCallsList, + parameters: { layout: 'centered' }, + // The list is only ever seen inside the navbar dropdown, which is what gives it its width and its surface. + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** A call ringing this user right now: "Ringing…" where the time would be, and both Silence and Decline. */ +export const Ringing: Story = { + decorators: [withCalls([ringing], [{ callId: 'ringing', dismissed: false }])], +}; + +/** + * The same ring after Silence is clicked: the button gives way to a struck-through bell and Decline stays. + * + * Reached by clicking rather than set up, because being silenced is this list's own state — the ring is + * quietened for the session, not answered. + */ +export const RingingSilenced: Story = { + decorators: [withCalls([ringing], [{ callId: 'ringing', dismissed: false }])], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click(await canvas.findByRole('button', { name: 'Silence' })); + }, +}; + +/** + * A ring this client never heard — discovered by polling rather than announced to it. Nothing to silence, so + * only Decline is offered. + */ +export const RingingNotHeardHere: Story = { + decorators: [withCalls([ringing])], +}; + +/** A call simply running. No ring to answer, so the only action is to turn it down. */ +export const Ongoing: Story = { + decorators: [withCalls([ongoing])], +}; + +/** Ringing first, then the ones merely running. */ +export const Several: Story = { + decorators: [ + withCalls( + [ + ringing, + ongoing, + buildJoinableCall({ callId: 'design', name: 'Design review', createdAt: new Date('2026-08-03T09:30:00.000Z'), usersCount: 2 }), + buildJoinableCall({ callId: 'joined', name: 'Pairing session', joined: true, usersCount: 2 }), + ], + [{ callId: 'ringing', dismissed: false }], + ), + ], +}; + +/** + * A declined call is kept, below a divider — the way back into a call this user turned down. It carries + * "(declined)" instead of a Decline button, because there is nothing left to decline. + */ +export const WithDeclinedCall: Story = { + decorators: [withCalls([ongoing, buildJoinableCall({ callId: 'refused', name: 'Design review', declined: true, usersCount: 3 })])], +}; + +/** + * More calls than the list shows at once. Collapsed to five with a "Show all" that counts what is hidden; + * clicking it expands and turns into "Show fewer". + */ +export const Collapsed: Story = { + decorators: [ + withCalls( + Array.from({ length: 7 }, (_, index) => + buildJoinableCall({ + callId: `call-${index}`, + name: `Call number ${index + 1}`, + createdAt: new Date(Date.now() - index * 60_000), + usersCount: index + 1, + }), + ), + ), + ], +}; + +/** Nothing to join. The list renders empty — it is the navbar item that decides to disappear. */ +export const Empty: Story = { + decorators: [withCalls([])], +}; diff --git a/apps/meteor/client/components/OngoingCalls/OngoingCallsList.tsx b/apps/meteor/client/components/OngoingCalls/OngoingCallsList.tsx new file mode 100644 index 0000000000000..57d85f043e777 --- /dev/null +++ b/apps/meteor/client/components/OngoingCalls/OngoingCallsList.tsx @@ -0,0 +1,58 @@ +import { Box, Button, Divider } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +import CallListItem from './CallListItem'; +import { 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) => ( + + ))} + + {visibleDeclined.length > 0 && ( + <> + {visibleActive.length > 0 && } + {visibleDeclined.map((item) => ( + + ))} + + )} + + {(hasMore || showAll) && ( + + + + )} + + ); +}; + +export default OngoingCallsList; 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..2ab5c6e9021fa --- /dev/null +++ b/apps/meteor/client/components/OngoingCalls/useOngoingCalls.ts @@ -0,0 +1,60 @@ +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, useState } from 'react'; + +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 } = useJoinableCalls(); + + // 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/definitions/global.d.ts b/apps/meteor/client/definitions/global.d.ts index 16442d1aabfaf..c713df5a4caf6 100644 --- a/apps/meteor/client/definitions/global.d.ts +++ b/apps/meteor/client/definitions/global.d.ts @@ -1,8 +1,9 @@ -import type { IRocketChatDesktop } from '@rocket.chat/desktop-api'; +import type { IRocketChatDesktop, IVideoCallWindow } from '@rocket.chat/desktop-api'; declare global { interface Window { RocketChatDesktop?: IRocketChatDesktop; + videoCallWindow?: IVideoCallWindow; opera?: string; } @@ -42,5 +43,7 @@ declare global { interface NotificationEventMap { reply: { response: string }; + /** Fired by the desktop app when one of a notification's action buttons is pressed. */ + action: { action: string }; } } diff --git a/apps/meteor/client/hooks/notification/useNotification.ts b/apps/meteor/client/hooks/notification/useNotification.ts index 41482bc004d33..0c5a2ffdf4ca0 100644 --- a/apps/meteor/client/hooks/notification/useNotification.ts +++ b/apps/meteor/client/hooks/notification/useNotification.ts @@ -2,6 +2,7 @@ import type { INotificationDesktop } from '@rocket.chat/core-typings'; import { useStableCallback } from '@rocket.chat/fuselage-hooks'; import { Random } from '@rocket.chat/random'; import { useRouter, useUserPreference } from '@rocket.chat/ui-contexts'; +import { useVideoConfJoinCall } from '@rocket.chat/ui-video-conf'; import { useNotificationAllowed } from './useNotificationAllowed'; import { stripTags } from '../../../lib/utils/stringUtils'; @@ -10,8 +11,9 @@ import { getUserAvatarURL } from '../../lib/getUserAvatarURL'; import { onClientMessageReceived } from '../../lib/onClientMessageReceived'; export const useNotification = () => { - const requireInteraction = useUserPreference('desktopNotificationRequireInteraction'); + const requireInteractionPreference = useUserPreference('desktopNotificationRequireInteraction'); const router = useRouter(); + const joinCall = useVideoConfJoinCall(); const notificationAllowed = useNotificationAllowed(); const notify = useStableCallback(async (notification: INotificationDesktop) => { @@ -22,6 +24,9 @@ export const useNotification = () => { return; } + // A notification can opt into staying until interacted with, on top of the user preference. + const requireInteraction = Boolean(notification.requireInteraction || requireInteractionPreference); + const { rid, name: roomName, _id: msgId } = notification.payload; if (!rid) { return; @@ -39,6 +44,7 @@ export const useNotification = () => { canReply: true, silent: true, requireInteraction, + ...(window.RocketChatDesktop && notification.actions?.length ? { actions: notification.actions } : {}), } as NotificationOptions & { canReply?: boolean; }); @@ -59,6 +65,22 @@ export const useNotification = () => { }, }), ); + + // "Join" action (desktop app): join the call the same way the ongoing-call banner does. The event fires + // for whichever button was pressed, so it has to say which — the server names this one `join` + // (`video-conference/service.ts`), and a second action added later must not silently join a call. + const { conferenceId } = notification.payload; + if (conferenceId) { + n.addEventListener('action', (event) => { + if (event.action !== 'join') { + return; + } + + n.close(); + window.focus(); + joinCall(conferenceId); + }); + } } n.onclick = () => { diff --git a/apps/meteor/client/hooks/useRingingExpiry.ts b/apps/meteor/client/hooks/useRingingExpiry.ts new file mode 100644 index 0000000000000..01c8d51927024 --- /dev/null +++ b/apps/meteor/client/hooks/useRingingExpiry.ts @@ -0,0 +1,37 @@ +import { VIDEO_CONF_RINGING_WINDOW_MS } from '@rocket.chat/core-typings'; +import { useEffect, useState } from 'react'; + +/** + * Re-renders when the earliest of these rings stops being a ring. + * + * A ring lapses on its own — nothing announces it, because nothing happened — so anything that reads "is this + * ringing?" would keep saying yes until something unrelated moved. Both readers of that question need this: the + * list, to let a ringing call settle into an ordinary one, and a member's row, to offer to ring them again. + * + * @param ringingAt when each ring started; anything absent is ignored. + */ +export const useRingingExpiry = (ringingAt: (Date | undefined)[]): void => { + const [, setElapsed] = useState(0); + + // The moments are what matter, not the array identity — a fresh array of the same rings must not restart the + // timer, and callers build these lists inline. + const earliest = ringingAt.reduce((soonest, at) => { + if (!at) { + return soonest; + } + + const stopsAt = at.getTime() + VIDEO_CONF_RINGING_WINDOW_MS; + return soonest === undefined || stopsAt < soonest ? stopsAt : soonest; + }, undefined); + + useEffect(() => { + if (earliest === undefined) { + return; + } + + // A little past the window, so the wake-up lands on the far side of it rather than exactly on the edge. + const timer = setTimeout(() => setElapsed((tick) => tick + 1), Math.max(earliest - Date.now(), 0) + 100); + + return () => clearTimeout(timer); + }, [earliest]); +}; diff --git a/apps/meteor/client/lib/UserAction.ts b/apps/meteor/client/lib/UserAction.ts index 327fd76ef45ac..883fd1eb30641 100644 --- a/apps/meteor/client/lib/UserAction.ts +++ b/apps/meteor/client/lib/UserAction.ts @@ -23,7 +23,13 @@ const activityTimeouts = new Map(); const activityRenews = new Map(); const continuingIntervals = new Map(); const roomActivities = new Map>(); -const rooms = new Map void>(); +type RoomActivityStream = { + handler: (username: string, activityType: string[], extras?: object) => void; + stop: () => void; + refs: number; +}; + +const rooms = new Map(); const performingUsers = new Map(); const performingUsersEmitter = new Emitter<{ changed: void }>(); @@ -67,10 +73,35 @@ function handleStreamAction(rid: string, username: string, activityTypes: string performingUsers.set(rid, roomActivities); performingUsersEmitter.emit('changed'); } +/** + * The disposer for one `addStream` call. It releases that call's reference and no more, however many times it is + * invoked: an effect cleanup can run twice (StrictMode's double invoke, a cleanup racing a re-mount), and a second + * decrement would take `refs` below the number of mounts still holding the stream — closing it under them, and then + * never closing it at all once the count can no longer come back to zero. + */ +const releaseRoomStream = (rid: string, entry: RoomActivityStream): (() => void) => { + let released = false; + + return () => { + if (released) { + return; + } + released = true; + + entry.refs--; + if (entry.refs === 0) { + entry.stop(); + rooms.delete(rid); + } + }; +}; + export const UserAction = new (class { addStream(rid: string): () => void { - if (rooms.get(rid)) { - throw new Error('UserAction - addStream should only be called once per room'); + const existing = rooms.get(rid); + if (existing) { + existing.refs++; + return releaseRoomStream(rid, existing); } const handler = function (username: string, activityType: string[], extras?: object): void { @@ -82,16 +113,12 @@ export const UserAction = new (class { } handleStreamAction(rid, username, activityType, extras); }; - rooms.set(rid, handler); const { stop } = sdk.stream('notify-room', [`${rid}/${USER_ACTIVITY}`], handler); - return () => { - if (!rooms.get(rid)) { - return; - } - stop(); - rooms.delete(rid); - }; + const entry: RoomActivityStream = { handler, stop, refs: 1 }; + rooms.set(rid, entry); + + return releaseRoomStream(rid, entry); } performContinuously(rid: string, activityType: string, extras: IExtras = {}): void { diff --git a/apps/meteor/client/lib/appLayout.tsx b/apps/meteor/client/lib/appLayout.tsx index 0f2fc6920b729..3f373bc79c5c5 100644 --- a/apps/meteor/client/lib/appLayout.tsx +++ b/apps/meteor/client/lib/appLayout.tsx @@ -1,5 +1,5 @@ import { Emitter } from '@rocket.chat/emitter'; -import type { ReactNode } from 'react'; +import type { ComponentProps, ReactNode } from 'react'; import { lazy } from 'react'; const ConnectionStatusBar = lazy(() => import('../components/connectionStatus/ConnectionStatusBar')); @@ -9,6 +9,8 @@ const ActionManagerBusyState = lazy(() => import('../components/ActionManagerBus const AppLayoutThemeWrapper = lazy(() => import('../components/AppLayoutThemeWrapper')); const CloudAnnouncementsRegion = lazy(() => import('../views/cloud/CloudAnnouncementsRegion')); +type AppLayoutThemeWrapperProps = ComponentProps; + class AppLayoutSubscription extends Emitter<{ update: void }> { private descriptor: ReactNode = null; @@ -25,13 +27,28 @@ class AppLayoutSubscription extends Emitter<{ update: void }> { this.setCurrentValue(element); } - wrap(element: ReactNode): ReactNode { + /** + * `standalone` is a route that is a complete UI of its own — the conference window — rather than the + * workspace with something rendered inside it. Those omit the app-level chrome, so an admin announcement or + * an E2E password prompt doesn't appear over a call. + * + * Deliberately not called `embedded`: that already means Rocket.Chat rendered inside someone else's page + * (`layout=embedded`), which is still the workspace and still wants its banners. Embedded chats reach this + * through the ordinary room route and never pass this option. + * + * `theme` pins the palette for a route whose look is part of what it is, rather than following the reader's + * appearance preference. + */ + wrap( + element: ReactNode, + { standalone = false, theme }: { standalone?: boolean; theme?: AppLayoutThemeWrapperProps['theme'] } = {}, + ): ReactNode { return ( - + - - + {!standalone && } + {!standalone && } {element} diff --git a/apps/meteor/client/lib/queryKeys.ts b/apps/meteor/client/lib/queryKeys.ts index da9979eef2da2..fb6763da8ba8a 100644 --- a/apps/meteor/client/lib/queryKeys.ts +++ b/apps/meteor/client/lib/queryKeys.ts @@ -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..c829eb17d71c1 --- /dev/null +++ b/apps/meteor/client/lib/utils/mapVideoConfFromApi.ts @@ -0,0 +1,16 @@ +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. + */ +export const mapVideoConfFromApi = (videoConf: Serialized): VideoConference => + ({ + ...videoConf, + _updatedAt: new Date(videoConf._updatedAt), + createdAt: new Date(videoConf.createdAt), + ...(videoConf.endedAt && { endedAt: new Date(videoConf.endedAt) }), + users: videoConf.users.map(mapVideoConfUserFromApi), + }) 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/NavBarItemOngoingCalls.spec.tsx b/apps/meteor/client/navbar/NavBarItemOngoingCalls.spec.tsx new file mode 100644 index 0000000000000..b9a93b112caa4 --- /dev/null +++ b/apps/meteor/client/navbar/NavBarItemOngoingCalls.spec.tsx @@ -0,0 +1,94 @@ +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(); +}); + +// The list opens into a bare box, so the calls in it were loose rows nothing could scope to. +it('opens a named region holding the calls', async () => { + renderButton([call({ callId: 'call-1', name: 'Standup' })]); + + await userEvent.click(await screen.findByRole('button', { name: /Ongoing_calls/ })); + + const list = await screen.findByRole('region', { name: 'Ongoing_calls' }); + + await waitFor(() => expect(list).toHaveTextContent('Standup')); +}); diff --git a/apps/meteor/client/navbar/NavBarItemOngoingCalls.stories.tsx b/apps/meteor/client/navbar/NavBarItemOngoingCalls.stories.tsx new file mode 100644 index 0000000000000..e5e94b0a75e75 --- /dev/null +++ b/apps/meteor/client/navbar/NavBarItemOngoingCalls.stories.tsx @@ -0,0 +1,89 @@ +import type { JoinableVideoConference } from '@rocket.chat/core-typings'; +import { Box } from '@rocket.chat/fuselage'; +import type { Meta, StoryObj } from '@storybook/react'; + +import NavBarItemOngoingCalls from './NavBarItemOngoingCalls'; +import { conferenceAppRoot, withCallProviders } from '../views/conference/storyFixtures'; +import { buildJoinableCall } from '../views/conference/testFixtures'; + +const withCalls = (calls: JoinableVideoConference[], incoming: { callId: string; dismissed: boolean }[] = []) => + withCallProviders( + conferenceAppRoot() + .withEndpoint('GET', '/v1/video-conference.joinable', () => ({ calls, success: true }) as any) + .withEndpoint('POST', '/v1/video-conference.decline', () => ({ success: true }) as any) + .withIncomingCalls(incoming as any), + ); + +/** + * The navbar's way in to the calls running now. It is the button plus the dropdown it opens, so these stories + * are about which of the two a reviewer sees — and the button's colour, which is the whole signal. + */ +const meta = { + component: NavBarItemOngoingCalls, + parameters: { layout: 'centered' }, + // Room beneath for the dropdown, which is absolutely positioned against the button. + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** + * Nothing to join, so the item renders nothing at all — no empty button in the navbar. The frame below is + * deliberately blank. + */ +export const NoCalls: Story = { + decorators: [withCalls([])], +}; + +/** One call running: the button appears in info blue, badged with how many are reachable. */ +export const OneOngoing: Story = { + decorators: [withCalls([buildJoinableCall({ callId: 'standup', name: 'Daily standup', usersCount: 4 })])], +}; + +/** + * Something ringing. The button goes danger red, and the dropdown opens itself — a ring is not something to + * make the user go looking for. + */ +export const Ringing: Story = { + decorators: [ + withCalls( + [ + buildJoinableCall({ + callId: 'ringing', + name: 'Ada Lovelace', + ringingAt: new Date(), + usersCount: 1, + participants: [{ _id: 'ada', username: 'ada', name: 'Ada Lovelace' }], + }), + ], + [{ callId: 'ringing', dismissed: false }], + ), + ], +}; + +/** Several running at once. The badge counts them; the dropdown stays shut until asked. */ +export const SeveralOngoing: Story = { + decorators: [ + withCalls([ + buildJoinableCall({ callId: 'standup', name: 'Daily standup', usersCount: 4 }), + buildJoinableCall({ callId: 'design', name: 'Design review', createdAt: new Date('2026-08-03T09:30:00.000Z'), usersCount: 2 }), + buildJoinableCall({ callId: 'pairing', name: 'Pairing session', joined: true, usersCount: 2 }), + ]), + ], +}; + +/** + * Only a declined call left. It still counts towards showing the button — the way back in — but not towards + * the badge, which is about calls actually on offer. + */ +export const OnlyDeclined: Story = { + decorators: [withCalls([buildJoinableCall({ callId: 'refused', name: 'Design review', declined: true, usersCount: 3 })])], +}; diff --git a/apps/meteor/client/navbar/NavBarItemOngoingCalls.tsx b/apps/meteor/client/navbar/NavBarItemOngoingCalls.tsx new file mode 100644 index 0000000000000..65ac59aa2361d --- /dev/null +++ b/apps/meteor/client/navbar/NavBarItemOngoingCalls.tsx @@ -0,0 +1,75 @@ +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} + // The badge sits beside the button rather than inside it, so a screen reader would otherwise announce + // the name and then a stray number. The count goes into the name and the badge is hidden from + // assistive technology, so it is said once. + aria-label={active > 0 ? t('Ongoing_calls_count', { count: active }) : name} + icon='video' + /> + {active > 0 && ( + + )} + + {isVisible && ( + + {/* Named, so the calls in it are reachable as a group rather than as loose rows in an unnamed box. */} + + + + + )} + + ); +}; + +export default NavBarItemOngoingCalls; diff --git a/apps/meteor/client/sidebar/Item/Extended.tsx b/apps/meteor/client/sidebar/Item/Extended.tsx index f585e7de2667b..6ca52dbc8dd33 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,10 @@ const Extended = ({ {icon} {title} - {time && {formatDate(time)}} + {/* `timeLabel` is a node, so an empty string or a 0 is still a caller saying "put this here" — which is + what `timeLabel ?? formatDate(time)` below already honours. Testing it for truth instead would fall + back to the timestamp for those, and render neither. */} + {(timeLabel !== undefined || time) && {timeLabel ?? formatDate(time)}} {subtitle} diff --git a/apps/meteor/client/startup/routes.tsx b/apps/meteor/client/startup/routes.tsx index d09d2a6bc5cbd..f3327ddabee10 100644 --- a/apps/meteor/client/startup/routes.tsx +++ b/apps/meteor/client/startup/routes.tsx @@ -210,7 +210,9 @@ router.defineRoutes([ { path: '/conference/:id', id: 'conference', - element: appLayout.wrap(), + // Dark whatever the reader's appearance preference says: a call surface is dark in every product that + // has one, and the panels beside it put the preference back for the room UI they carry. + element: appLayout.wrap(, { standalone: true, theme: 'dark' }), }, { path: '/setup-wizard/:step?', diff --git a/apps/meteor/client/stories/i18n.ts b/apps/meteor/client/stories/i18n.ts new file mode 100644 index 0000000000000..13bb240d0d0c6 --- /dev/null +++ b/apps/meteor/client/stories/i18n.ts @@ -0,0 +1,35 @@ +import en from '@rocket.chat/i18n/dist/resources/en.i18n.json'; +import i18next from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +/** + * The real English copy, as an i18next instance for stories to render against. + * + * Without it a story shows key names: `mockAppRoot` builds its own i18next instance with *no resources* and + * wraps its children in an `I18nextProvider`, so anything calling `useTranslation()` from react-i18next gets + * each key handed straight back — `__count__people_joined` where "2 people joined" belongs. This is the same + * locale file, and the same import path, the app itself loads (see `client/providers/TranslationProvider.tsx`). + * + * Given as a whole instance rather than through `mockAppRoot().withTranslations(…)` for two reasons. The builder + * loads resources key by key, and with a dot for both the key and namespace separator a flat key like + * `onboarding.component.form.action.next` is read as a *path* — loading it tries to hang `component` off the + * string already at `onboarding`, which throws. And an instance can simply be provided closer to the story than + * the builder's own, which is what makes it win: see `RocketChatDecorator`, and `withCallProviders` for the + * stories that build their own app root. + * + * `keySeparator` and `nsSeparator` are off precisely so those 179 dotted keys are treated as the flat names they + * are, which is why all 7471 of them load here. + */ +export const storybookI18n = i18next.createInstance(); + +void storybookI18n.use(initReactI18next).init({ + lng: 'en', + fallbackLng: 'en', + ns: ['core'], + defaultNS: 'core', + resources: { en: { core: en } }, + keySeparator: false, + nsSeparator: false, + interpolation: { escapeValue: false }, + initImmediate: false, +}); 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/ConferenceChat.spec.tsx b/apps/meteor/client/views/conference/ConferenceChat.spec.tsx new file mode 100644 index 0000000000000..70fdb332ce834 --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceChat.spec.tsx @@ -0,0 +1,55 @@ +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('./ConferenceRoomPanel', () => ({ __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. What the panel carries +// instead is its own control in the header, which is what this pins: the banner's Review button being absent +// would be true of a panel that offered nothing at all. +it('offers chat access from its own header rather than carrying the notice', () => { + renderChat(buildAccess(['someone-else'])); + + expect(screen.getByRole('button', { name: '__count__participants_cannot_see_the_chat' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Review' })).not.toBeInTheDocument(); +}); + +// Nobody to tell about, nothing to offer — the header control is about other people, not about a decoration. +it('says nothing about chat access when everyone in the call can read it', () => { + renderChat(buildAccess([])); + + expect(screen.queryByRole('button', { name: '__count__participants_cannot_see_the_chat' })).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..8703c39ab5b16 --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceChat.tsx @@ -0,0 +1,93 @@ +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 ConferenceRoomPanel from './ConferenceRoomPanel'; +import ConferenceStoresReady from './ConferenceStoresReady'; +import CallPanelHeader from './components/CallPanelHeader'; +import NotFoundPage from '../notFound/NotFoundPage'; +import ChatAccessModal from './components/ChatAccessModal/ChatAccessModal'; +import ConferenceChatNotShared from './components/ConferenceChatNotShared'; +import type { ConferenceChatAccess } from './hooks/useConferenceEmbedded'; +import { hasConferenceChatAccess } from '../../../lib/videoConference/chatAccess'; +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 ( + + {/* The icon sits between the words, so the heading's name is given rather than assembled from them. */} + + {presentWithoutAccess > 0 && chatAccess && ( + setModal( setModal(null)} />)} + /> + )} + + + {!shared && } + + {shared && ( + + + + )} + + ); +}; + +export default ConferenceChat; diff --git a/apps/meteor/client/views/conference/ConferenceEmbeddedPage.stories.tsx b/apps/meteor/client/views/conference/ConferenceEmbeddedPage.stories.tsx new file mode 100644 index 0000000000000..593eaa4281499 --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceEmbeddedPage.stories.tsx @@ -0,0 +1,207 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { QueryClient } from '@tanstack/react-query'; +import { userEvent, within } from 'storybook/test'; + +import ConferenceEmbeddedPage from './ConferenceEmbeddedPage'; +import { conferenceAppRoot, onPhone, withCallProviders } from './storyFixtures'; +import { videoConferenceQueryKeys } from '../../lib/queryKeys'; + +/** + * The assembled ongoing call — the thing all the other stories are parts of: the top bar with the call's name + * and its timer, the call itself, the bar's actions, and a panel docked beside it. + * + * This is the real `ConferenceEmbeddedPage`, not a mock-up of it. Two things make that possible without a live + * conference: + * + * - **Joining is a cache entry.** `useConferenceEmbedded` reads whether this window has joined from a *disabled* + * query, so seeding that key is exactly what a window which has already joined would find there. That is what + * puts the page past the preflight and into the call. + * - **The chat panel stays shut.** It is the only part that needs a real room in the store; the members panel + * needs nothing but the conference's own membership. So these stories open the members panel, and the chat + * never mounts. + * + * The provider's page is `about:blank` — the iframe is real, but no third-party URL is loaded. + */ + +const callId = 'call-id'; + +const CALL_STARTED_MS_AGO = 8 * 60 * 1000 + 12 * 1000; + +/** `ts` is required on a membership entry and arrives as a string over REST, as in the hook's own spec. */ +const member = (_id: string, name: string, overrides: Record = {}): Record => ({ + _id, + username: _id, + name, + ts: '2026-08-03T10:00:00.000Z', + joined: true, + ...overrides, +}); + +const viewer = member('john.doe', 'John Doe'); + +const buildInfo = (users: Record[], membersWithoutAccess: string[] = []) => + ({ + _id: callId, + type: 'videoconference', + rid: 'room-id', + title: 'Weekly sync', + createdAt: new Date(Date.now() - CALL_STARTED_MS_AGO).toISOString(), + createdBy: { _id: 'john.doe', username: 'john.doe', name: 'John Doe' }, + users, + messages: { started: 'started-message-id' }, + capabilities: { mic: true, cam: true, title: true }, + chatAccess: { rid: 'room-id', name: 'general', type: 'c', membersWithoutAccess, canInvite: true }, + }) as any; + +/** + * A window that has already joined. + * + * The url is what the join endpoint would have answered with. A fresh `QueryClient` per story keeps one story's + * seeded call out of the next one's cache. + */ +const joinedAppRoot = (users: Record[], membersWithoutAccess: string[] = []) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + queryClient.setQueryData(videoConferenceQueryKeys.join(callId), { url: 'about:blank', providerName: 'storybook' }); + + return ( + conferenceAppRoot() + .withQueryClient(queryClient) + .withEndpoint('GET', '/v1/video-conference.info', () => buildInfo(users, membersWithoutAccess)) + .withEndpoint('POST', '/v1/video-conference.join', () => ({ url: 'about:blank', providerName: 'storybook' }) as any) + // Renewing presence, ringing someone back, and sharing the chat all happen from this screen. Stubbed so + // the page is fully live without reaching the network. + .withEndpoint('POST', '/v1/video-conference.heartbeat', () => ({ success: true }) as any) + .withEndpoint('POST', '/v1/video-conference.ring', () => ({ success: true }) as any) + .withEndpoint('POST', '/v1/video-conference.share-chat', () => ({ success: true }) as any) + .withEndpoint('POST', '/v1/video-conference.add-participants', () => ({ added: [], success: true }) as any) + .withEndpoint('GET', '/v1/users.autocomplete', () => ({ items: [], success: true }) as any) + // The unread badge on the shut chat reads the viewer's subscription. Nothing here needs one, and + // answering `null` keeps it out of the subscriptions store. + .withEndpoint('GET', '/v1/subscriptions.getOne', () => ({ subscription: null, success: true }) as any) + ); +}; + +/** Opens the members panel, which is where the page shows who is in the call. */ +const openMembers = async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + await userEvent.click(await canvas.findByRole('button', { name: /in the call/i })); +}; + +/** + * Opens the chat panel. Only safe for a viewer who cannot read the chat: that branch renders the notice instead + * of the room, and the room is the one part of this page that needs a live store. + */ +const openChat = async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const canvas = within(canvasElement); + + await userEvent.click(await canvas.findByRole('button', { name: /^Chat/i })); +}; + +const meta = { + component: ConferenceEmbeddedPage, + parameters: { layout: 'fullscreen' }, + args: { callId }, + decorators: [ + // `100dvh` and no minimum: a floor propped the window up to a height the phone stories don't have, which + // is the very thing they exist to show. Desktop stories are unaffected — their viewport was always taller. + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** + * Alone in the call, with the members panel open beside it — the call just started and nobody else has arrived. + */ +export const AloneInTheCall: Story = { + decorators: [withCallProviders(joinedAppRoot([viewer]))], + play: openMembers, +}; + +/** + * A call in progress: several people in it, one still ringing, and one who turned it down — each labelled, with + * a way to ring back the ones who aren't here. + */ +export const SeveralParticipants: Story = { + decorators: [ + withCallProviders( + joinedAppRoot([ + viewer, + member('ada', 'Ada Lovelace'), + member('grace', 'Grace Hopper'), + member('katherine', 'Katherine Johnson', { joined: false, ringingAt: new Date().toISOString() }), + member('alan', 'Alan Turing', { joined: false, declined: true, declinedAt: new Date().toISOString() }), + ]), + ), + ], + play: openMembers, +}; + +/** + * The same call where somebody in it can't read the chat: the notice sits above the top bar, and the member it + * is about is marked in the panel. + */ +export const WithChatAccessNotice: Story = { + decorators: [withCallProviders(joinedAppRoot([viewer, member('ada', 'Ada Lovelace'), member('grace', 'Grace Hopper')], ['grace']))], + play: openMembers, +}; + +/** + * The call with every panel shut — the chrome on its own, which is what a participant looking at the call sees. + */ +export const PanelsClosed: Story = { + decorators: [withCallProviders(joinedAppRoot([viewer, member('ada', 'Ada Lovelace')]))], +}; + +/** + * The chat panel for someone who was added to the *call* and not to its room: membership grants no room access, + * so what the panel can offer is an explanation rather than the conversation. + */ +export const ChatNotSharedWithYou: Story = { + decorators: [withCallProviders(joinedAppRoot([viewer, member('ada', 'Ada Lovelace')], ['john.doe']))], + play: openChat, +}; + +/** + * The call window on a phone held upright, with the people panel open. + * + * The panel is a **sheet**: it rises over the whole window instead of docking beside the call, because splitting + * a 393px screen left the call a sliver and the panel too narrow to use. Look for the call showing through above + * it and down both sides — the sheet is inset, which is what says it is laid over the call rather than being the + * window's new contents. + */ +export const MobilePortraitPanelSheet: Story = { + ...onPhone('phonePortrait'), + decorators: [withCallProviders(joinedAppRoot([viewer, member('ada', 'Ada Lovelace')]))], + play: openMembers, +}; + +/** + * The same sheet on a phone turned sideways. + * + * This is the shape that used to dock: 852px is past `md`, so the panel took its 400px and left the call a third + * of a short screen with the chat's message list two lines tall above its own composer. The sheet is chosen on + * the window being too small to split in *either* direction, so it appears here too. + */ +export const MobileLandscapePanelSheet: Story = { + ...onPhone('phoneLandscape'), + decorators: [withCallProviders(joinedAppRoot([viewer, member('ada', 'Ada Lovelace')]))], + play: openMembers, +}; + +/** + * Landscape with every panel shut — the call chrome alone on a short screen: the top bar with the timer, name, + * member count and chat toggle, the call filling what is left, and the provider's own controls along the bottom. + */ +export const MobileLandscapePanelsClosed: Story = { + ...onPhone('phoneLandscape'), + decorators: [withCallProviders(joinedAppRoot([viewer, member('ada', 'Ada Lovelace')]))], +}; diff --git a/apps/meteor/client/views/conference/ConferenceEmbeddedPage.tsx b/apps/meteor/client/views/conference/ConferenceEmbeddedPage.tsx new file mode 100644 index 0000000000000..a3f3dfefdc116 --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceEmbeddedPage.tsx @@ -0,0 +1,271 @@ +import { isInVideoConference, isRingingVideoConferenceMember } from '@rocket.chat/core-typings'; +import { css } from '@rocket.chat/css-in-js'; +import { Badge, Box, Icon, IconButton } from '@rocket.chat/fuselage'; +import { useBreakpoints, useMediaQuery } from '@rocket.chat/fuselage-hooks'; +import { useCustomSound, useUser, useUserSubscription } from '@rocket.chat/ui-contexts'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import ConferenceChat from './ConferenceChat'; +import ConferencePageError from './ConferencePageError'; +import ConferencePreflight from './ConferencePreflight'; +import ConferenceStatePage from './ConferenceStatePage'; +import ConferenceThreadModal from './ConferenceThreadModal'; +import ConferenceUnauthorizedPage from './ConferenceUnauthorizedPage'; +import PageLoading from '../root/PageLoading'; +import CallMembersPanel from './components/CallMembersPanel/CallMembersPanel'; +import CallPanel from './components/CallPanel'; +import CallTimer from './components/CallTimer/CallTimer'; +import CallTopBar from './components/CallTopBar'; +import ChatAccessNotice from './components/ChatAccessNotice/ChatAccessNotice'; +import ConferenceIframe from './components/ConferenceIframe'; +import { useConferenceEmbedded } from './hooks/useConferenceEmbedded'; +import { useConferencePresenceLease } from './hooks/useConferencePresenceLease'; +import { useConferenceSubscription } from './hooks/useConferenceSubscription'; +import { useConfinedNavigation } from './hooks/useConfinedNavigation'; +import { useLeaveConferenceOnClose } from './hooks/useLeaveConferenceOnClose'; +import { PREFLIGHT_FACES_SHOWN } from '../../../lib/videoConference/constants'; +import { useRingingExpiry } from '../../hooks/useRingingExpiry'; +import { useUnreadDisplay } from '../../sidebar/hooks/useUnreadDisplay'; + +type ConferenceEmbeddedPageProps = { + callId: string; +}; + +type ConferencePanel = 'members' | 'chat'; + +const emptyUnreadData = { alert: false, userMentions: 0, unread: 0, groupMentions: 0 } as const; + +const callHeaderTimerStyles = css` + display: inline-flex; + align-items: center; + min-width: 0; + color: rgba(255, 255, 255, 0.85); + font-variant-numeric: tabular-nums; +`; + +/** + * `aria-label` overrides a button's contents, so a badge rendered inside one is never announced. The count is + * folded into the name instead — as the members action does — and the badge is hidden from assistive technology + * so it is said once rather than twice. + */ +/** + * The badges are `aria-hidden`, so whatever they say has to reach the button's own name — the dot included: it + * is drawn for activity with no count behind it, and a reader who cannot see it would otherwise be told nothing. + */ +const withBadgeCount = (label: string, unread: number, unreadTitle: string, hasUnseenActivity = false): string => + (unread > 0 || hasUnseenActivity) && unreadTitle ? `${label}, ${unreadTitle}` : label; + +const ConferenceEmbeddedPage = ({ callId }: ConferenceEmbeddedPageProps) => { + const { room, conference, call } = useConferenceEmbedded(callId); + const { t } = useTranslation(); + const [threadTmid, setThreadTmid] = useState(null); + + const handleOpenThread = useCallback( + (tmid: string) => { + if (!room.rid) { + return; + } + setThreadTmid(tmid); + }, + [room.rid], + ); + + useConfinedNavigation({ onOpenThread: room.tmid ? undefined : handleOpenThread }); + + const { leaveNow } = useLeaveConferenceOnClose(callId, conference.departure); + + useConferencePresenceLease(callId, conference.joined); + + const user = useUser(); + + const [bannerDismissed, setBannerDismissed] = useState(false); + + const [activePanel, setActivePanel] = useState(); + const togglePanel = useCallback((panel: ConferencePanel) => setActivePanel((current) => (current === panel ? undefined : panel)), []); + const chatVisible = activePanel === 'chat'; + + const breakpoints = useBreakpoints(); + const tooShortToSplit = useMediaQuery('(max-height: 520px)'); + + // Too small to split, in either direction: below `md` there is no width for a panel beside the call, and a + // phone in landscape has the width but not the height — docking there left the call a third of a short screen + // and the chat a message list two lines tall above its own composer. Both get the sheet instead. + // + // Width alone was the first answer and the wrong one: a phone in landscape is 852pt wide, which is `md`. + const sheetPanel = !breakpoints.includes('md') || tooShortToSplit; + + useConferenceSubscription(room.rid); + + const subscription = useUserSubscription(room.rid ?? ''); + const { showUnread, unreadCount, unreadVariant, unreadTitle } = useUnreadDisplay(subscription ?? emptyUnreadData); + const unread = !chatVisible && showUnread ? unreadCount.total : 0; + const hasUnseenActivity = !chatVisible && !unread && Boolean(subscription?.alert); + + const present = useMemo(() => call.members.filter(isInVideoConference), [call.members]); + const presentCount = present.length; + + const { callSounds } = useCustomSound(); + const otherMembers = call.canRing && conference.joined ? call.members.filter((m) => m._id !== user?._id && !isInVideoConference(m)) : []; + useRingingExpiry(otherMembers.map((m) => m.ringingAt)); + const someoneRinging = otherMembers.some((m) => isRingingVideoConferenceMember(m)); + useEffect(() => { + if (someoneRinging) { + callSounds.playDialer(); + } else { + callSounds.stopDialer(); + } + return () => callSounds.stopDialer(); + }, [someoneRinging, callSounds]); + + const membersAction = ( + togglePanel('members')} + icon={} + > + {presentCount > 0 && ( + + )} + + ); + + const chatAction = ( + togglePanel('chat')} + icon={} + > + {unread > 0 && ( + + )} + {unread === 0 && hasUnseenActivity && ( + + )} + + ); + + if (room.error) { + return ; + } + + if (conference.error) { + return ; + } + + if (conference.loading) { + return ; + } + + if (call.ended && !conference.joined) { + return ; + } + + if (!conference.joined) { + if (room.loading) { + return ; + } + + return ( + conference.join({ state: preferences, name })} + onCancel={leaveNow} + /> + ); + } + + if (!conference.url) { + return ; + } + + return ( + + {room.chatAccess && !bannerDismissed && ( + setBannerDismissed(true)} /> + )} + + + + {call.name && ( + <> + + | + + + {call.name} + + + )} + + } + > + {membersAction} + {chatAction} + + + + + + + + + {activePanel === 'members' && ( + togglePanel('members')} + /> + )} + {activePanel === 'chat' && ( + togglePanel('chat')} + /> + )} + + + + {threadTmid && room.rid && setThreadTmid(null)} />} +
+ ); +}; + +export default ConferenceEmbeddedPage; diff --git a/apps/meteor/client/views/conference/ConferencePreflight.stories.tsx b/apps/meteor/client/views/conference/ConferencePreflight.stories.tsx new file mode 100644 index 0000000000000..b80da324ba12c --- /dev/null +++ b/apps/meteor/client/views/conference/ConferencePreflight.stories.tsx @@ -0,0 +1,142 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from 'storybook/actions'; +import { userEvent, within } from 'storybook/test'; + +import ConferencePreflight from './ConferencePreflight'; +import { allCapabilities, conferenceAppRoot, onPhone, storeCallPreferences, withCallProviders } from './storyFixtures'; + +/** + * The screen a call actually starts on: what the camera will do on arrival, and what the call is called. + * + * Mic and camera state is remembered rather than passed in, so the stories that care about it seed the stored + * preferences instead of setting a prop. + */ +const meta = { + component: ConferencePreflight, + parameters: { layout: 'fullscreen' }, + args: { + name: 'Ada Lovelace', + action: 'start', + isDirect: true, + canName: false, + capabilities: allCapabilities, + onConfirm: action('onConfirm'), + onCancel: action('onCancel'), + }, + decorators: [ + // `100dvh` and no minimum: a floor here would have propped the screen up to a height the phone stories + // don't have, which is exactly the case they exist to show. Desktop stories are unaffected — their + // viewport is taller than the floor ever was. + (Story) => ( +
+ +
+ ), + withCallProviders(conferenceAppRoot()), + ], + beforeEach: storeCallPreferences({ cam: false }), +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** + * Calling one person. No name to give a direct call, and the ring switch is offered — with a line saying who + * gets interrupted while it is on. + */ +export const DirectCall: Story = { + args: { canChooseRinging: true }, +}; + +/** The same call with ringing turned off: nobody is notified, so the notice goes away. */ +export const DirectCallNotRinging: Story = { + args: { canChooseRinging: true }, + beforeEach: storeCallPreferences({ cam: false, ring: false }), +}; + +/** + * Starting a call in a room. It gets a name, defaulted to the room's, and there is nobody in particular to ring. + */ +export const GroupCall: Story = { + args: { isDirect: false, canName: true, name: 'Weekly sync', defaultName: 'Weekly sync' }, +}; + +/** Camera on: the preview says so, and warns that which camera is a question for inside the call. */ +export const CameraOn: Story = { + beforeEach: storeCallPreferences({ cam: true }), +}; + +/** Mic muted and camera off — both toggles red, which is the state worth noticing at a glance. */ +export const BothDevicesOff: Story = { + beforeEach: storeCallPreferences({ mic: false, cam: false }), +}; + +/** + * A provider that can't be told about a camera at all. The toggle isn't offered rather than being offered and + * ignored. + */ +export const MicOnlyProvider: Story = { + args: { capabilities: { mic: true, cam: false } }, +}; + +/** Walking into a call already running: it says who is in there, and the button says Join rather than Call. */ +export const JoiningACall: Story = { + args: { + action: 'join', + isDirect: false, + name: 'Weekly sync', + participants: { + people: [ + { _id: 'ada', username: 'ada' }, + { _id: 'grace', username: 'grace' }, + { _id: 'alan', username: 'alan' }, + ], + total: 7, + }, + }, +}; + +/** + * The moment after the primary button is pressed: it goes to a spinner and stays there, because the window is + * about to be replaced by the call and offering the button again would start a second one. + */ +export const Confirming: Story = { + args: { canChooseRinging: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await userEvent.click(await canvas.findByRole('button', { name: 'Call Ada Lovelace' })); + }, +}; + +/** + * The preflight on a phone held upright: one column, preview first, and everything down to Cancel fitting + * without a scroll. + */ +export const MobilePortrait: Story = { + ...onPhone('phonePortrait'), + args: { isDirect: false, canName: true, name: 'Weekly sync', defaultName: 'Weekly sync' }, +}; + +/** + * The same phone turned sideways — the shape that was broken. + * + * 852px wide is past `md`, so width alone said "desktop" while 393px of height said otherwise: the screen + * stacked, a full-width 16:9 preview took more height than the whole viewport, and the name field and the call + * button sat below the fold with the preview clipped at the top. It should now be two columns with the preview + * capped, and the primary button on screen. + */ +export const MobileLandscape: Story = { + ...onPhone('phoneLandscape'), + args: { isDirect: false, canName: true, name: 'Weekly sync', defaultName: 'Weekly sync' }, +}; + +/** + * Landscape with the camera on, which is the tightest the screen gets: the preview carries a line of its own + * under the icon, and both have to stay clear of the mic and camera toggles floating over the tile's bottom edge. + */ +export const MobileLandscapeCameraOn: Story = { + ...onPhone('phoneLandscape'), + beforeEach: storeCallPreferences({ cam: true }), +}; diff --git a/apps/meteor/client/views/conference/ConferencePreflight.tsx b/apps/meteor/client/views/conference/ConferencePreflight.tsx new file mode 100644 index 0000000000000..ddac17d9a4be2 --- /dev/null +++ b/apps/meteor/client/views/conference/ConferencePreflight.tsx @@ -0,0 +1,250 @@ +import type { VideoConferenceCapabilities } from '@rocket.chat/core-typings'; +import { css } from '@rocket.chat/css-in-js'; +import { Box, Button, ButtonGroup, CheckBox, Field, FieldRow, Icon, TextInput } from '@rocket.chat/fuselage'; +import { useBreakpoints, useMediaQuery } from '@rocket.chat/fuselage-hooks'; +import type { ComponentProps } from 'react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import CallDeviceToggle from './components/CallDeviceToggle'; +import type { CallPreferences } from './hooks/useCallPreferences'; +import { useCallPreferences } from './hooks/useCallPreferences'; +import CallParticipants from '../../components/CallParticipants'; + +/** + * How much of the tile's bottom edge the mic and camera toggles float over: their own inset plus their height, + * and a little clear air. The placeholder inside the tile centres in what is left above this rather than in the + * whole tile — otherwise the icon and its line of text land underneath the buttons, which is what a short tile + * (a phone in landscape, a small window) leaves room for. + */ +const TOGGLES_ZONE = 60; + +/** + * The camera tile: 16:9, and black rather than a themed surface, since this is where a camera goes and a camera + * with nothing to show is black. It stays black with the camera off too, so toggling it doesn't repaint the tile. + * + * Width leads while there is height to spare. On a short viewport — a phone in landscape, a small window — a + * full-width 16:9 tile is taller than the whole screen, and it was pushing the field and the button that starts + * the call below the fold: the tile says what the camera *will* do, while the button is what the screen is for. + * So there, height leads and the aspect ratio derives the width, which keeps the tile 16:9 and centred instead + * of crowding out the actions. + */ +const previewTileStyle = css` + width: 100%; + aspect-ratio: 16 / 9; + background-color: #000; + + @media (max-height: 620px) { + width: auto; + max-width: 100%; + height: min(52dvh, 300px); + } +`; + +type ConferencePreflightProps = { + name: string; + action: 'start' | 'join'; + isDirect: boolean; + canName: boolean; + defaultName?: string; + participants?: ComponentProps; + capabilities: VideoConferenceCapabilities; + canChooseRinging?: boolean; + onConfirm: (preferences: CallPreferences, name: string, ring: boolean) => void; + onCancel: () => void; +}; + +const ConferencePreflight = ({ + name, + action, + isDirect, + canName, + defaultName, + participants, + capabilities, + canChooseRinging = false, + onConfirm, + onCancel, +}: ConferencePreflightProps) => { + const { t } = useTranslation(); + // `useCallPreferences` already carries the ring habit — it calls `useCallRingPreference` itself. Calling that + // again here put a second `useLocalStorage` subscriber on the same key, only one of which drove this screen's + // state, leaving two sources of truth for one answer. + const { preferences, ring, toggle, toggleRing } = useCallPreferences(capabilities); + + // Side by side once there is room for both; stacked below that, with the preview still first. + // + // Width alone was the wrong question. A phone in landscape is wide but short, and stacking there spent the + // little height it has on the preview, leaving the name and the call button off the bottom of the screen — + // so the columns come back on the viewport's shape as well as its width. + const wideEnough = useBreakpoints().includes('md'); + const shortAndWide = useMediaQuery('(max-height: 620px) and (min-width: 480px)'); + const columns = wideEnough || shortAndWide; + + const [title, setTitle] = useState(defaultName ?? name); + const [confirming, setConfirming] = useState(false); + + const handleConfirm = () => { + setConfirming(true); + onConfirm(preferences, title.trim() || name, ring); + }; + + const heading = (() => { + if (action === 'start') { + return isDirect ? t('Start_conference_with__name__', { name }) : t('Start_a_new_conference'); + } + + return isDirect ? t('Join_conference_with__name__', { name }) : t('Join_the_conference'); + })(); + + const confirmLabel = (() => { + if (action === 'join') { + return t('Join_call'); + } + + return isDirect ? t('Call__name__', { name }) : t('Start_call'); + })(); + + const previewColumn = ( + + + + + + {preferences.cam ? t('Your_camera_will_be_on') : t('Your_camera_is_turned_off')} + + {preferences.cam && ( + + {t('Which_devices_are_used_is_chosen_in_the_call')} + + )} + + + + + {capabilities.mic && ( + toggle('mic')} + /> + )} + {capabilities.cam && ( + toggle('cam')} + /> + )} + + + + + ); + + const detailsColumn = ( + + {/* An `h2` rather than a `div` at heading size: it is the screen's heading, and this is the only thing + that lets anyone — or anything — find it as one. */} + + {heading} + + + {canName && ( + + + + setTitle((event.target as HTMLInputElement).value)} + /> + + + + )} + + {action === 'join' && participants && ( + + + {t('People_in_the_call')} + + + + + + )} + + {action === 'start' && canChooseRinging && ( + + + + + + {t('Ring_people')} + + + + + )} + + {action === 'start' && isDirect && (!canChooseRinging || ring) && ( + + {t('__name__will_be_notified_when_you_start_the_call', { name })} + + )} + + + + + + + + + ); + + return ( + + + {previewColumn} + {detailsColumn} + + + ); +}; + +export default ConferencePreflight; diff --git a/apps/meteor/client/views/conference/ConferenceRoomPanel.tsx b/apps/meteor/client/views/conference/ConferenceRoomPanel.tsx new file mode 100644 index 0000000000000..9ee190a6c068d --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceRoomPanel.tsx @@ -0,0 +1,67 @@ +import { Box } from '@rocket.chat/fuselage'; +import { LayoutContext, useLayout } from '@rocket.chat/ui-contexts'; +import { lazy, Suspense, useMemo } from 'react'; + +import ConferenceThreadChat from './ConferenceThreadChat'; +import ConferenceChatNotShared from './components/ConferenceChatNotShared'; +import { narrowRoomStyle } from './panelStyles'; +import { NotSubscribedToRoomError } from '../../lib/errors/NotSubscribedToRoomError'; +import RoomSkeleton from '../room/RoomSkeleton'; +import { useOpenRoomById } from '../room/hooks/useOpenRoomById'; + +const RoomProvider = lazy(() => import('../room/providers/RoomProvider')); +const ChatProvider = lazy(() => import('../room/providers/ChatProvider')); +const Room = lazy(() => import('../room/Room')); +const RoomNotFound = lazy(() => import('../room/RoomNotFound')); + +type ConferenceRoomPanelProps = { + rid: string; + /** Given, the panel is the call's thread rather than its room. */ + tmid?: string; + onEscape?: () => void; +}; + +/** + * The call's chat, rendered in the panel beside it: the room, or one thread of it. + * + * Which of the two it is only decides what goes inside the room provider. Everything around that is the same + * either way — the room has to be opened, the layout has to be told it is embedded so the room UI fits a narrow + * panel, and the same three outcomes have to be drawn while and after it loads. + * + * Keeping this room's subscription fresh is the page's job, not this panel's — see `useConferenceSubscription`. + * It has to outlive the panel, because the closed chat's unread badge needs it. + */ +const ConferenceRoomPanel = ({ rid, tmid, onEscape }: ConferenceRoomPanelProps) => { + const { data, error, isSuccess, isError, isLoading } = useOpenRoomById(rid); + const layoutContext = useLayout(); + // The room renders inside a narrow panel next to the call, so force the embedded layout. + const layoutContextEmbedded = useMemo(() => ({ ...layoutContext, isEmbedded: true }), [layoutContext]); + + return ( + + + }> + {isLoading && } + {isSuccess && ( + + {tmid ? ( + + + + ) : ( + + )} + + )} + {/* A public room this user has neither joined nor may preview: not a missing page, and the one error + `useOpenRoomById` raises here that has a better answer than "room not found". It is the same + situation the server usually reports in advance through `chatAccess`, reached from the other end — + a room that became unreadable while the panel was open, say. */} + {isError && (error instanceof NotSubscribedToRoomError ? : )} + + + + ); +}; + +export default ConferenceRoomPanel; diff --git a/apps/meteor/client/views/conference/ConferenceRoute.tsx b/apps/meteor/client/views/conference/ConferenceRoute.tsx index 5e1c64018ab7a..c952aea5968e4 100644 --- a/apps/meteor/client/views/conference/ConferenceRoute.tsx +++ b/apps/meteor/client/views/conference/ConferenceRoute.tsx @@ -1,11 +1,53 @@ +import { useRouteParameter, useSearchParameter } from '@rocket.chat/ui-contexts'; + +import ConferenceEmbeddedPage from './ConferenceEmbeddedPage'; import ConferencePage from './ConferencePage'; +import ConferencePageError from './ConferencePageError'; +import ConferenceStartPage from './ConferenceStartPage'; +import ConferenceViewport from './ConferenceViewport'; +import { NEW_CONFERENCE_ID } from './lib/callWindow'; import AuthenticationCheck from '../root/MainLayout/AuthenticationCheck'; +import PageLoading from '../root/PageLoading'; + +const conferenceLoading = ; const ConferenceRoute = () => { + const id = useRouteParameter('id'); + const callUrlParam = useSearchParameter('callUrl'); + const rid = useSearchParameter('rid'); + + if (callUrlParam) { + return ( + + + + ); + } + + if (id === NEW_CONFERENCE_ID && rid) { + return ( + + + + + + ); + } + + if (id) { + return ( + + + + + + ); + } + return ( - - - + + + ); }; diff --git a/apps/meteor/client/views/conference/ConferenceStartPage.spec.tsx b/apps/meteor/client/views/conference/ConferenceStartPage.spec.tsx new file mode 100644 index 0000000000000..937f74715ac2c --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceStartPage.spec.tsx @@ -0,0 +1,88 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import ConferenceStartPage from './ConferenceStartPage'; + +const start = jest.fn(() => ({ data: { type: 'videoconference', callId: 'new-call' }, success: true }) as any); +const join = jest.fn(() => ({ url: 'https://call.example', providerName: 'test' }) as any); +const navigate = jest.fn(); + +jest.mock('@rocket.chat/ui-contexts', () => ({ + ...jest.requireActual('@rocket.chat/ui-contexts'), + useRouter: () => ({ navigate, buildRoutePath: () => '/conference/new-call' }), +})); + +const subscription = (t: 'c' | 'd') => ({ + subscription: { _id: 'sub', rid: 'room-id', t, fname: 'general', name: 'general' }, + success: true, +}); + +const renderStart = (t: 'c' | 'd' = 'c') => + render(, { + wrapper: mockAppRoot() + .withJohnDoe() + .withEndpoint('GET', '/v1/subscriptions.getOne', () => subscription(t) as any) + .withEndpoint( + 'GET', + '/v1/video-conference.capabilities', + () => ({ providerName: 'test', capabilities: { mic: true, cam: true } }) as any, + ) + .withEndpoint('POST', '/v1/video-conference.start', start) + .withEndpoint('POST', '/v1/video-conference.join', join) + .build(), + }); + +beforeEach(() => { + start.mockClear(); + join.mockClear(); + navigate.mockClear(); + localStorage.clear(); +}); + +// The reported problem: clicking *call* in a room posted a message there and rang people for a call that hadn't +// happened yet. Opening this window must create nothing at all. +it('creates no conference until it is asked to', async () => { + renderStart(); + + expect(await screen.findByRole('button', { name: 'Start_call' })).toBeInTheDocument(); + expect(start).not.toHaveBeenCalled(); + expect(join).not.toHaveBeenCalled(); +}); + +// Offered as the meeting it is, rather than as the room's bare name. +it('names the call after the room it is being started in', async () => { + renderStart(); + + expect(await screen.findByLabelText('Call_name')).toHaveValue('Meeting_in__roomName__'); +}); + +it('starts the conference with the name and devices it was given', async () => { + renderStart(); + + await userEvent.clear(await screen.findByLabelText('Call_name')); + await userEvent.type(screen.getByLabelText('Call_name'), 'Release planning'); + await userEvent.click(screen.getByRole('button', { name: 'Mic_on' })); + await userEvent.click(screen.getByRole('button', { name: 'Start_call' })); + + await waitFor(() => expect(start).toHaveBeenCalledWith({ roomId: 'room-id', title: 'Release planning', allowRinging: true })); + await waitFor(() => expect(join).toHaveBeenCalledWith({ callId: 'new-call', state: { mic: false, cam: false } })); +}); + +// Replacing this screen is what stops a reload starting a second conference, and what lets the window be reached +// again as the call it now holds. +it('becomes the conference it started', async () => { + renderStart(); + + await userEvent.click(await screen.findByRole('button', { name: 'Start_call' })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith({ name: 'conference', params: { id: 'new-call' } }, { replace: true })); +}); + +// A direct call is placed to someone: it is their name on the button, and it has no name of its own to set. +it('offers to call the person in a direct message', async () => { + renderStart('d'); + + expect(await screen.findByRole('button', { name: 'Call__name__' })).toBeInTheDocument(); + expect(screen.queryByLabelText('Call_name')).not.toBeInTheDocument(); +}); diff --git a/apps/meteor/client/views/conference/ConferenceStartPage.tsx b/apps/meteor/client/views/conference/ConferenceStartPage.tsx new file mode 100644 index 0000000000000..05a380d568765 --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceStartPage.tsx @@ -0,0 +1,57 @@ +import { useTranslation } from 'react-i18next'; + +import ConferencePageError from './ConferencePageError'; +import ConferencePreflight from './ConferencePreflight'; +import { useConfinedNavigation } from './hooks/useConfinedNavigation'; +import { useStartConference } from './hooks/useStartConference'; +import { closeCallWindow } from './lib/callWindow'; +import PageLoading from '../root/PageLoading'; + +type ConferenceStartPageProps = { + rid: string; +}; + +/** + * The call window before there is a call: the preflight for a conference this user is about to start. + * + * Clicking *call* in a room opens this window and nothing else. Creating the conference 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 — so it waits for them to confirm here, and cancelling leaves no trace at all. + */ +const ConferenceStartPage = ({ rid }: ConferenceStartPageProps) => { + // The preflight is the whole page, but the window is still the call's: a stray link must not navigate it. + useConfinedNavigation(); + + const { t } = useTranslation(); + const { name, isDirect, capabilities, loading, error, start } = useStartConference(rid); + + if (error) { + return ; + } + + if (loading) { + return ; + } + + return ( + start({ state: preferences, name: chosenName, ring })} + onCancel={closeCallWindow} + /> + ); +}; + +export default ConferenceStartPage; diff --git a/apps/meteor/client/views/conference/ConferenceStatePage.tsx b/apps/meteor/client/views/conference/ConferenceStatePage.tsx new file mode 100644 index 0000000000000..26040c372e3e6 --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceStatePage.tsx @@ -0,0 +1,44 @@ +import { States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions, StatesAction } from '@rocket.chat/fuselage'; +import type { Keys as IconName } from '@rocket.chat/icons'; +import { Page, PageHeader, PageContent } from '@rocket.chat/ui-client'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { ReactNode } from 'react'; + +type ConferenceStatePageProps = { + icon: IconName; + title: string; + subtitle?: ReactNode; + /** What the user can do about it, if anything. A state with nothing on offer simply says what happened. */ + action?: { label: string; onClick: () => void }; +}; + +/** + * A conference window that can't show a call — no such call, or not this user's to see. + * + * The window is all the user has: there is no sidebar to go back to and no room behind it, so the page has to + * say what happened and carry whatever way out it has. It keeps the conference's own header so the window + * still reads as the call it was opened for. + */ +const ConferenceStatePage = ({ icon, title, subtitle, action }: ConferenceStatePageProps) => { + const t = useTranslation(); + + return ( + + + + + + {title} + {subtitle && {subtitle}} + {action && ( + + {action.label} + + )} + + + + ); +}; + +export default ConferenceStatePage; diff --git a/apps/meteor/client/views/conference/ConferenceStoresReady.tsx b/apps/meteor/client/views/conference/ConferenceStoresReady.tsx new file mode 100644 index 0000000000000..b1a46dac212e6 --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceStoresReady.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from 'react'; +import { useEffect } from 'react'; + +import { RoomsCachedStore, SubscriptionsCachedStore } from '../../cachedStores'; +import PageLoading from '../root/PageLoading'; +import { useMainReady } from '../root/hooks/useMainReady'; + +/** + * Lets a room render outside the main app layout. + * + * The room UI waits on the cached stores being *ready* — a flag the sidebar's subscriptions normally set once + * they have loaded. Nothing loads them here, and nothing needs to: the room the chat panel shows is the one room + * in play, and `useOpenRoomById` fetches it and its subscription itself. So this only says the stores are as + * loaded as they are going to get, which is what unblocks the room. + * + * It used to fetch that room here as well, which meant two `rooms.info` for the same room a moment apart. + */ +const ConferenceStoresReady = ({ children }: { children: ReactNode }) => { + const ready = useMainReady(); + + useEffect(() => { + SubscriptionsCachedStore.setReady(true); + RoomsCachedStore.setReady(true); + }, []); + + if (!ready) { + return ; + } + + return <>{children}; +}; + +export default ConferenceStoresReady; diff --git a/apps/meteor/client/views/conference/ConferenceThreadChat.tsx b/apps/meteor/client/views/conference/ConferenceThreadChat.tsx new file mode 100644 index 0000000000000..c2701b0efaa1d --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceThreadChat.tsx @@ -0,0 +1,158 @@ +import type { IMessage } from '@rocket.chat/core-typings'; +import { isEditedMessage, isThreadMainMessage } from '@rocket.chat/core-typings'; +import { Box, CheckBox, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; +import { clientCallbacks } from '@rocket.chat/ui-client'; +import { useEndpoint, useUserPreference } from '@rocket.chat/ui-contexts'; +import { useCallback, useEffect, useId, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import MessageListErrorBoundary from '../room/MessageList/MessageListErrorBoundary'; +import DropTargetOverlay from '../room/body/DropTargetOverlay'; +import { useFileUploadDropTarget } from '../room/body/hooks/useFileUploadDropTarget'; +import ComposerContainer from '../room/composer/ComposerContainer'; +import RoomComposer from '../room/composer/RoomComposer/RoomComposer'; +import { useChat } from '../room/contexts/ChatContext'; +import { useRoom, useRoomSubscription } from '../room/contexts/RoomContext'; +import ThreadMessageList from '../room/contextualBar/Threads/components/ThreadMessageList'; +import { useThreadMainMessageQuery } from '../room/contextualBar/Threads/hooks/useThreadMainMessageQuery'; +import { DateListProvider } from '../room/providers/DateListProvider'; +import PageLoading from '../root/PageLoading'; + +type ConferenceThreadChatProps = { + tmid: string; + onEscape?: () => void; +}; + +const ConferenceThreadChat = ({ tmid, onEscape }: ConferenceThreadChatProps) => { + const { t } = useTranslation(); + const chat = useChat(); + if (!chat) { + throw new Error('No ChatContext provided'); + } + + const mainMessageQueryResult = useThreadMainMessageQuery(tmid); + + const sendToChannelPreference = useUserPreference<'always' | 'never' | 'default'>('alsoSendThreadToChannel'); + + const [sendToChannel, setSendToChannel] = useState(() => { + switch (sendToChannelPreference) { + case 'always': + return true; + case 'never': + return false; + default: + return false; + } + }); + + const handleSend = useCallback((): void => { + if (sendToChannelPreference === 'default') { + setSendToChannel(false); + } + }, [sendToChannelPreference]); + + const handleComposerEscape = useCallback((): void => { + onEscape?.(); + }, [onEscape]); + + const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget(); + + const handleNavigateToPreviousMessage = useCallback((): void => { + chat?.messageEditing.toPreviousMessage(); + }, [chat?.messageEditing]); + + const handleNavigateToNextMessage = useCallback((): void => { + chat?.messageEditing.toNextMessage(); + }, [chat?.messageEditing]); + + const room = useRoom(); + const readThread = useEndpoint('POST', '/v1/chat.readThread'); + + useEffect(() => { + clientCallbacks.add( + 'streamNewMessage', + (msg: IMessage) => { + if (room._id !== msg.rid || isEditedMessage(msg) || msg.tmid !== tmid) { + return; + } + + void Promise.resolve(readThread({ tmid })).catch(() => undefined); + }, + clientCallbacks.priority.MEDIUM, + `conference-thread-${room._id}`, + ); + + return () => { + clientCallbacks.remove('streamNewMessage', `conference-thread-${room._id}`); + }; + }, [tmid, readThread, room._id]); + + const subscription = useRoomSubscription(); + const sendToChannelID = useId(); + + const [shouldJumpToBottom, setShouldJumpToBottom] = useState(true); + + if (mainMessageQueryResult.isLoading) { + return ; + } + + if (!mainMessageQueryResult.isSuccess) { + return null; + } + + const mainMessage = mainMessageQueryResult.data; + + return ( + + + + + + + + + + + + setSendToChannel((checked) => !checked)} + name='alsoSendThreadToChannel' + /> + + {t('Also_send_to_channel')} + + + + + + + + ); +}; + +export default ConferenceThreadChat; diff --git a/apps/meteor/client/views/conference/ConferenceThreadModal.tsx b/apps/meteor/client/views/conference/ConferenceThreadModal.tsx new file mode 100644 index 0000000000000..30d3d200460e6 --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceThreadModal.tsx @@ -0,0 +1,50 @@ +import { Box, Modal, ModalClose, ModalContent, ModalHeader, ModalHeaderText, ModalTitle } from '@rocket.chat/fuselage'; +import { ModalBackdrop } from '@rocket.chat/ui-client'; +import { useId } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; + +import ConferenceRoomPanel from './ConferenceRoomPanel'; +import { CONFERENCE_THEMED_CLASS } from './panelStyles'; + +type ConferenceThreadModalProps = { + rid: string; + tmid: string; + onClose: () => void; +}; + +/** + * Renders the thread in a locally-portalled modal instead of going through `useSetModal`. + * + * `useSetModal` renders inside `ModalProvider`'s portal at the app root, which is outside the conference + * page's component tree. Components like `RoomProvider` and `ChatProvider` inside `ConferenceRoomPanel` + * crash when rendered there. Using `createPortal` directly keeps the React parent chain intact so all + * context providers from the conference page remain available. + */ +const ConferenceThreadModal = ({ rid, tmid, onClose }: ConferenceThreadModalProps) => { + const { t } = useTranslation(); + const titleId = useId(); + + return createPortal( + + {/* A thread is the chat panel's content one step further out, so it is read in the same theme the panel + is — not in the window's dark, which is what a modal portalled to the body would otherwise take. */} + + + + {t('Thread')} + + + + + + + + + + , + document.body, + ); +}; + +export default ConferenceThreadModal; diff --git a/apps/meteor/client/views/conference/ConferenceUnauthorizedPage.tsx b/apps/meteor/client/views/conference/ConferenceUnauthorizedPage.tsx new file mode 100644 index 0000000000000..40a2b4efe6655 --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceUnauthorizedPage.tsx @@ -0,0 +1,26 @@ +import { UserContext, useTranslation, useUser, useRoute } from '@rocket.chat/ui-contexts'; +import { useContext } from 'react'; + +import ConferenceStatePage from './ConferenceStatePage'; + +const ConferenceUnauthorizedPage = () => { + const t = useTranslation(); + const user = useUser(); + // Use the raw context logout (not `useLogout`, which redirects to `/`) so we stay on the + // `/conference/:id` URL — after logging back in, the user lands right back on this conference. + const { logout } = useContext(UserContext); + const loginRoute = useRoute('login'); + + return ( + void logout() } : { label: t('Back_to_login'), onClick: () => loginRoute.push() }} + /> + ); +}; + +export default ConferenceUnauthorizedPage; diff --git a/apps/meteor/client/views/conference/ConferenceViewport.spec.tsx b/apps/meteor/client/views/conference/ConferenceViewport.spec.tsx new file mode 100644 index 0000000000000..9f494a4286a39 --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceViewport.spec.tsx @@ -0,0 +1,42 @@ +import { PaletteStyleTag } from '@rocket.chat/fuselage'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; + +import ConferenceViewport from './ConferenceViewport'; +import { CONFERENCE_THEMED_CLASS } from './panelStyles'; + +const REFERENCE_TAG_ID = 'reference-palette'; + +/** The palette Fuselage emits for a theme at `:root`, to compare against the one scoped to the panel class. */ +const paletteFor = (theme: 'light' | 'dark') => { + const { unmount } = render(); + const css = document.getElementById(REFERENCE_TAG_ID)?.textContent; + unmount(); + return css; +}; + +const renderViewport = (themeAppearence: string) => + render(the call, { + wrapper: mockAppRoot().withUserPreference('themeAppearence', themeAppearence).build(), + }); + +const themedCss = (theme: string) => document.getElementById(`conference-themed-palette-${theme}`)?.textContent; + +describe('ConferenceViewport', () => { + it('renders what it is given', () => { + renderViewport('light'); + + expect(screen.getByText('the call')).toBeInTheDocument(); + }); + + // The window itself is pinned dark by its route; this is what hands the reader's own theme back to the + // panels beside the call, and only to them. + it.each(['light', 'dark'] as const)('scopes the %s palette to the panels', (themeAppearence) => { + renderViewport(themeAppearence); + + const css = themedCss(themeAppearence); + + expect(css).toContain(`.${CONFERENCE_THEMED_CLASS}`); + expect(css?.replace(`.${CONFERENCE_THEMED_CLASS}`, ':root')).toBe(paletteFor(themeAppearence)); + }); +}); diff --git a/apps/meteor/client/views/conference/ConferenceViewport.tsx b/apps/meteor/client/views/conference/ConferenceViewport.tsx new file mode 100644 index 0000000000000..6af69e3960e8a --- /dev/null +++ b/apps/meteor/client/views/conference/ConferenceViewport.tsx @@ -0,0 +1,29 @@ +import { Box, PaletteStyleTag } from '@rocket.chat/fuselage'; +import { useThemeMode } from '@rocket.chat/ui-client'; +import type { ReactNode } from 'react'; + +import { CONFERENCE_THEMED_CLASS } from './panelStyles'; + +/** + * The conference renders standalone, outside the app's navigation chrome, so it has no `MainContent` + * ancestor to inherit a height from — this establishes the viewport box the conference fills. + * + * It also carries the window's one palette exception. The window is pinned dark at the root (see the route's + * `theme`), which is what the call wants and what anything portalled out of it — a device picker, a menu over a + * video tile — inherits by landing in the same document. `CONFERENCE_THEMED_CLASS` hands the reader's + * preference back to the subtrees that ask for it, so a chat beside the call is read in the theme its room is + * read in everywhere else. + */ +const ConferenceViewport = ({ children }: { children: ReactNode }) => { + const theme = useThemeMode(); + + return ( + // `100dvh` so a mobile browser's collapsing URL bar doesn't leave the call clipped or scrollable. + + + {children} + + ); +}; + +export default ConferenceViewport; diff --git a/apps/meteor/client/views/conference/components/AddParticipantsModal/AddParticipantsModal.spec.tsx b/apps/meteor/client/views/conference/components/AddParticipantsModal/AddParticipantsModal.spec.tsx new file mode 100644 index 0000000000000..5261bb3890b8d --- /dev/null +++ b/apps/meteor/client/views/conference/components/AddParticipantsModal/AddParticipantsModal.spec.tsx @@ -0,0 +1,165 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { QueryClient } from '@tanstack/react-query'; +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 { videoConferenceQueryKeys } from '../../../../lib/queryKeys'; +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'); + + // The mount-time query fires immediately with no term and no exceptions, and the members list arrives on its + // own schedule, so "has been called" and "the last call so far" prove nothing about either. Wait for the query + // the test is about — the debounced term and the loaded exceptions travelling together — and read that one. + const selectorFor = (term: string) => + autocomplete.mock.calls.map(([params]) => JSON.parse(params.selector)).find((selector) => selector.term === term); + + await waitFor(() => expect(selectorFor('outsider')).toBeDefined()); + + expect(selectorFor('outsider')).toEqual({ term: 'outsider', 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 })); +}); + +// The window learns about other people's changes from the conference stream. Its own are not other people's: +// leaning on that left the panel this was opened from still listing the call as it was before the add. +it('has the call read again, so the panel it was opened from is not left stale', async () => { + const invalidateQueries = jest.spyOn(QueryClient.prototype, 'invalidateQueries'); + + renderModal(); + + await selectOutsider(); + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + + await waitFor(() => expect(addParticipants).toHaveBeenCalled()); + // The conference query is what the members panel reads, so invalidating it is what sends the panel back to + // the server for the roster it is showing. + await waitFor(() => + expect(invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ queryKey: videoConferenceQueryKeys.conference('call-id') })), + ); + + invalidateQueries.mockRestore(); +}); diff --git a/apps/meteor/client/views/conference/components/AddParticipantsModal/AddParticipantsModal.stories.tsx b/apps/meteor/client/views/conference/components/AddParticipantsModal/AddParticipantsModal.stories.tsx new file mode 100644 index 0000000000000..798d4d5761ee0 --- /dev/null +++ b/apps/meteor/client/views/conference/components/AddParticipantsModal/AddParticipantsModal.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from 'storybook/actions'; + +import AddParticipantsModal from './AddParticipantsModal'; +import { conferenceAppRoot, storeCallPreferences, withCallProviders } from '../../storyFixtures'; + +/** + * Adding people to a call in progress. They become members of the *conference*, which is what lets them join — + * it deliberately puts them in no room, and whether they can read the chat is surfaced separately. + * + * The picker is the product's own user autocomplete, so it asks the server as you type. Here that endpoint + * returns a fixed handful, which keeps the story off the network while leaving the picker itself real: type a + * letter to see the options. No room is seeded, so nobody is excluded as an existing member. + */ +const meta = { + component: AddParticipantsModal, + parameters: { layout: 'centered' }, + args: { + callId: 'call-1', + rid: 'room-id', + onClose: action('onClose'), + }, + decorators: [ + withCallProviders( + conferenceAppRoot() + .withEndpoint('POST', '/v1/video-conference.add-participants', () => ({ added: ['grace'], success: true }) as any) + .withEndpoint( + 'GET', + '/v1/users.autocomplete', + () => + ({ + items: [ + { _id: 'grace', username: 'grace', name: 'Grace Hopper' }, + { _id: 'alan', username: 'alan', name: 'Alan Turing' }, + { _id: 'katherine', username: 'katherine', name: 'Katherine Johnson' }, + ], + success: true, + }) as any, + ), + ), + ], + beforeEach: storeCallPreferences({ ring: true }), +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** Nothing chosen yet, so Add is disabled. Ringing is on, which is the remembered default. */ +export const Empty: Story = {}; + +/** + * With ringing turned off — the same habit the preflight remembers, asked here for the same reason: someone + * added so they can join later is not someone to interrupt now. + */ +export const NotRinging: Story = { + beforeEach: storeCallPreferences({ ring: false }), +}; diff --git a/apps/meteor/client/views/conference/components/AddParticipantsModal/AddParticipantsModal.tsx b/apps/meteor/client/views/conference/components/AddParticipantsModal/AddParticipantsModal.tsx new file mode 100644 index 0000000000000..c6f4f489fc13e --- /dev/null +++ b/apps/meteor/client/views/conference/components/AddParticipantsModal/AddParticipantsModal.tsx @@ -0,0 +1,132 @@ +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, useQueryClient } from '@tanstack/react-query'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import UserAutoCompleteMultiple from '../../../../components/UserAutoCompleteMultiple'; +import { videoConferenceQueryKeys } from '../../../../lib/queryKeys'; +import { Rooms } from '../../../../stores'; +import { useCallRingPreference } from '../../hooks/useCallPreferences'; + +type AddParticipantsModalProps = { + callId: string; + rid: string; + onClose: () => void; +}; + +const AddParticipantsModal = ({ callId, rid, onClose }: AddParticipantsModalProps) => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + 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'); + // 100 is the members endpoints' own page size, and this asks for one page rather than paging the whole room. + // So in a room with more members than that, some of them are still offered as options. That is a redundant + // option rather than a wrong outcome: the server skips anyone already associated with the call, and the toast + // below says as much. Paging every member of a large room to tidy up a picker isn't worth the requests. + 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') }, + ); + + // Read the call again rather than waiting to be told about our own doing: the window is watching the + // conference for changes other people make, and leaning on that for a change made *here* left the + // members panel — the very panel this was opened from — still listing who was in the call before. + if (added.length) { + void queryClient.invalidateQueries({ queryKey: videoConferenceQueryKeys.conference(callId) }); + } + + 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/components/CallDeviceToggle.tsx b/apps/meteor/client/views/conference/components/CallDeviceToggle.tsx new file mode 100644 index 0000000000000..c7d5235daa33a --- /dev/null +++ b/apps/meteor/client/views/conference/components/CallDeviceToggle.tsx @@ -0,0 +1,36 @@ +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. + */ +const CallDeviceToggle = ({ device, on, label, onToggle }: CallDeviceToggleProps) => ( + } + /> +); + +export default CallDeviceToggle; diff --git a/apps/meteor/client/views/conference/components/CallMemberItem/CallMemberItem.stories.tsx b/apps/meteor/client/views/conference/components/CallMemberItem/CallMemberItem.stories.tsx new file mode 100644 index 0000000000000..639a7e1719678 --- /dev/null +++ b/apps/meteor/client/views/conference/components/CallMemberItem/CallMemberItem.stories.tsx @@ -0,0 +1,73 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from 'storybook/actions'; + +import CallMemberItem from './CallMemberItem'; +import { conferenceAppRoot, members, withCallProviders } from '../../storyFixtures'; + +/** + * One member of a call, labelled with where they stand with it. + * + * The membership record accumulates rather than replaces — `joined` never goes back to false, and a decline + * stays recorded after someone changes their mind — so these stories are the states that reading it in the + * right order produces. + */ +const meta = { + component: CallMemberItem, + parameters: { layout: 'centered' }, + args: { + hasChatAccess: true, + onRing: action('onRing'), + }, + decorators: [ + (Story) => ( + + + + ), + withCallProviders(conferenceAppRoot()), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** In the call. Nothing to say about them, and nothing to ring — they are already here. */ +export const Joined: Story = { + args: { member: members.joined }, +}; + +/** Their phone is ringing now. No ring button while it is: there is nothing to ask for. */ +export const Ringing: Story = { + args: { member: members.ringing }, +}; + +/** They turned it down. Ringing them back is exactly the point, so the button is offered. */ +export const Declined: Story = { + args: { member: members.declined }, +}; + +/** They were here and left. That beats an earlier decline, because they did answer. */ +export const Left: Story = { + args: { member: members.left }, +}; + +/** Invited and never rung — waiting for an answer nobody has been asked for yet. */ +export const Invited: Story = { + args: { member: { _id: 'invited', username: 'linus', name: 'Linus Torvalds', joined: false } }, +}; + +/** + * In the call but unable to read its chat — being added to a conference grants no room access. Marked with a + * struck-through balloon rather than being hidden, since it is the thing someone can act on. + */ +export const WithoutChatAccess: Story = { + args: { member: members.joined, hasChatAccess: false }, +}; + +/** With real names on, the username is kept alongside rather than replaced. */ +export const ShowingBothNames: Story = { + args: { member: members.joined }, + decorators: [withCallProviders(conferenceAppRoot().withSetting('UI_Use_Real_Name', true))], +}; diff --git a/apps/meteor/client/views/conference/components/CallMemberItem/CallMemberItem.tsx b/apps/meteor/client/views/conference/components/CallMemberItem/CallMemberItem.tsx new file mode 100644 index 0000000000000..51920e57bcb3a --- /dev/null +++ b/apps/meteor/client/views/conference/components/CallMemberItem/CallMemberItem.tsx @@ -0,0 +1,78 @@ +import { isRingingVideoConferenceMember } from '@rocket.chat/core-typings'; +import { Box, Icon, IconButton, Option, OptionAvatar, OptionColumn, OptionContent } from '@rocket.chat/fuselage'; +import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { useSetting } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; + +import { 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'; +import type { ConferenceMember } from '../../hooks/useConferenceEmbedded'; + +type CallMemberItemProps = { + member: ConferenceMember; + hasChatAccess: boolean; + onRing: (memberId: string) => void; +}; + +const statusLabel: Record, string> = { + left: 'Left', + declined: 'Declined', + invited: 'Waiting_for_answer', +}; + +const CallMemberItem = ({ member, hasChatAccess, onRing }: CallMemberItemProps) => { + const { t } = useTranslation(); + const useRealName = useSetting('UI_Use_Real_Name', false); + const [nameOrUsername, displayUsername] = getUserDisplayNames(member.name, member.username, useRealName); + const status = getConferenceMemberStatus(member); + + const ringing = isRingingVideoConferenceMember(member); + useRingingExpiry([ringing ? member.ringingAt : undefined]); + + return ( + + ); +}; + +export default CallMemberItem; diff --git a/apps/meteor/client/views/conference/components/CallMembersPanel/CallMembersPanel.stories.tsx b/apps/meteor/client/views/conference/components/CallMembersPanel/CallMembersPanel.stories.tsx new file mode 100644 index 0000000000000..f7970e5d881c9 --- /dev/null +++ b/apps/meteor/client/views/conference/components/CallMembersPanel/CallMembersPanel.stories.tsx @@ -0,0 +1,84 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from 'storybook/actions'; + +import CallMembersPanel from './CallMembersPanel'; +import { conferenceAppRoot, members, withCallProviders } from '../../storyFixtures'; +import { buildChatAccess } from '../../testFixtures'; + +/** + * The people panel: who is in the call, and who isn't, under headings that count them. + * + * "Add people" is only offered where there is a room to add them from — a member who joined from outside the + * room has none. + */ +const meta = { + component: CallMembersPanel, + parameters: { layout: 'fullscreen' }, + args: { + callId: 'call-1', + rid: 'room-id', + onClose: action('onClose'), + }, + decorators: [ + (Story) => ( + + + + ), + withCallProviders( + conferenceAppRoot() + .withEndpoint('POST', '/v1/video-conference.ring', () => ({ success: true }) as any) + .withEndpoint('POST', '/v1/video-conference.add-participants', () => ({ added: [], success: true }) as any) + .withEndpoint('GET', '/v1/users.autocomplete', () => ({ items: [], success: true }) as any), + ), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** Everyone is here, so there is no second heading at all. */ +export const EveryoneJoined: Story = { + args: { members: [members.joined, { ...members.left, leftAt: undefined }] }, +}; + +/** The mix worth looking at: two in the call, and three who aren't, each labelled with why. */ +export const MixedStates: Story = { + args: { + members: [ + members.joined, + { ...members.left, _id: 'present', name: 'Margaret Hamilton', username: 'margaret', leftAt: undefined }, + members.ringing, + members.declined, + members.left, + ], + }, +}; + +/** + * Somebody in the call can't read its chat. It shows against the member rather than as a banner here — the + * banner is `ChatAccessNotice`'s job. + */ +export const WithoutChatAccess: Story = { + args: { + members: [members.joined, members.left], + chatAccess: buildChatAccess({ membersWithoutAccess: ['joined'] }), + }, +}; + +/** + * Nobody has answered yet — a call just started, ringing everyone. Every row offers a ring except the ones + * already ringing. + */ +export const NobodyAnsweredYet: Story = { + args: { members: [members.ringing, { ...members.declined, declined: false, declinedAt: undefined }] }, +}; + +/** + * No room behind the call, so there is nowhere to add people from and the button isn't offered. + */ +export const WithoutARoom: Story = { + args: { rid: undefined, members: [members.joined, members.ringing] }, +}; diff --git a/apps/meteor/client/views/conference/components/CallMembersPanel/CallMembersPanel.tsx b/apps/meteor/client/views/conference/components/CallMembersPanel/CallMembersPanel.tsx new file mode 100644 index 0000000000000..3c2347a51e54c --- /dev/null +++ b/apps/meteor/client/views/conference/components/CallMembersPanel/CallMembersPanel.tsx @@ -0,0 +1,86 @@ +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 { hasConferenceChatAccess } from '../../../../../lib/videoConference/chatAccess'; +import { MembersListDivider } from '../../../room/contextualBar/RoomMembers/MembersListDivider'; +import type { ConferenceChatAccess, ConferenceMember } from '../../hooks/useConferenceEmbedded'; +import AddParticipantsModal from '../AddParticipantsModal/AddParticipantsModal'; +import CallMemberItem from '../CallMemberItem/CallMemberItem'; +import CallPanelHeader from '../CallPanelHeader'; + +type CallMembersPanelProps = { + callId: string; + rid?: string; + members: ConferenceMember[]; + chatAccess?: ConferenceChatAccess; + onClose: () => void; +}; + +const CallMembersPanel = ({ callId, rid, members, chatAccess, onClose }: CallMembersPanelProps) => { + const { t } = useTranslation(); + const setModal = useSetModal(); + const dispatchToastMessage = useToastMessageDispatch(); + const ring = useEndpoint('POST', '/v1/video-conference.ring'); + + const [present, absent] = useMemo( + () => [members.filter(isInVideoConference), members.filter((member) => !isInVideoConference(member))], + [members], + ); + + const { mutate: ringMember } = useMutation({ + mutationFn: (memberId: string) => ring({ callId, userId: memberId }), + onError: (error) => dispatchToastMessage({ type: 'error', message: error }), + }); + + const renderMember = (member: ConferenceMember) => ( + + ); + + return ( + <> + + {rid && ( + + )} + + + {/* Said out loud, because the rows are Fuselage `Option`s — `li` elements in a plain box, which made + them neither countable nor individually referrable. + + A list per group rather than one list around everything: the dividers between the groups are not + list items, and a `list` whose children are not `listitem`s is a list a screen reader may skip or + miscount. Each group is its own list, named by the divider that heads it, and the box around them + is a `group` so the panel still has one handle. */} + + {present.length > 0 && ( + <> + + + {present.map(renderMember)} + + + )} + {absent.length > 0 && ( + <> + + + {absent.map(renderMember)} + + + )} + + + ); +}; + +export default CallMembersPanel; diff --git a/apps/meteor/client/views/conference/components/CallPanel.tsx b/apps/meteor/client/views/conference/components/CallPanel.tsx new file mode 100644 index 0000000000000..03b8251941be3 --- /dev/null +++ b/apps/meteor/client/views/conference/components/CallPanel.tsx @@ -0,0 +1,133 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box } from '@rocket.chat/fuselage'; +import { Contextualbar } from '@rocket.chat/ui-client'; +import type { ReactNode } from 'react'; + +import { CONFERENCE_THEMED_CLASS } from '../panelStyles'; + +type CallPanelProps = { + visible: boolean; + /** + * Come up from the bottom over the whole window instead of taking width from the call — used on viewports + * too narrow to split, where a docked panel leaves both halves too small to use. + */ + sheet?: boolean; + children: ReactNode; +}; + +const PANEL_WIDTH = 400; + +/** + * Never wider than the window. `minWidth` is what makes the panel keep its width instead of being squeezed by + * the call beside it, and on the sheet path — chosen for viewports too narrow to split — that same floor would + * push the panel's own controls off the screen. The clamp leaves the split layout untouched, since it is only + * used above the `md` breakpoint, which is wider than the panel. + */ +const PANEL_INLINE_SIZE = `min(${PANEL_WIDTH}px, 100vw)`; + +const CLOSE_MS = 200; + +/** + * A phone-sized window has no room to split: a docked panel took half of it, which left the call a sliver and + * the chat a message list two lines tall above its own composer — neither of them usable. So there the panel + * stops being a column beside the call and becomes a sheet that rises over the whole window, the way a phone + * shows a screen that owns your attention until you dismiss it. + * + * `fixed` rather than absolute, because the sheet covers the call's bars too: the panel is a sibling of the + * call area, so anything positioned within that row would stop at the top bar and leave the controls beneath it + * reachable behind the sheet. Its own header carries the close button, which is the way back out. + * + * Rounded at the top and inset by nothing else, so the dark call shows at the corners — enough to read as + * something laid over the call rather than a new page — and padded for the home indicator, which otherwise sits + * on top of the chat's composer. + */ +const sheetStyle = css` + /* A hair of the call left showing down both sides, for the same reason as the gap above: it reads as + something laid over the call rather than as the window's new contents. */ + inset-inline: 2px; + inset-block-end: 0; + + /* A sheet doesn't touch the top: leaving the call visible above it is what says this is laid *over* the call + rather than being a page of its own. Proportional, so a landscape phone doesn't spend a tenth of its height + on the gap, and capped, so a tall window doesn't open a chasm. */ + inset-block-start: clamp(16px, 6dvh, 48px); + + border-start-start-radius: 12px; + border-start-end-radius: 12px; + + /* The product's own elevation-2 pair, scaled up and aimed upwards: a sheet is a much larger surface than the + dropdown that shadow was drawn for, and what it has to lift away from is above it. It reads when there is + something bright behind — a camera with a picture in it — and costs nothing over a black tile. */ + box-shadow: + 0 -2px 4px 0 var(--rcx-color-shadow-elevation-2x, rgba(47, 52, 61, 0.08)), + 0 -8px 24px 0 var(--rcx-color-shadow-elevation-2y, rgba(47, 52, 61, 0.12)); + + padding-block-end: env(safe-area-inset-bottom, 0px); + transition: transform ${CLOSE_MS}ms ease; + will-change: transform; +`; + +/** + * A side panel for conference content (the chat, the members). + * + * It is the product's own contextual bar, so a panel beside the call has the same edges, background and + * elevation as one beside a room. What it adds is opening and closing: the outer width animates to zero while + * the inner box keeps its own, so the content slides out rather than reflowing as it goes. + * + * It is a sibling of the call area *above* the call bar, never a child of it, so that animation never reflows + * the bar. + */ +const CallPanel = ({ visible, sheet = false, children }: CallPanelProps) => { + // The docked panel's width is what animates; a sheet's is the window's, and it animates its position instead. + const dockedInlineSize = visible ? PANEL_INLINE_SIZE : 0; + + // `Contextualbar` sets `insetBlockStart`, `insetInlineEnd`, `height` and `zIndex` on its own Box, and a Box + // prop beats a class whichever order the two stylesheets land in — the sheet's `top` and derived height lost + // to `top: 0` and `height: 100%` in silence, leaving it flush with the top of the window, and its inline end + // would lose the same way. So the sheet hands all four back as props and sets its own insets in the class; + // the docked panel passes nothing and keeps every default it had. + const sheetOverrides = sheet ? { insetBlockStart: undefined, insetInlineEnd: undefined, height: undefined, zIndex: 100 } : {}; + + return ( + + + {children} + + + ); +}; + +export default CallPanel; diff --git a/apps/meteor/client/views/conference/components/CallPanelHeader.tsx b/apps/meteor/client/views/conference/components/CallPanelHeader.tsx new file mode 100644 index 0000000000000..9d5dbf2e4df31 --- /dev/null +++ b/apps/meteor/client/views/conference/components/CallPanelHeader.tsx @@ -0,0 +1,34 @@ +import { ContextualbarActions, ContextualbarClose, ContextualbarHeader, ContextualbarTitle } from '@rocket.chat/ui-client'; +import type { ReactNode } from 'react'; + +type CallPanelHeaderProps = { + title: ReactNode; + /** + * What the title *says*, for a title assembled from more than words — an icon between them lands in the + * middle of the name otherwise. It goes on the heading, which is the element a name can be put on: a + * `generic` element like a `span` cannot be named at all, so labelling the contents achieves nothing. + */ + titleLabel?: string; + /** 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, titleLabel, children, onClose }: CallPanelHeaderProps) => ( + + {title} + + {children} + + + +); + +export default CallPanelHeader; diff --git a/apps/meteor/client/views/conference/components/CallTimer/CallTimer.spec.tsx b/apps/meteor/client/views/conference/components/CallTimer/CallTimer.spec.tsx new file mode 100644 index 0000000000000..886de6aaa3db6 --- /dev/null +++ b/apps/meteor/client/views/conference/components/CallTimer/CallTimer.spec.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react'; + +import CallTimer from './CallTimer'; + +/** + * The timer reads the call's start from a conference that arrives a render after the window mounts, so what it + * must not do is decide the call's age from the first render it happens to get. + */ +afterEach(() => { + jest.useRealTimers(); +}); + +it('counts from the call it is given, not from when it mounted', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:10:00.000Z')); + + render(); + + expect(screen.getByText('08:37')).toBeInTheDocument(); +}); + +it('picks the call up when it arrives a render later', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:10:00.000Z')); + + // What the window actually does: the conference is still loading, so there is no start yet. + const { rerender } = render(); + expect(screen.getByText('00:00')).toBeInTheDocument(); + + rerender(); + + expect(screen.getByText('05:00')).toBeInTheDocument(); +}); + +it('shows the hour once a call has been running that long', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-01-01T02:00:04.000Z')); + + render(); + + expect(screen.getByText('2:00:04')).toBeInTheDocument(); +}); + +// A workstation whose clock sits ahead of the server's would otherwise render a negative duration. +it('does not count backwards for a start in the future', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + render(); + + expect(screen.getByText('00:00')).toBeInTheDocument(); +}); + +// A counting clock is a live region, and its role is what anything else can refer to it by. +it('is a timer', () => { + render(); + + expect(screen.getByRole('timer')).toBeInTheDocument(); +}); diff --git a/apps/meteor/client/views/conference/components/CallTimer/CallTimer.tsx b/apps/meteor/client/views/conference/components/CallTimer/CallTimer.tsx new file mode 100644 index 0000000000000..8a2df99d8fb18 --- /dev/null +++ b/apps/meteor/client/views/conference/components/CallTimer/CallTimer.tsx @@ -0,0 +1,46 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useEffect, useState } from 'react'; + +type CallTimerProps = { startAt?: Date }; + +const CallTimer = ({ startAt }: CallTimerProps) => { + // The moment is ticked and the elapsed time derived from it, rather than the start being captured once: the + // conference this reads from arrives a render later than the timer mounts, so freezing the start anchored a + // call that had been running for minutes at zero and left it counting from there. + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const interval = setInterval(() => setNow(Date.now()), 1000); + + return () => clearInterval(interval); + }, []); + + const start = startAt?.getTime(); + // Nothing is known about the call's age until it arrives, and a clock skewed ahead of the server must not + // count backwards. + const elapsedTime = start === undefined ? 0 : Math.max(0, now - start); + + const totalSeconds = Math.floor(elapsedTime / 1000); + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = Math.floor(totalSeconds % 60); + + const hoursStr = hours.toString().padStart(2, '0'); + const minutesStr = minutes.toString().padStart(2, '0'); + const secondsStr = seconds.toString().padStart(2, '0'); + + return ( + // `role='timer'` because that is what it is, and because a bare `