From 55e83065767645ed7cd510a6f4b5cf4733b000b4 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:00:35 +1100 Subject: [PATCH] Display call member speaking status on bottom bar (#2742) * add mutation observer hok * add hook to read speaking member by observing iframe content * display speaking member name in call status bar and improve layout * fix shrining --- src/app/features/call-status/CallControl.tsx | 19 ++--- src/app/features/call-status/CallStatus.tsx | 43 +++++++--- src/app/features/call-status/MemberGlance.tsx | 5 +- .../features/call-status/MemberSpeaking.tsx | 78 +++++++++++++++++++ src/app/features/call-status/styles.css.ts | 4 + src/app/hooks/useCallSpeakers.ts | 60 ++++++++++++++ src/app/hooks/useMutationObserver.ts | 37 +++++++++ src/app/plugins/call/CallEmbed.ts | 4 + 8 files changed, 229 insertions(+), 21 deletions(-) create mode 100644 src/app/features/call-status/MemberSpeaking.tsx create mode 100644 src/app/hooks/useCallSpeakers.ts create mode 100644 src/app/hooks/useMutationObserver.ts diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index 39cd975b..097d00c9 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -20,12 +20,12 @@ function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { {(anchorRef) => ( onToggle()} - outlined={!enabled} + outlined > @@ -51,12 +51,12 @@ function SoundButton({ enabled, onToggle }: SoundButtonProps) { {(anchorRef) => ( onToggle()} - outlined={!enabled} + outlined > ( onToggle()} - outlined={enabled} + outlined > ( setEnabled(!enabled)} - outlined={enabled} + outlined > @@ -144,9 +144,6 @@ export function CallControl({ callEmbed }: { callEmbed: CallEmbed }) { onToggle={() => callEmbed.control.toggleMicrophone()} /> callEmbed.control.toggleSound()} /> - - - callEmbed.control.toggleVideo()} /> {false && } diff --git a/src/app/features/call-status/CallStatus.tsx b/src/app/features/call-status/CallStatus.tsx index e2a17222..2b8b809f 100644 --- a/src/app/features/call-status/CallStatus.tsx +++ b/src/app/features/call-status/CallStatus.tsx @@ -12,6 +12,8 @@ import { MemberGlance } from './MemberGlance'; import { StatusDivider } from './components'; import { CallEmbed } from '../../plugins/call/CallEmbed'; import { useCallJoined } from '../../hooks/useCallEmbed'; +import { useCallSpeakers } from '../../hooks/useCallSpeakers'; +import { MemberSpeaking } from './MemberSpeaking'; type CallStatusProps = { callEmbed: CallEmbed; @@ -23,28 +25,51 @@ export function CallStatus({ callEmbed }: CallStatusProps) { const callMembers = useCallMembers(room, callSession); const screenSize = useScreenSize(); const callJoined = useCallJoined(callEmbed); + const speakers = useCallSpeakers(callEmbed); + + const compact = screenSize === ScreenSize.Mobile; + + const memberVisible = callJoined && callMembers.length > 0; return ( - - {callJoined && callMembers.length > 0 ? ( - - + + {memberVisible ? ( + ) : ( )} - - + + + {speakers.size > 0 && !compact && ( + <> + + + + + )} + + {memberVisible && ( + + + + )} - + {memberVisible && !compact && } + diff --git a/src/app/features/call-status/MemberGlance.tsx b/src/app/features/call-status/MemberGlance.tsx index f4991f93..2e65069a 100644 --- a/src/app/features/call-status/MemberGlance.tsx +++ b/src/app/features/call-status/MemberGlance.tsx @@ -10,13 +10,15 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { StackedAvatar } from '../../components/stacked-avatar'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { getMouseEventCords } from '../../utils/dom'; +import * as css from './styles.css'; type MemberGlanceProps = { room: Room; members: CallMembership[]; + speakers: Set; max?: number; }; -export function MemberGlance({ room, members, max = 6 }: MemberGlanceProps) { +export function MemberGlance({ room, members, speakers, max = 6 }: MemberGlanceProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const openUserProfile = useOpenUserRoomProfile(); @@ -38,6 +40,7 @@ export function MemberGlance({ room, members, max = 6 }: MemberGlanceProps) { return ( ; +}; +export function MemberSpeaking({ room, speakers }: MemberSpeakingProps) { + const speakingNames = Array.from(speakers).map( + (userId) => getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId + ); + return ( + + + + {speakingNames.length === 1 && ( + <> + {speakingNames[0]} + + {' is speaking...'} + + + )} + {speakingNames.length === 2 && ( + <> + {speakingNames[0]} + + {' and '} + + {speakingNames[1]} + + {' are speaking...'} + + + )} + {speakingNames.length === 3 && ( + <> + {speakingNames[0]} + + {', '} + + {speakingNames[1]} + + {' and '} + + {speakingNames[2]} + + {' are speaking...'} + + + )} + {speakingNames.length > 3 && ( + <> + {speakingNames[0]} + + {', '} + + {speakingNames[1]} + + {', '} + + {speakingNames[2]} + + {' and '} + + {speakingNames.length - 3} others + + {' are speaking...'} + + + )} + + + ); +} diff --git a/src/app/features/call-status/styles.css.ts b/src/app/features/call-status/styles.css.ts index 26b08f0b..dd32bc3d 100644 --- a/src/app/features/call-status/styles.css.ts +++ b/src/app/features/call-status/styles.css.ts @@ -15,3 +15,7 @@ export const CallStatus = style([ export const ControlDivider = style({ height: toRem(16), }); + +export const SpeakerAvatarOutline = style({ + boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Success.Main}`, +}); diff --git a/src/app/hooks/useCallSpeakers.ts b/src/app/hooks/useCallSpeakers.ts new file mode 100644 index 00000000..24003678 --- /dev/null +++ b/src/app/hooks/useCallSpeakers.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { 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 => { + 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] + ) + ); + + useEffect(() => { + videoContainers?.forEach((element) => { + mutationObserver.observe(element, { + attributes: true, + attributeFilter: ['class', 'style'], + }); + }); + + return () => { + mutationObserver.disconnect(); + }; + }, [videoContainers, mutationObserver]); + + return speakers; +}; diff --git a/src/app/hooks/useMutationObserver.ts b/src/app/hooks/useMutationObserver.ts new file mode 100644 index 00000000..5d0ce2ab --- /dev/null +++ b/src/app/hooks/useMutationObserver.ts @@ -0,0 +1,37 @@ +import { useEffect, useMemo } from 'react'; + +export type OnMutationCallback = (mutations: MutationRecord[]) => void; + +export const getMutationRecord = ( + target: Node, + mutations: MutationRecord[] +): MutationRecord | undefined => mutations.find((mutation) => mutation.target === target); + +export const useMutationObserver = ( + onMutationCallback: OnMutationCallback, + observeElement?: Node | null | (() => Node | null), + options?: MutationObserverInit +): MutationObserver => { + const mutationObserver = useMemo( + () => new MutationObserver(onMutationCallback), + [onMutationCallback] + ); + + useEffect(() => () => mutationObserver?.disconnect(), [mutationObserver]); + + useEffect(() => { + const element = typeof observeElement === 'function' ? observeElement() : observeElement; + + if (element) { + mutationObserver.observe(element, options); + } + + return () => { + if (element) { + mutationObserver.disconnect(); + } + }; + }, [mutationObserver, observeElement, options]); + + return mutationObserver; +}; diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index b852e3ef..f01cd685 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -163,6 +163,10 @@ export class CallEmbed { return this.room.roomId; } + get document(): Document | undefined { + return this.iframe.contentDocument ?? this.iframe.contentWindow?.document; + } + public setTheme(theme: ElementCallThemeKind) { return this.call.transport.send(WidgetApiToWidgetAction.ThemeChange, { name: theme,