Skip to content

Commit d57634e

Browse files
rodrigokclaude
andcommitted
feat(video-conf): show who is in a call as faces, not as a number
"2 people in the call" answers a worse question than three avatars do. What decides whether to walk into a call is *who* is already in it. `CallParticipants` draws a face for each of the people it is given and turns whatever is left over into a "+N" shaped and sized like one more avatar, so a row reads as a group of people rather than as faces followed by a statistic. The whole count stays as the group's label — for anyone who cannot see the avatars, and because "+2" means nothing without a total. Two surfaces, one component: - the sidebar list, under a `Participants` label, from `participants` on the joinable payload. The server caps that at three, since a call in a busy channel would otherwise send a roster to draw three avatars. - the preflight when joining, under a "Participants in the call" label and five at a time — a screen has more room than a row. These come from the call window's own copy of the members, so nothing extra travels for them. `participants` returns to the joinable payload, and `CallParticipants` to the tree; both were cut from the first release in 12ce0c2, which recorded the design for exactly this. The joinable fixture now carries participants, and the spec asserts the cap, that the payload carries nothing beyond what a face needs, and that people who were invited but never turned up get no face. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent b746f76 commit d57634e

14 files changed

Lines changed: 234 additions & 12 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { mockAppRoot } from '@rocket.chat/mock-providers';
2+
import { render, screen } from '@testing-library/react';
3+
4+
import CallParticipants from './CallParticipants';
5+
6+
const person = (username: string) => ({ _id: username, username });
7+
8+
const renderParticipants = (props: Parameters<typeof CallParticipants>[0]) =>
9+
render(<CallParticipants {...props} />, { wrapper: mockAppRoot().withJohnDoe().build() });
10+
11+
// Faces say *who* is in the call, which is usually what decides whether to join. The count alone never did.
12+
it('shows a face for each of the people it was given', () => {
13+
const { container } = renderParticipants({ people: [person('alice'), person('bob')], total: 2 });
14+
15+
expect(container.querySelectorAll('img')).toHaveLength(2);
16+
expect(screen.queryByText(/^\+/)).not.toBeInTheDocument();
17+
});
18+
19+
// Only a few travel with the call, so whatever is left over is a number at the end of the row.
20+
it('counts off the ones it has no room for', () => {
21+
renderParticipants({ people: [person('alice'), person('bob'), person('carol')], total: 12 });
22+
23+
expect(screen.getByText('+9')).toBeInTheDocument();
24+
});
25+
26+
it('still says how many there are, for anyone who cannot see the faces', () => {
27+
renderParticipants({ people: [person('alice')], total: 4 });
28+
29+
expect(screen.getByTitle('__count__people_in_the_call')).toBeInTheDocument();
30+
});
31+
32+
// An older server, or a call whose members did not travel with it.
33+
it('falls back to the number when there are no faces to show', () => {
34+
const { container } = renderParticipants({ people: [], total: 3 });
35+
36+
expect(screen.getByText('__count__people_in_the_call')).toBeInTheDocument();
37+
expect(container.querySelectorAll('img')).toHaveLength(0);
38+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { IUser } from '@rocket.chat/core-typings';
2+
import { Box } from '@rocket.chat/fuselage';
3+
import { UserAvatar } from '@rocket.chat/ui-avatar';
4+
import { useTranslation } from 'react-i18next';
5+
6+
type CallParticipantsProps = {
7+
/** A few of the people in the call — whoever is to get a face. Capped by `CALL_FACES_SHOWN`. */
8+
people: (Pick<IUser, '_id'> & Partial<Pick<IUser, 'username'>>)[];
9+
/** How many are in the call altogether, which is what the "+N" is worked out from. */
10+
total: number;
11+
/** Avatar size, since a sidebar row and a full screen don't want the same one. */
12+
size?: 'x18' | 'x24';
13+
};
14+
15+
/**
16+
* Who is already in a call, as faces rather than a number.
17+
*
18+
* Faces say *who* is in there, which is usually what decides whether to walk in; a count never did. Only a few of
19+
* them are shown, so whatever is left over becomes a "+N" at the end — shaped and sized like one more avatar, so
20+
* the row reads as a group of people rather than as faces followed by a statistic.
21+
*
22+
* The count is still there as the group's label, both for anyone who cannot see the avatars and because "+2" only
23+
* means something next to a total.
24+
*/
25+
const CallParticipants = ({ people, total, size = 'x18' }: CallParticipantsProps) => {
26+
const { t } = useTranslation();
27+
28+
const label = t('__count__people_in_the_call', { count: total });
29+
30+
// Nothing to show faces from — an older server, or a call whose members didn't travel with it.
31+
if (!people.length) {
32+
return (
33+
<Box fontScale='micro' color='hint'>
34+
{label}
35+
</Box>
36+
);
37+
}
38+
39+
const remaining = total - people.length;
40+
41+
return (
42+
<Box display='flex' alignItems='center' aria-label={label} title={label}>
43+
{people.map(({ _id, username }, index) => (
44+
// Overlapped a little, so a row of faces reads as one group rather than a list.
45+
<Box key={_id} marginInlineStart={index === 0 ? 0 : -4}>
46+
<UserAvatar username={username ?? ''} size={size} />
47+
</Box>
48+
))}
49+
{remaining > 0 && (
50+
// One more avatar in the row, carrying a number instead of a face. It keeps the avatar's shape and
51+
// size but is allowed to grow with its digits, since a call can hold a lot of people.
52+
<Box
53+
marginInlineStart={-4}
54+
minWidth={size}
55+
height={size}
56+
paddingInline={4}
57+
display='flex'
58+
alignItems='center'
59+
justifyContent='center'
60+
borderRadius='x4'
61+
backgroundColor='surface-neutral'
62+
color='hint'
63+
fontScale='micro'
64+
>
65+
{`+${remaining}`}
66+
</Box>
67+
)}
68+
</Box>
69+
);
70+
};
71+
72+
export default CallParticipants;

apps/meteor/client/components/OngoingCalls/CallSummary.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { Box, Icon } from '@rocket.chat/fuselage';
33
import type { ReactNode } from 'react';
44
import { useTranslation } from 'react-i18next';
55

6+
import CallParticipants from '../CallParticipants';
7+
68
type CallSummaryProps = {
79
call: JoinableVideoConference;
810
/** Ringing calls say so in red; the rest are just calls. */
@@ -27,8 +29,13 @@ const CallSummary = ({ call, ringing, children }: CallSummaryProps) => {
2729
<Box fontScale='p2b' color='default' withTruncatedText>
2830
{call.name}
2931
</Box>
30-
<Box fontScale='micro' color='hint'>
31-
{t('__count__people_in_the_call', { count: call.usersCount })}
32+
{/* Named as well as shown: under a call's title, a bare row of faces is a row of faces of nobody in
33+
particular until you know it is the people already in there. */}
34+
<Box display='flex' alignItems='center' style={{ gap: 4 }}>
35+
<Box fontScale='micro' color='hint' flexShrink={0}>
36+
{t('Participants')}
37+
</Box>
38+
<CallParticipants people={call.participants} total={call.usersCount} />
3239
</Box>
3340
</Box>
3441
{children}

apps/meteor/client/components/OngoingCalls/OngoingCalls.spec.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,9 @@ describe('a call that is ringing', () => {
143143

144144
expect(await screen.findByText('Alice')).toBeInTheDocument();
145145
expect(screen.getByText('Incoming_call')).toBeInTheDocument();
146-
// The ringing item says the same thing about itself as the calls below: how many are in there.
147-
expect(screen.getByText('__count__people_in_the_call')).toBeInTheDocument();
146+
// The ringing item says the same thing about itself as the calls below: who is in there, named and shown.
147+
expect(screen.getByText('Participants')).toBeInTheDocument();
148+
expect(screen.getByTitle('__count__people_in_the_call')).toBeInTheDocument();
148149
});
149150

150151
it('says how many are incoming when there is more than one', async () => {

apps/meteor/client/views/conference/ConferenceEmbeddedPage.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import ConferenceIframe from './ConferenceIframe';
1414
import ConferencePageError from './ConferencePageError';
1515
import ConferencePreflight from './ConferencePreflight';
1616
import ConferenceUnauthorizedPage from './ConferenceUnauthorizedPage';
17+
import { PREFLIGHT_FACES_SHOWN } from '../../../lib/videoConference/constants';
1718
import PageLoading from '../root/PageLoading';
1819
import CallBar from './components/CallBar/CallBar';
1920
import CallBarAction from './components/CallBar/CallBarAction';
@@ -109,8 +110,9 @@ const ConferenceEmbeddedPage = ({ callId }: ConferenceEmbeddedPageProps) => {
109110
const { showUnread, unreadCount, unreadVariant, unreadTitle } = useUnreadDisplay(subscription ?? emptyUnreadData);
110111
const unread = !chatVisible && showUnread ? unreadCount.total : 0;
111112

112-
// How many people are actually in the call, which is the number worth glancing at.
113-
const presentCount = call.members.filter(isInVideoConference).length;
113+
// Who is actually in the call — the faces worth glancing at, and how many there are altogether.
114+
const present = useMemo(() => call.members.filter(isInVideoConference), [call.members]);
115+
const presentCount = present.length;
114116

115117
// Where the call puts its own controls — see `actionsContainer`. Created up front rather than captured from
116118
// a ref, so it is non-null on the very first render: a ref would still be empty then, and the call would
@@ -197,6 +199,9 @@ const ConferenceEmbeddedPage = ({ callId }: ConferenceEmbeddedPageProps) => {
197199
action={call.placing ? 'start' : 'join'}
198200
isDirect={call.canRing}
199201
canName={call.canRename}
202+
// The same faces the sidebar showed on the way here, from this window's own copy of the members —
203+
// a screen has room for more of them than a row does.
204+
participants={{ people: present.slice(0, PREFLIGHT_FACES_SHOWN), total: presentCount }}
200205
capabilities={call.capabilities}
201206
onConfirm={(preferences, name) => conference.join({ state: preferences, name })}
202207
onCancel={leaveNow}

apps/meteor/client/views/conference/ConferencePreflight.spec.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,28 @@ it('can be walked away from', async () => {
8383
expect(onConfirm).not.toHaveBeenCalled();
8484
});
8585

86+
// The other half of what this screen is asking: not only how you will arrive, but who is already in there.
87+
describe('who is already in the call', () => {
88+
const people = ['alice', 'bob', 'carol', 'dave', 'erin'].map((username) => ({ _id: username, username }));
89+
90+
it('shows their faces when joining, under a label saying what they are', async () => {
91+
const { container } = renderPreflight({ action: 'join', participants: { people, total: 8 } });
92+
93+
expect(await screen.findByText('Participants_in_the_call')).toBeInTheDocument();
94+
expect(container.querySelectorAll('img')).toHaveLength(5);
95+
expect(screen.getByText('+3')).toBeInTheDocument();
96+
});
97+
98+
// Nobody is in a call that hasn't started, so there is nothing to show and no space to leave for it.
99+
it('shows nothing when starting a call', async () => {
100+
renderPreflight({ action: 'start', participants: { people, total: 8 } });
101+
102+
await screen.findByRole('button', { name: 'Start_call' });
103+
expect(screen.queryByText('Participants_in_the_call')).not.toBeInTheDocument();
104+
expect(screen.queryByTitle('__count__people_in_the_call')).not.toBeInTheDocument();
105+
});
106+
});
107+
86108
describe('naming the call', () => {
87109
it('is not offered to everyone', async () => {
88110
renderPreflight();

apps/meteor/client/views/conference/ConferencePreflight.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { VideoConferenceCapabilities } from '@rocket.chat/core-typings';
22
import { Box, Button, ButtonGroup, Field, FieldRow, Icon, TextInput } from '@rocket.chat/fuselage';
33
import { useBreakpoints } from '@rocket.chat/fuselage-hooks';
4+
import type { ComponentProps } from 'react';
45
import { useCallback, useState } from 'react';
56
import { useTranslation } from 'react-i18next';
67

@@ -9,6 +10,7 @@ import CallDeviceToggle from './CallDeviceToggle';
910
import { useCallDevicePreview } from './hooks/useCallDevicePreview';
1011
import type { CallPreferences } from './hooks/useCallPreferences';
1112
import { useCallPreferences } from './hooks/useCallPreferences';
13+
import CallParticipants from '../../components/CallParticipants';
1214

1315
type ConferencePreflightProps = {
1416
/** What the call is called, or would be: its own name, or the room it belongs to. */
@@ -24,6 +26,11 @@ type ConferencePreflightProps = {
2426
canName: boolean;
2527
/** What to offer as the name. Defaults to what the call is called, which is right for one that already exists. */
2628
defaultName?: string;
29+
/**
30+
* Who is already in the call, as faces. Only meaningful for a join — nobody is in a call that hasn't started —
31+
* and it is the same thing the sidebar shows about the call, which is what the reader saw on their way here.
32+
*/
33+
participants?: ComponentProps<typeof CallParticipants>;
2734
capabilities: VideoConferenceCapabilities;
2835
onConfirm: (preferences: CallPreferences, name: string) => void;
2936
onCancel: () => void;
@@ -52,6 +59,7 @@ const ConferencePreflight = ({
5259
isDirect,
5360
canName,
5461
defaultName,
62+
participants,
5563
capabilities,
5664
onConfirm,
5765
onCancel,
@@ -248,6 +256,21 @@ const ConferencePreflight = ({
248256
</Box>
249257
)}
250258

259+
{/* Who is in there already, which is the other half of what the reader is deciding: the call has a name
260+
above and people in it here. Faces rather than a count, and the same ones the sidebar showed them. */}
261+
{action === 'join' && participants && (
262+
<Box marginBlockStart={16} display='flex' flexDirection='column' alignItems='center'>
263+
{/* Said in words as well as in faces: a row of avatars is only obvious once you already know what
264+
it is a row of, and the count alone lives in the group's label where nothing reads it aloud. */}
265+
<Box fontScale='c1' color='hint'>
266+
{t('Participants_in_the_call')}
267+
</Box>
268+
<Box marginBlockStart={8}>
269+
<CallParticipants {...participants} size='x24' />
270+
</Box>
271+
</Box>
272+
)}
273+
251274
{/* Nobody's phone is ringing yet — going in is what rings it, and saying so is what makes the wait
252275
afterwards make sense. */}
253276
{action === 'start' && isDirect && (

apps/meteor/client/views/conference/testFixtures.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ export const buildJoinableCall = (
1515
name: `Call ${overrides.callId}`,
1616
createdAt: new Date('2026-08-03T10:00:00.000Z'),
1717
usersCount: 2,
18+
// As many faces as `usersCount` says are in it, so a fixture doesn't accidentally claim a "+N".
19+
participants: [
20+
{ _id: 'one', username: 'one', name: 'One' },
21+
{ _id: 'two', username: 'two', name: 'Two' },
22+
],
1823
joined: false,
1924
declined: false,
2025
...overrides,

apps/meteor/lib/videoConference/constants.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ export const availabilityErrors = {
66
NO_APP: 'no-videoconf-provider-app',
77
};
88

9+
/**
10+
* How many of the people in a call are shown as faces in the sidebar list before the rest become a "+N".
11+
*
12+
* Shared with the server, which slices the joinable payload to it: sending more would be sending a roster nobody
13+
* draws. Three is what fits beside a call's name in a sidebar row without pushing it out.
14+
*/
15+
export const CALL_FACES_SHOWN = 3;
16+
17+
/**
18+
* The same, on the preflight — a screen rather than a row, so it has room for more of them before the count takes
19+
* over. The people come from the call window's own copy of the members, so nothing has to travel for these.
20+
*/
21+
export const PREFLIGHT_FACES_SHOWN = 5;
22+
923
/** Whether this many recipients is a set worth ringing. See `VIDEO_CONF_RINGING_LIMIT` for why there is a cap. */
1024
export const shouldRingVideoConference = (recipientCount: number): boolean =>
1125
recipientCount > 0 && recipientCount <= VIDEO_CONF_RINGING_LIMIT;

apps/meteor/server/services/video-conference/service.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ import {
5454
} from '../../../lib/videoConference/callHistory';
5555
import { resolveChatAccessMode } from '../../../lib/videoConference/chatAccess';
5656
import { conferenceNameFor } from '../../../lib/videoConference/conferenceName';
57-
import { availabilityErrors, shouldRingVideoConference } from '../../../lib/videoConference/constants';
57+
import { availabilityErrors, CALL_FACES_SHOWN, shouldRingVideoConference } from '../../../lib/videoConference/constants';
5858
import { isUnaskedConferenceMember } from '../../../lib/videoConference/memberStatus';
5959
import { expiredPresenceLeases, INFERRED_LEAVE_REASONS } from '../../../lib/videoConference/presence';
6060
import { readSecondaryPreferred } from '../../database/readSecondaryPreferred';
@@ -1432,6 +1432,9 @@ export class VideoConfService extends ServiceClassInternal implements IVideoConf
14321432
name: conferenceNameFor(call, uid, subscription?.fname || subscription?.name) || (await this.getRoomName(call.rid)),
14331433
createdAt: call.createdAt,
14341434
usersCount: present.length,
1435+
// A few of them travel with the call so the list can show faces. Capped here rather than at the
1436+
// reader, because a call in a busy channel would otherwise send a roster to draw three avatars.
1437+
participants: present.slice(0, CALL_FACES_SHOWN).map(({ _id, username, name }) => ({ _id, username, name })),
14351438
joined: !!member && isInVideoConference(member),
14361439
declined: !!member?.declined,
14371440
// Whether that ring is still live is the reader's to decide, so the moment is what travels.

0 commit comments

Comments
 (0)