Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/add_per_user_voice_activity_ring_indicators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

# add per user voice activity ring indicators
6 changes: 6 additions & 0 deletions src/app/features/room-nav/RoomNavItem.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { MouseEventHandler, MouseEvent } from 'react';
import { forwardRef, startTransition, useState, useEffect } from 'react';
import classNames from 'classnames';
import type { Room } from '$types/matrix-sdk';
import { RoomEvent as RoomEventEnum } from '$types/matrix-sdk';
import {
Expand Down Expand Up @@ -73,6 +74,7 @@ import { useRoomMenuActions } from '$hooks/useRoomMenuActions';

// Call Hooks & Plugins
import { useCallMembers, useCallSession } from '$hooks/useCall';
import { useCallSpeakers } from '$hooks/useCallSpeakers';
import { useCallEmbed, useCallStart } from '$hooks/useCallEmbed';
import { callChatAtom } from '$state/callEmbed';
import { useCallPreferencesAtom } from '$state/hooks/callPreferences';
Expand Down Expand Up @@ -362,6 +364,8 @@ export function RoomNavItem({
undefined;

const isActiveCall = callEmbed?.roomId === room.roomId;
const speakers = useCallSpeakers(isActiveCall ? callEmbed : undefined);
const isDmPartnerSpeaking = !!dmUserId && speakers.has(dmUserId);

const menu = useMenuAnchor<HTMLElement>();

Expand Down Expand Up @@ -528,6 +532,7 @@ export function RoomNavItem({
<Avatar
size={hideText ? undefined : '200'}
radii="400"
className={classNames(isDmPartnerSpeaking && css.SpeakerAvatarRing)}
style={hideTextStyling(hideText)}
>
{showAvatar || (avatarSrc && isStrict) ? (
Expand Down Expand Up @@ -704,6 +709,7 @@ export function RoomNavItem({
room={room}
callMembership={callMembership}
hideText={hideText}
activeSpeakers={speakers}
/>
))}
</Box>
Expand Down
15 changes: 7 additions & 8 deletions src/app/features/room-nav/RoomNavUser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,28 @@ import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '$state/hooks/userRoomProfile';
import { useSpaceOptionally } from '$hooks/useSpace';
import { nicknamesAtom } from '$state/nicknames';
import { useCallEmbed } from '$hooks/useCallEmbed';
import classNames from 'classnames';
import * as css from './styles.css';

type RoomNavUserProps = {
room: Room;
callMembership: CallMembership;
hideText?: boolean;
activeSpeakers?: Set<string>;
};

export function RoomNavUser({ room, callMembership, hideText }: RoomNavUserProps) {
export function RoomNavUser({ room, callMembership, hideText, activeSpeakers }: RoomNavUserProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const openProfile = useOpenUserRoomProfile();
const space = useSpaceOptionally();

const callEmbed = useCallEmbed();
const isActiveCall = callEmbed?.roomId === room.roomId;

const userId = callMembership.sender ?? '';
const avatarMxcUrl = getMemberAvatarMxc(room, userId);
const avatarUrl = getAvatarUrl(mx, avatarMxcUrl, 32, useAuthentication);
const nicknames = useAtomValue(nicknamesAtom);
const name = getMemberDisplayName(room, userId, nicknames) ?? getMxIdLocalPart(userId);
const isCallParticipant = isActiveCall && userId !== mx.getUserId();
const isSpeaking = !!activeSpeakers?.has(userId);

const handleNavUserClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
openProfile(
Expand All @@ -46,15 +45,15 @@ export function RoomNavUser({ room, callMembership, hideText }: RoomNavUserProps
);
};

const ariaLabel = isCallParticipant ? `Call Participant: ${name}` : name;
const ariaLabel = isSpeaking ? `Speaking: ${name}` : name;

return (
<NavItem variant="Background" radii="400">
<NavButton onClick={handleNavUserClick} aria-label={ariaLabel}>
<NavItemContent as="div" style={hideText ? { padding: '0' } : {}}>
<Box direction="Column" grow="Yes" gap="200" justifyContent="Stretch">
<Box alignItems="Center" gap="200" justifyContent={hideText ? 'Center' : 'Start'}>
<Avatar size="200">
<Avatar size="200" className={classNames(isSpeaking && css.SpeakerAvatarRing)}>
<UserAvatar
userId={userId}
src={avatarUrl ?? undefined}
Expand Down
7 changes: 6 additions & 1 deletion src/app/features/room-nav/styles.css.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { color, config } from 'folds';

export const CategoryButton = style({
flexGrow: 1,
Expand All @@ -19,3 +19,8 @@ export const NavItemChipIcon = style({
lineHeight: 0,
flexShrink: 0,
});

export const SpeakerAvatarRing = style({
boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Success.Main}`,
borderRadius: config.radii.Pill,
});
64 changes: 12 additions & 52 deletions src/app/hooks/useCallSpeakers.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,20 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import type { CallEmbed } from '../plugins/call';
import { useMutationObserver } from './useMutationObserver';
import { isUserId } from '../utils/matrix';
import { useCallMembers, useCallSession } from './useCall';
import { useCallJoined } from './useCallEmbed';

export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
const [speakers, setSpeakers] = useState(new Set<string>());
const callSession = useCallSession(callEmbed.room);
const callMembers = useCallMembers(callEmbed.room, callSession);
const joined = useCallJoined(callEmbed);

const videoContainers = useMemo(() => {
if (callMembers && joined) return callEmbed.document?.querySelectorAll('[data-video-fit]');
return undefined;
}, [callEmbed, callMembers, joined]);

const mutationObserver = useMutationObserver(
useCallback(
(mutations) => {
const s = new Set<string>();

mutations.forEach((mutation) => {
if (mutation.type !== 'attributes') return;
const el = mutation.target as HTMLElement;

const style = callEmbed.iframe.contentWindow?.getComputedStyle(el, '::before');
if (!style) return;
const tileBackgroundImage = style.getPropertyValue('background-image');
const speaking = tileBackgroundImage !== 'none';
if (!speaking) return;

const speakerId = el.querySelector('[aria-label]')?.getAttribute('aria-label');
if (speakerId && isUserId(speakerId)) {
s.add(speakerId);
}
});

setSpeakers(s);
},
[callEmbed]
)
);
/**
* Returns the set of Matrix user IDs currently speaking in the active call.
* The call widget pushes the current set of active speakers to us via a
* widget action io.element.active_speakers
*/
export const useCallSpeakers = (callEmbed?: CallEmbed): Set<string> => {
const [speakers, setSpeakers] = useState<Set<string>>(new Set());

useEffect(() => {
videoContainers?.forEach((element) => {
mutationObserver.observe(element, {
attributes: true,
attributeFilter: ['class', 'style'],
});
if (!callEmbed) return undefined;
return callEmbed.onActiveSpeakers((userIds) => {
setSpeakers(new Set(userIds));
});

return () => {
mutationObserver.disconnect();
};
}, [videoContainers, mutationObserver]);
}, [callEmbed]);

return speakers;
};
25 changes: 25 additions & 0 deletions src/app/plugins/call/CallEmbed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export class CallEmbed {

private readonly disposables: Array<() => void> = [];

private activeSpeakersListeners = new Set<(userIds: string[]) => void>();

static getIntent(dm: boolean, ongoing: boolean, video: boolean | undefined): ElementCallIntent {
if (ongoing) {
if (dm) {
Expand Down Expand Up @@ -267,6 +269,21 @@ export class CallEmbed {
})
);

// The call widget pushes the current set of active speakers
this.disposables.push(
this.listenAction(ElementWidgetActions.ActiveSpeakers, (evt) => {
evt.preventDefault();
this.call.transport.reply(evt.detail as IWidgetApiRequest, {});
const data = (evt.detail as { data?: { userIds?: unknown } }).data;
const userIds = data?.userIds;
if (Array.isArray(userIds)) {
this.activeSpeakersListeners.forEach((listener) =>
listener(userIds.filter((id): id is string => typeof id === 'string'))
);
}
})
);

this.start();
}

Expand All @@ -278,6 +295,14 @@ export class CallEmbed {
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
}

// Suscribe to active speakers
public onActiveSpeakers(listener: (userIds: string[]) => void): () => void {
this.activeSpeakersListeners.add(listener);
return () => {
this.activeSpeakersListeners.delete(listener);
};
}

public setTheme(theme: ElementCallThemeKind) {
return this.call.transport.send(WidgetApiToWidgetAction.ThemeChange, {
name: theme,
Expand Down
1 change: 1 addition & 0 deletions src/app/plugins/call/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ export enum ElementWidgetActions {
HangupCall = 'im.vector.hangup',
Close = 'io.element.close',
DeviceMute = 'io.element.device_mute',
ActiveSpeakers = 'io.element.active_speakers',
}
Loading