diff --git a/.changeset/add_per_user_voice_activity_ring_indicators.md b/.changeset/add_per_user_voice_activity_ring_indicators.md new file mode 100644 index 0000000000..760b8cd415 --- /dev/null +++ b/.changeset/add_per_user_voice_activity_ring_indicators.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +# add per user voice activity ring indicators diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index ce350c074c..ddb10f47a1 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -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 { @@ -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'; @@ -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(); @@ -528,6 +532,7 @@ export function RoomNavItem({ {showAvatar || (avatarSrc && isStrict) ? ( @@ -704,6 +709,7 @@ export function RoomNavItem({ room={room} callMembership={callMembership} hideText={hideText} + activeSpeakers={speakers} /> ))} diff --git a/src/app/features/room-nav/RoomNavUser.tsx b/src/app/features/room-nav/RoomNavUser.tsx index d3ce157211..028ab3c77e 100644 --- a/src/app/features/room-nav/RoomNavUser.tsx +++ b/src/app/features/room-nav/RoomNavUser.tsx @@ -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; }; -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 = (evt) => { openProfile( @@ -46,7 +45,7 @@ export function RoomNavUser({ room, callMembership, hideText }: RoomNavUserProps ); }; - const ariaLabel = isCallParticipant ? `Call Participant: ${name}` : name; + const ariaLabel = isSpeaking ? `Speaking: ${name}` : name; return ( @@ -54,7 +53,7 @@ export function RoomNavUser({ room, callMembership, hideText }: RoomNavUserProps - + => { - const [speakers, setSpeakers] = useState(new Set()); - 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(); - - 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 => { + const [speakers, setSpeakers] = useState>(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; }; diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 0de990d719..c35b59073d 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -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) { @@ -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(); } @@ -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, diff --git a/src/app/plugins/call/types.ts b/src/app/plugins/call/types.ts index f6537c43cf..4f1d79ef2d 100644 --- a/src/app/plugins/call/types.ts +++ b/src/app/plugins/call/types.ts @@ -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', }