Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
1548ff0
feat(video-conf): add the shared-package plumbing for the conference …
rodrigok Aug 26, 2026
6e75f46
refactor(video-conf): let a route render without the navigation chrome
rodrigok Aug 26, 2026
1d73cfb
feat(video-conf): add the conference window views
rodrigok Aug 26, 2026
3949c5a
feat(video-conf): add the ongoing-calls list and its navbar item
rodrigok Aug 26, 2026
7c2db19
chore(i18n): add the strings the conference window uses
rodrigok Aug 26, 2026
a12f52d
fix(video-conf): say the call controls' state and counts to a screen …
rodrigok Aug 26, 2026
0c741b0
fix(video-conf): correct the conference window's edge cases
rodrigok Aug 26, 2026
d6e770a
test(video-conf): make the conference specs assert what they claim
rodrigok Aug 26, 2026
1cf0e56
refactor(video-conf): drop the unused declined-calls toggle
rodrigok Aug 26, 2026
bbdb59f
chore(video-conf): mark the conference-window changeset as groundwork
rodrigok Aug 26, 2026
f1e48c0
fix(video-conf): stop mirroring the muted-mic icon in the preflight
rodrigok Aug 26, 2026
eb215dd
refactor(video-conf): build the call window's controls from IconButton
rodrigok Aug 26, 2026
262eb00
fix(storybook): load the real translations and shim the node built-ins
rodrigok Aug 27, 2026
0ac0863
refactor(video-conf): move the call window's components under compone…
rodrigok Aug 27, 2026
0f9150f
test(video-conf): add stories for the conference window components
rodrigok Aug 27, 2026
ca3e12e
fix(video-conf): count the call timer from the call, not from the mount
rodrigok Aug 27, 2026
48e42ef
refactor(video-conf): leave the call bar to the native-video branch
rodrigok Aug 27, 2026
1e75ce7
fix(video-conf): answer the review on the call window's stories and l…
rodrigok Aug 27, 2026
ac7d861
chore(video-conf): tidy the storybook note, the dependency order and …
rodrigok Aug 28, 2026
b5bcf83
chore(video-conf): drop the participants branch from the conference m…
rodrigok Aug 28, 2026
351e43d
chore(video-conf): follow the chat mode's new default on the client
rodrigok Aug 28, 2026
8ea4a60
chore(video-conf): ring one member from the members panel
rodrigok Aug 28, 2026
f683597
chore(video-conf): follow LegacyRoomManager to its new module
rodrigok Aug 28, 2026
40599f5
chore(video-conf): fall back to main_room until the window registers …
rodrigok Aug 29, 2026
893c7f4
chore(video-conf): answer the review on the layout flag and stray edits
rodrigok Aug 29, 2026
432d4e7
refactor(video-conf): one call row instead of three components
rodrigok Aug 29, 2026
a289913
refactor(video-conf): fewer components for the same conference UI
rodrigok Aug 29, 2026
9707e7d
fix(video-conf): a call that was answered stops reading as ringing
rodrigok Aug 29, 2026
e0b2029
chore(video-conf): keep only the call preferences this window uses
rodrigok Aug 29, 2026
faba010
fix(video-conf): report the departure that happened, and take one cha…
rodrigok Aug 31, 2026
465f4bc
fix(video-conf): read this window's standing from the server as well …
rodrigok Aug 31, 2026
183c4b4
fix(video-conf): give the call UI the roles and names it was missing
rodrigok Aug 31, 2026
656a297
fix(video-conf): name the call's header landmark
rodrigok Aug 31, 2026
c3c690a
fix(video-conf): read the call again after adding people to it
rodrigok Sep 1, 2026
f1f1326
fix(video-conf): follow RestApiClient to where develop moved it
rodrigok Sep 1, 2026
8e9f7aa
fix(video-conf): make the accessibility work actually reach the tree
rodrigok Sep 1, 2026
639fd6b
fix(video-conf): keep the call surface dark whatever the theme
rodrigok Sep 3, 2026
ce87094
fix(video-conf): open the call's panels as a sheet where the window c…
rodrigok Sep 4, 2026
9770402
fix(video-conf): fit the preflight on a short screen
rodrigok Sep 4, 2026
b25249c
test(video-conf): story the phone shapes for the preflight and the ca…
rodrigok Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/conference-window-groundwork.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 15 additions & 3 deletions apps/meteor/.storybook/decorators.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<MockedAppRoot>
<ServerContextMock {...parameters.serverContext}>
<TranslationContextMock {...parameters.translationContext}>
<ModalContextMock {...parameters.modalContext}>
<RouterContextMock {...parameters.routerContext}>
<div className='color-primary-font-color rcx-content--main'>
<Story />
</div>
<I18nextProvider i18n={storybookI18n}>
<div className='color-primary-font-color rcx-content--main'>
<Story />
</div>
</I18nextProvider>
</RouterContextMock>
</ModalContextMock>
</TranslationContextMock>
Expand Down
11 changes: 11 additions & 0 deletions apps/meteor/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Comment thread
rodrigok marked this conversation as resolved.
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')],
};
Expand All @@ -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')),
Expand Down
47 changes: 47 additions & 0 deletions apps/meteor/client/components/AppLayoutThemeWrapper.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<PaletteStyleTag theme={theme} tagId={REFERENCE_TAG_ID} />);
const css = cssOf(REFERENCE_TAG_ID);
unmount();
return css;
};

const renderWrapper = (themeAppearence: string, theme?: 'dark') =>
render(<AppLayoutThemeWrapper theme={theme}>{null}</AppLayoutThemeWrapper>, {
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'));
});
});
43 changes: 40 additions & 3 deletions apps/meteor/client/components/AppLayoutThemeWrapper.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useThemeMode>, 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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a route does not pin a theme, this unconditional useThemeMode() still subscribes the global wrapper to the user's theme preference even though resolveTheme immediately ignores it. Split pinned and unpinned rendering into separate components so ordinary routes keep the previous subscription and rerender behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/client/components/AppLayoutThemeWrapper.tsx, line 42:

<comment>When a route does not pin a theme, this unconditional `useThemeMode()` still subscribes the global wrapper to the user's theme preference even though `resolveTheme` immediately ignores it. Split pinned and unpinned rendering into separate components so ordinary routes keep the previous subscription and rerender behavior.</comment>

<file context>
@@ -1,14 +1,51 @@
+
+const AppLayoutThemeWrapper = ({ children, theme: pinned }: AppLayoutThemeWrapperProps) => {
 	const dark = useDarkMode();
+	const mode = useThemeMode();
+
+	const theme = resolveTheme(pinned, mode, dark);
</file context>


const theme = resolveTheme(pinned, mode, dark);

return (
<>
<PaletteStyleTag theme={dark ? 'dark' : 'light'} tagId='app-layout-palette' />
<PaletteStyleTag theme={theme} tagId='app-layout-palette' />
{children}
</>
);
Expand Down
78 changes: 78 additions & 0 deletions apps/meteor/client/components/CallParticipants.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof CallParticipants>[0], avatars = true) =>
render(<CallParticipants {...props} />, {
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);
});
73 changes: 73 additions & 0 deletions apps/meteor/client/components/CallParticipants.stories.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<Box backgroundColor='surface-light' padding={12} borderRadius='x4'>
<Story />
</Box>
),
withCallProviders(conferenceAppRoot()),
],
} satisfies Meta<typeof CallParticipants>;

export default meta;

type Story = StoryObj<typeof meta>;

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 },
};
Loading
Loading