From c93e8e2ebfa1dbfdb4310cbe9ea81d97749c28d3 Mon Sep 17 00:00:00 2001 From: Alison Jenkins <1176328+alisonjenkins@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:55:57 +0000 Subject: [PATCH 1/6] Add deafen event type and DeafenReader for remote deafen state Define ElementCallDeafenedKey ("io.element.call.deafened") and DeafenedInfo type. Add DeafenReader class that listens for m.reaction events with the deafen key on membership events, following the same pattern as ReactionsReader for hand raises. Co-Authored-By: Claude Opus 4.6 --- src/reactions/DeafenReader.ts | 211 ++++++++++++++++++++++++++++++++++ src/reactions/index.ts | 13 +++ 2 files changed, 224 insertions(+) create mode 100644 src/reactions/DeafenReader.ts diff --git a/src/reactions/DeafenReader.ts b/src/reactions/DeafenReader.ts new file mode 100644 index 0000000000..50c593129a --- /dev/null +++ b/src/reactions/DeafenReader.ts @@ -0,0 +1,211 @@ +/* +Copyright 2026 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { + type CallMembership, + MatrixRTCSessionEvent, + type MatrixRTCSession, +} from "matrix-js-sdk/lib/matrixrtc"; +import { logger } from "matrix-js-sdk/lib/logger"; +import { type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk"; +import { type ReactionEventContent } from "matrix-js-sdk/lib/types"; +import { + RelationType, + EventType, + RoomEvent as MatrixRoomEvent, +} from "matrix-js-sdk"; +import { BehaviorSubject } from "rxjs"; + +import { ElementCallDeafenedKey, type DeafenedInfo } from "."; +import { type ObservableScope } from "../state/ObservableScope"; + +/** + * Listens for deafen state reactions from an RTCSession and populates a subject + * for consumption by the CallViewModel. + */ +export class DeafenReader { + private readonly deafenedSubject$ = new BehaviorSubject< + Record + >({}); + + /** + * The latest set of deafened users. + */ + public readonly deafened$ = this.deafenedSubject$.asObservable(); + + public constructor( + private readonly scope: ObservableScope, + private readonly rtcSession: MatrixRTCSession, + ) { + this.rtcSession.room.on(MatrixRoomEvent.Timeline, this.handleEvent); + this.scope.onEnd(() => + this.rtcSession.room.off(MatrixRoomEvent.Timeline, this.handleEvent), + ); + + this.rtcSession.room.on(MatrixRoomEvent.Redaction, this.handleEvent); + this.scope.onEnd(() => + this.rtcSession.room.off(MatrixRoomEvent.Redaction, this.handleEvent), + ); + + this.rtcSession.room.client.on( + MatrixEventEvent.Decrypted, + this.handleEvent, + ); + this.scope.onEnd(() => + this.rtcSession.room.client.off( + MatrixEventEvent.Decrypted, + this.handleEvent, + ), + ); + + this.rtcSession.room.on( + MatrixRoomEvent.LocalEchoUpdated, + this.handleEvent, + ); + this.scope.onEnd(() => + this.rtcSession.room.off( + MatrixRoomEvent.LocalEchoUpdated, + this.handleEvent, + ), + ); + + this.rtcSession.on( + MatrixRTCSessionEvent.MembershipsChanged, + this.onMembershipsChanged, + ); + this.scope.onEnd(() => + this.rtcSession.off( + MatrixRTCSessionEvent.MembershipsChanged, + this.onMembershipsChanged, + ), + ); + + // Run this once to ensure we have fetched the state from the call. + this.onMembershipsChanged([]); + } + + /** + * Fetches any deafen reactions by the given sender on the given + * membership event. + */ + private getLastDeafenEvent( + membershipEventId: string, + expectedSender: string, + ): MatrixEvent | undefined { + const relations = this.rtcSession.room.relations.getChildEventsForEvent( + membershipEventId, + RelationType.Annotation, + EventType.Reaction, + ); + const allEvents = relations?.getRelations() ?? []; + return allEvents.find( + (reaction) => + reaction.event.sender === expectedSender && + reaction.getType() === EventType.Reaction && + reaction.getContent()?.["m.relates_to"]?.key === + ElementCallDeafenedKey, + ); + } + + private onMembershipsChanged = (oldMemberships: CallMembership[]): void => { + // Remove deafen state for users no longer joined to the call. + for (const identifier of Object.keys(this.deafenedSubject$.value).filter( + (id) => oldMemberships.find((u) => u.userId == id), + )) { + this.removeDeafened(identifier); + } + + // For each member in the call, check to see if a deafen reaction exists. + for (const m of this.rtcSession.memberships) { + if (!m.userId || !m.eventId) { + continue; + } + const identifier = `${m.userId}:${m.deviceId}`; + if ( + this.deafenedSubject$.value[identifier] && + this.deafenedSubject$.value[identifier].membershipEventId !== m.eventId + ) { + // Membership event for sender has changed since deafen was set, reset. + this.removeDeafened(identifier); + } + const reaction = this.getLastDeafenEvent(m.eventId, m.userId); + if (reaction) { + const eventId = reaction?.getId(); + if (!eventId) { + continue; + } + this.addDeafened(`${m.userId}:${m.deviceId}`, { + membershipEventId: m.eventId, + reactionEventId: eventId, + }); + } + } + }; + + private addDeafened(identifier: string, info: DeafenedInfo): void { + this.deafenedSubject$.next({ + ...this.deafenedSubject$.value, + [identifier]: info, + }); + } + + private removeDeafened(identifier: string): void { + this.deafenedSubject$.next( + Object.fromEntries( + Object.entries(this.deafenedSubject$.value).filter( + ([uId]) => uId !== identifier, + ), + ), + ); + } + + private handleEvent = (event: MatrixEvent): void => { + const room = this.rtcSession.room; + if (event.getRoomId() !== room.roomId) return; + if (event.isSending()) return; + + const sender = event.getSender(); + const reactionEventId = event.getId(); + if (!sender || !reactionEventId) return; + + room.client + .decryptEventIfNeeded(event) + .catch((e) => logger.warn(`Failed to decrypt ${event.getId()}`, e)); + if (event.isBeingDecrypted() || event.isDecryptionFailure()) return; + + if (event.getType() === EventType.Reaction) { + const content = event.getContent() as ReactionEventContent; + const membershipEventId = content["m.relates_to"].event_id; + + const membershipEvent = this.rtcSession.memberships.find( + (e) => e.eventId === membershipEventId && e.userId === sender, + ); + if (!membershipEvent) { + return; + } + + if (content?.["m.relates_to"].key === ElementCallDeafenedKey) { + this.addDeafened( + `${membershipEvent.userId}:${membershipEvent.deviceId}`, + { + reactionEventId, + membershipEventId, + }, + ); + } + } else if (event.getType() === EventType.RoomRedaction) { + const targetEvent = event.event.redacts; + const targetUser = Object.entries(this.deafenedSubject$.value).find( + ([_u, r]) => r.reactionEventId === targetEvent, + )?.[0]; + if (!targetUser) { + return; + } + this.removeDeafened(targetUser); + } + }; +} diff --git a/src/reactions/index.ts b/src/reactions/index.ts index acf7e18161..a2581c2872 100644 --- a/src/reactions/index.ts +++ b/src/reactions/index.ts @@ -204,6 +204,19 @@ export const ReactionSet: ReactionOption[] = [ }, ]; +export const ElementCallDeafenedKey = "io.element.call.deafened"; + +export interface DeafenedInfo { + /** + * Call membership event that was reacted to. + */ + membershipEventId: string; + /** + * Event ID of the reaction itself. + */ + reactionEventId: string; +} + export interface RaisedHandInfo { /** * Call membership event that was reacted to. From b588d7d5c189c99d79e0d413892d71334fd508ea Mon Sep 17 00:00:00 2001 From: Alison Jenkins <1176328+alisonjenkins@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:56:06 +0000 Subject: [PATCH 2/6] Add local deafen toggle and integrate with muteAllAudio$ DeafenModel provides a simple BehaviorSubject toggle for local deafen state. MuteAllAudioModel now includes deafened$ as a third input to muteAllAudio$, so deafening automatically mutes all incoming audio renderers (LiveKit, call events, reactions). Co-Authored-By: Claude Opus 4.6 --- src/state/DeafenModel.ts | 20 ++++++++++++++++++++ src/state/MuteAllAudioModel.ts | 10 ++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 src/state/DeafenModel.ts diff --git a/src/state/DeafenModel.ts b/src/state/DeafenModel.ts new file mode 100644 index 0000000000..3e726765ce --- /dev/null +++ b/src/state/DeafenModel.ts @@ -0,0 +1,20 @@ +/* +Copyright 2026 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { BehaviorSubject } from "rxjs"; + +/** + * Whether the local user is currently deafened (all incoming audio muted). + */ +export const deafened$ = new BehaviorSubject(false); + +/** + * Toggle the local deafen state. + */ +export function toggleDeafen(): void { + deafened$.next(!deafened$.value); +} diff --git a/src/state/MuteAllAudioModel.ts b/src/state/MuteAllAudioModel.ts index 46c29815d6..b7b41855be 100644 --- a/src/state/MuteAllAudioModel.ts +++ b/src/state/MuteAllAudioModel.ts @@ -10,13 +10,19 @@ import { combineLatest, startWith } from "rxjs"; import { setAudioEnabled$ } from "../controls"; import { muteAllAudio as muteAllAudioSetting } from "../settings/settings"; import { globalScope } from "./ObservableScope"; +import { deafened$ } from "./DeafenModel"; /** * This can transition into sth more complete: `GroupCallViewModel.ts` */ export const muteAllAudio$ = globalScope.behavior( combineLatest( - [setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$], - (outputEnabled, settingsMute) => !outputEnabled || settingsMute, + [ + setAudioEnabled$.pipe(startWith(true)), + muteAllAudioSetting.value$, + deafened$, + ], + (outputEnabled, settingsMute, deafened) => + !outputEnabled || settingsMute || deafened, ), ); From 9ce561ede0d1754a12b4da7c876210373408002a Mon Sep 17 00:00:00 2001 From: Alison Jenkins <1176328+alisonjenkins@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:56:16 +0000 Subject: [PATCH 3/6] Thread deafened$ through CallViewModel and UserMediaViewModel Add deafenedSubject$ parameter to createCallViewModel$ and expose deafened$ on the CallViewModel interface. Map per-member deafen state into BaseUserMediaViewModel so tiles can read it. Update test utilities and SDK to pass the new parameter. Co-Authored-By: Claude Opus 4.6 --- sdk/main.ts | 1 + src/state/CallViewModel/CallViewModel.ts | 12 ++++++++++++ src/state/CallViewModel/CallViewModelTestUtils.ts | 1 + src/state/media/UserMediaViewModel.ts | 4 ++++ src/utils/test-viewmodel.ts | 1 + src/utils/test.ts | 2 ++ 6 files changed, 21 insertions(+) diff --git a/sdk/main.ts b/sdk/main.ts index c65bf4a70c..8adb2b1a6b 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -142,6 +142,7 @@ export async function createMatrixRTCSdk( { encryptionSystem: { kind: E2eeType.PER_PARTICIPANT } }, of({}), of({}), + of({}), constant({ supported: false, processor: undefined }), ); logger.info("CallViewModelCreated"); diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index c19c4818dc..7d5a01910e 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -72,6 +72,7 @@ import { oneOnOneLayout } from "../OneOnOneLayout"; import { pipLayout } from "../PipLayout"; import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement"; import { + type DeafenedInfo, type RaisedHandInfo, type ReactionInfo, type ReactionOption, @@ -286,6 +287,8 @@ export interface CallViewModel { localMatrixLivekitMember$: Behavior; /** List of participants raising their hand */ handsRaised$: Behavior>; + /** List of deafened participants */ + deafened$: Behavior>; /** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/ reactions$: Behavior>; @@ -391,6 +394,7 @@ export function createCallViewModel$( options: CallViewModelOptions, handsRaisedSubject$: Observable>, reactionsSubject$: Observable>, + deafenedSubject$: Observable>, trackProcessorState$: Behavior, ): CallViewModel { const client = matrixRoom.client; @@ -697,6 +701,10 @@ export function createCallViewModel$( handsRaisedSubject$.pipe(pauseWhen(localMembership.reconnecting$)), ); + const deafened$ = scope.behavior( + deafenedSubject$.pipe(pauseWhen(localMembership.reconnecting$)), + ); + const reactions$ = scope.behavior( reactionsSubject$.pipe( map((v) => @@ -788,6 +796,9 @@ export function createCallViewModel$( reaction$: scope.behavior( reactions$.pipe(map((v) => v[mediaId] ?? undefined)), ), + deafened$: scope.behavior( + deafened$.pipe(map((v) => v[mediaId] !== undefined)), + ), }), ), ), @@ -1514,6 +1525,7 @@ export function createCallViewModel$( allConnections$, participantCount$: participantCount$, handsRaised$: handsRaised$, + deafened$: deafened$, reactions$: reactions$, joinSoundEffect$: joinSoundEffect$, leaveSoundEffect$: leaveSoundEffect$, diff --git a/src/state/CallViewModel/CallViewModelTestUtils.ts b/src/state/CallViewModel/CallViewModelTestUtils.ts index b6f532751e..91b9feec0e 100644 --- a/src/state/CallViewModel/CallViewModelTestUtils.ts +++ b/src/state/CallViewModel/CallViewModelTestUtils.ts @@ -187,6 +187,7 @@ export function withCallViewModel(mode: MatrixRTCMode) { }, raisedHands$, reactions$, + new BehaviorSubject>({}), new BehaviorSubject({ processor: undefined, supported: undefined, diff --git a/src/state/media/UserMediaViewModel.ts b/src/state/media/UserMediaViewModel.ts index 8da5e63aa2..4f5ec0bdd2 100644 --- a/src/state/media/UserMediaViewModel.ts +++ b/src/state/media/UserMediaViewModel.ts @@ -54,6 +54,7 @@ export interface BaseUserMediaViewModel extends MemberMediaViewModel { rtcBackendIdentity: string; handRaised$: Behavior; reaction$: Behavior; + deafened$: Behavior; audioStreamStats$: Observable< RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined >; @@ -69,6 +70,7 @@ export interface BaseUserMediaInputs extends Omit< rtcBackendIdentity: string; handRaised$: Behavior; reaction$: Behavior; + deafened$: Behavior; statsType: "inbound-rtp" | "outbound-rtp"; } @@ -78,6 +80,7 @@ export function createBaseUserMedia( rtcBackendIdentity, handRaised$, reaction$, + deafened$, statsType, ...inputs }: BaseUserMediaInputs, @@ -120,6 +123,7 @@ export function createBaseUserMedia( rtcBackendIdentity, handRaised$, reaction$, + deafened$, audioStreamStats$: combineLatest([ participant$, showConnectionStats.value$, diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index 0745be7265..0f9098e468 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -168,6 +168,7 @@ export function getBasicCallViewModelEnvironment( }, handRaisedSubject$, reactionsSubject$, + new BehaviorSubject({}), constant({ processor: undefined, supported: false }), ); return { diff --git a/src/utils/test.ts b/src/utils/test.ts index c1e6792714..9e96d2f341 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -347,6 +347,7 @@ export function mockLocalMedia( mxcAvatarUrl$: constant(member.getMxcAvatarUrl()), handRaised$: constant(null), reaction$: constant(null), + deafened$: constant(false), }); } @@ -390,6 +391,7 @@ export function mockRemoteMedia( mxcAvatarUrl$: constant(member.getMxcAvatarUrl()), handRaised$: constant(null), reaction$: constant(null), + deafened$: constant(false), }); } From 872b13f94cf795024454c4945a940557c07df5e4 Mon Sep 17 00:00:00 2001 From: Alison Jenkins <1176328+alisonjenkins@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:56:24 +0000 Subject: [PATCH 4/6] Add DeafenButton component and deafened indicator on tiles New DeafenButton in Button.tsx using HeadphonesSolidIcon / HeadphonesOffSolidIcon from Compound. GridTile now subscribes to vm.deafened$ and shows a HeadphonesOffSolidIcon in the name tag when the participant is deafened. Co-Authored-By: Claude Opus 4.6 --- src/button/Button.tsx | 29 +++++++++++++++++++++++++++++ src/tile/GridTile.test.tsx | 1 + src/tile/GridTile.tsx | 26 +++++++++++++++++++------- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 3136e2da26..270b42e7b8 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -17,6 +17,8 @@ import { EndCallIcon, ShareScreenSolidIcon, SettingsSolidIcon, + HeadphonesSolidIcon, + HeadphonesOffSolidIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import styles from "./Button.module.css"; @@ -45,6 +47,33 @@ export const MicButton: FC = ({ muted, ...props }) => { ); }; +interface DeafenButtonProps extends ComponentPropsWithoutRef<"button"> { + deafened: boolean; +} + +export const DeafenButton: FC = ({ + deafened, + ...props +}) => { + const { t } = useTranslation(); + const Icon = deafened ? HeadphonesOffSolidIcon : HeadphonesSolidIcon; + const label = deafened + ? t("undeafen_button_label") + : t("deafen_button_label"); + + return ( + + + + ); +}; + interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> { muted: boolean; } diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index 02f09a17b1..e539b42663 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -60,6 +60,7 @@ test("GridTile is accessible", async () => { const cVm = { reactions$: constant({}), handsRaised$: constant({}), + deafened$: constant({}), } as Partial as CallViewModel; const { container } = render( diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 9c3adea783..49d717869e 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -29,6 +29,7 @@ import { ExpandIcon, VolumeOffSolidIcon, SwitchCameraSolidIcon, + HeadphonesOffSolidIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { ContextMenu, @@ -114,6 +115,7 @@ const UserMediaTile: FC = ({ const rtcBackendIdentity = vm.rtcBackendIdentity; const handRaised = useBehavior(vm.handRaised$); const reaction = useBehavior(vm.reaction$); + const deafened = useBehavior(vm.deafened$); const AudioIcon = playbackMuted ? VolumeOffSolidIcon @@ -160,13 +162,23 @@ const UserMediaTile: FC = ({ [styles.handRaised]: !showSpeaking && handRaised, })} nameTagLeadingIcon={ - + <> + + {deafened && ( + + )} + } displayName={displayName} mxcAvatarUrl={mxcAvatarUrl} From 8c44448f64df12ebb1305cc4fac1d919beab0a3c Mon Sep 17 00:00:00 2001 From: Alison Jenkins <1176328+alisonjenkins@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:56:33 +0000 Subject: [PATCH 5/6] Add toggleDeafened broadcast to ReactionsSenderProvider Follow the toggleRaisedHand pattern: send an m.reaction event with key io.element.call.deafened to announce deafen state, and redact it to un-deafen. Expose toggleDeafened in the context so InCallView can broadcast state changes to other participants. Co-Authored-By: Claude Opus 4.6 --- src/reactions/useReactionsSender.tsx | 67 +++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/src/reactions/useReactionsSender.tsx b/src/reactions/useReactionsSender.tsx index afb9b78976..f82dfbf483 100644 --- a/src/reactions/useReactionsSender.tsx +++ b/src/reactions/useReactionsSender.tsx @@ -19,13 +19,18 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; import { useClientState } from "../ClientContext"; -import { ElementCallReactionEventType, type ReactionOption } from "."; +import { + ElementCallDeafenedKey, + ElementCallReactionEventType, + type ReactionOption, +} from "."; import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; import { useBehavior } from "../useBehavior"; interface ReactionsSenderContextType { supportsReactions: boolean; toggleRaisedHand: () => Promise; + toggleDeafened: () => Promise; sendReaction: (reaction: ReactionOption) => Promise; } @@ -88,6 +93,15 @@ export const ReactionsSenderProvider = ({ [myMembershipIdentifier, handsRaised], ); + const deafened = useBehavior(vm.deafened$); + const myDeafened = useMemo( + () => + myMembershipIdentifier !== undefined + ? deafened[myMembershipIdentifier] + : undefined, + [myMembershipIdentifier, deafened], + ); + const toggleRaisedHand = useCallback(async () => { if (!myMembershipIdentifier) { return; @@ -131,6 +145,56 @@ export const ReactionsSenderProvider = ({ room, ]); + const toggleDeafened = useCallback(async () => { + if (!myMembershipIdentifier) { + return; + } + const myDeafenedReactionId = myDeafened?.reactionEventId; + + if (!myDeafenedReactionId) { + try { + if (!myMembershipEvent) { + throw new Error("Cannot find own membership event"); + } + const reaction = await room.client.sendEvent( + rtcSession.room.roomId, + EventType.Reaction, + { + "m.relates_to": { + rel_type: RelationType.Annotation, + event_id: myMembershipEvent, + key: ElementCallDeafenedKey, + }, + }, + ); + logger.debug("Sent deafen event", reaction.event_id); + } catch (ex) { + logger.error("Failed to send deafen event", ex); + } + } else { + try { + await room.client.redactEvent( + rtcSession.room.roomId, + myDeafenedReactionId, + ); + logger.debug("Redacted deafen event"); + } catch (ex) { + logger.error( + "Failed to redact deafen event", + myDeafenedReactionId, + ex, + ); + throw ex; + } + } + }, [ + myMembershipEvent, + myMembershipIdentifier, + myDeafened, + rtcSession, + room, + ]); + const sendReaction = useCallback( async (reaction: ReactionOption) => { if (!myMembershipIdentifier || myReaction) { @@ -161,6 +225,7 @@ export const ReactionsSenderProvider = ({ value={{ supportsReactions, toggleRaisedHand, + toggleDeafened, sendReaction, }} > From 0e2d2909a5de6a0b05bfcb368849e001a1f064a2 Mon Sep 17 00:00:00 2001 From: Alison Jenkins <1176328+alisonjenkins@users.noreply.github.com> Date: Sat, 28 Feb 2026 00:56:42 +0000 Subject: [PATCH 6/6] Wire deafen button, auto-mute, keyboard shortcut, and translations InCallView: instantiate DeafenReader, add DeafenButton to toolbar, auto-mute mic on deafen (restore previous state on un-deafen), and disable mic toggle while deafened. Add 'd' keyboard shortcut for toggling deafen. Add translation keys for deafen/undeafen labels and the deafened tile indicator. Co-Authored-By: Claude Opus 4.6 --- locales/en/app.json | 3 ++ src/room/InCallView.tsx | 54 ++++++++++++++++--- .../__snapshots__/InCallView.test.tsx.snap | 33 ++++++++++-- src/useCallViewKeyboardShortcuts.test.tsx | 1 + src/useCallViewKeyboardShortcuts.ts | 5 ++ 5 files changed, 84 insertions(+), 12 deletions(-) diff --git a/locales/en/app.json b/locales/en/app.json index 0b0ac7b4dc..197809e640 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -61,6 +61,7 @@ "username": "Username", "video": "Video" }, + "deafen_button_label": "Deafen", "developer_mode": { "always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms", "crypto_version": "Crypto version: {{version}}", @@ -245,6 +246,7 @@ "unauthenticated_view_body": "Not registered yet? <2>Create an account", "unauthenticated_view_login_button": "Login to your account", "unauthenticated_view_ssla_caption": "By clicking \"Go\", you agree to our <2>Software and Services License Agreement (SSLA)", + "undeafen_button_label": "Undeafen", "unmute_microphone_button_label": "Unmute microphone", "version": "{{productName}} version: {{version}}", "video_tile": { @@ -252,6 +254,7 @@ "camera_starting": "Video loading...", "change_fit_contain": "Fit to frame", "collapse": "Collapse", + "deafened": "Deafened", "expand": "Expand", "mute_for_me": "Mute for me", "muted_for_me": "Muted for me", diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 135745eb37..e9778a1ccd 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -36,6 +36,7 @@ import LogoType from "../icons/LogoType.svg?react"; import { EndCallButton, MicButton, + DeafenButton, VideoButton, ShareScreenButton, SettingsButton, @@ -90,6 +91,11 @@ import { useSetting, } from "../settings/settings"; import { ReactionsReader } from "../reactions/ReactionsReader"; +import { DeafenReader } from "../reactions/DeafenReader"; +import { + deafened$ as localDeafened$, + toggleDeafen, +} from "../state/DeafenModel"; import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { useMediaDevices } from "../MediaDevicesContext.ts"; @@ -133,6 +139,7 @@ export const ActiveCall: FC = (props) => { logger.info("START CALL VIEW SCOPE"); const scope = new ObservableScope(); const reactionsReader = new ReactionsReader(scope, props.rtcSession); + const deafenReader = new DeafenReader(scope, props.rtcSession); const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } = urlParams; const vm = createCallViewModel$( @@ -149,6 +156,7 @@ export const ActiveCall: FC = (props) => { }, reactionsReader.raisedHands$, reactionsReader.reactions$, + deafenReader.deafened$, scope.behavior(trackProcessorState$), ); // TODO move this somewhere else once we use the callViewModel in the lobby as well! @@ -203,8 +211,12 @@ export const InCallView: FC = ({ onShareClick, }) => { const { t } = useTranslation(); - const { supportsReactions, sendReaction, toggleRaisedHand } = - useReactionsSender(); + const { + supportsReactions, + sendReaction, + toggleRaisedHand, + toggleDeafened: broadcastToggleDeafened, + } = useReactionsSender(); useWakeLock(); // TODO-MULTI-SFU This is unused now?? @@ -248,15 +260,36 @@ export const InCallView: FC = ({ const toggleVideo = useBehavior(muteStates.video.toggle$); const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$); + const deafened = useBehavior(localDeafened$); + const prevAudioEnabledRef = useRef(audioEnabled); + + const handleToggleDeafen = useCallback(() => { + if (!deafened) { + // About to deafen: save current mic state, then mute mic + prevAudioEnabledRef.current = audioEnabled; + if (audioEnabled) { + setAudioEnabled?.(false); + } + } else { + // About to un-deafen: restore previous mic state + if (prevAudioEnabledRef.current) { + setAudioEnabled?.(true); + } + } + toggleDeafen(); + void broadcastToggleDeafened(); + }, [deafened, audioEnabled, setAudioEnabled, broadcastToggleDeafened]); + // This function incorrectly assumes that there is a camera and microphone, which is not always the case. // TODO: Make sure that this module is resilient when it comes to camera/microphone availability! useCallViewKeyboardShortcuts( containerRef1, - toggleAudio, + deafened ? null : toggleAudio, toggleVideo, - setAudioEnabled, + deafened ? null : setAudioEnabled, (reaction) => void sendReaction(reaction), () => void toggleRaisedHand(), + handleToggleDeafen, ); const audioParticipants = useBehavior(vm.livekitRoomItems$); @@ -665,12 +698,19 @@ export const InCallView: FC = ({ buttons.push( , + , rendering > renders 1`] = ` /> +