From 5ef0a1fd3e935035fddede6769379fcf6d7645cb Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Mon, 29 Jun 2026 19:13:40 -0400 Subject: [PATCH] fix(call): ringtone loudness, caller decline notice, All-Muted badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues from live testing: - A1: the 'classic' ringtone (call.ogg, mastered near full scale) was much louder than the synthesized styles. Attenuate it (CLASSIC_GAIN 0.45) so all ringtones sit at a comparable level. - A3/A4: the caller had no indication when a DM/group callee declined — their UI kept "ringing" until the notification lifetime expired. IncomingCallListener now listens for RTCDecline events for a call we're hosting in the room and toasts the caller (" declined your call"). - G1: the PiP "All muted" badge fired when any single remote participant muted. useRemoteAllMuted now returns true only when there is >=1 remote and every remote participant is muted. Co-Authored-By: Claude Opus 4.8 --- src/app/components/CallEmbedProvider.tsx | 29 +++++++++++++++++++++++- src/app/hooks/useCallSpeakers.ts | 10 +++++--- src/app/utils/ringtones.ts | 7 +++++- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index b1d91b841..9d8ab8a88 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -37,6 +37,7 @@ import { useCallStart, } from '../hooks/useCallEmbed'; import { callChatAtom, callEmbedAtom } from '../state/callEmbed'; +import { toastQueueAtom } from '../state/toast'; import { CallEmbed, useCallControlState } from '../plugins/call'; import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; @@ -405,6 +406,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) const mx = useMatrixClient(); const directs = useAtomValue(mDirectAtom); const { navigateRoom } = useRoomNavigate(); + const setToast = useSetAtom(toastQueueAtom); const [callInfo, setCallInfo] = useState(); const dm = callInfo ? directs.has(callInfo.room.roomId) : false; @@ -424,6 +426,31 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) await event.getDecryptionPromise(); } + // Caller-side: a participant declined a call we're hosting in this room. + // Without this the caller's UI keeps "ringing" until the notification + // lifetime expires, with no indication the callee said no. + if (event.getType() === EventType.RTCDecline) { + const decliner = event.getSender(); + if ( + data.liveEvent && + room && + decliner && + decliner !== mx.getSafeUserId() && + callEmbed?.roomId === room.roomId + ) { + const declinerName = + getMemberDisplayName(room, decliner) ?? getMxIdLocalPart(decliner) ?? decliner; + setToast({ + id: `rtc-decline-${event.getId() ?? decliner}`, + displayName: declinerName, + body: 'Declined your call', + roomName: room.name, + roomId: room.roomId, + }); + } + return; + } + if ( !room || event.getType() !== EventType.RTCNotification || @@ -486,7 +513,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) setCallInfo(info); }, - [mx, directs], + [mx, directs, callEmbed, setToast], ); useEffect(() => { diff --git a/src/app/hooks/useCallSpeakers.ts b/src/app/hooks/useCallSpeakers.ts index de36929ba..2bee8dd84 100644 --- a/src/app/hooks/useCallSpeakers.ts +++ b/src/app/hooks/useCallSpeakers.ts @@ -145,13 +145,17 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean => // Each participant's mute icon has data-muted="true"|"false" and // aria-label set to their Matrix user ID. const muteIcons = doc.querySelectorAll('[data-muted]'); - let anyRemoteMuted = false; + let remoteCount = 0; + let remoteMutedCount = 0; muteIcons.forEach((el) => { const userId = el.getAttribute('aria-label') ?? ''; if (userId === localUserId) return; - if (el.getAttribute('data-muted') === 'true') anyRemoteMuted = true; + remoteCount += 1; + if (el.getAttribute('data-muted') === 'true') remoteMutedCount += 1; }); - setMuted(anyRemoteMuted); + // "All muted" badge: true only when there is at least one remote + // participant and every one of them is muted (not merely any single one). + setMuted(remoteCount > 0 && remoteMutedCount === remoteCount); }; let tileObserver: MutationObserver | undefined; diff --git a/src/app/utils/ringtones.ts b/src/app/utils/ringtones.ts index d7ee22cff..1f2a71ccd 100644 --- a/src/app/utils/ringtones.ts +++ b/src/app/utils/ringtones.ts @@ -102,12 +102,17 @@ const playPhrase = (style: SynthStyle, volume: number, destination: AudioNode): }); }; +// The bundled call.ogg is mastered near full scale, so at equal `volume` it is +// perceptibly much louder than the synthesized styles (which peak at ~0.12–0.3). +// Attenuate it so all ringtones sit at a comparable loudness. +const CLASSIC_GAIN = 0.45; + const startClassic = (volume: number, loop: boolean): (() => void) => { let audio: HTMLAudioElement | undefined; try { audio = new Audio(CallSound); audio.loop = loop; - audio.volume = clamp01(volume); + audio.volume = clamp01(volume) * CLASSIC_GAIN; audio.play().catch(() => undefined); } catch { audio = undefined;