From 177485d053ab842ed0afd224f07be976a0dd7d5e Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 11:17:05 -0500 Subject: [PATCH 01/27] tests for some goal behaviors --- src/app/components/IncomingCallModal.test.tsx | 65 ++++++ .../call/rtcNotificationParser.test.ts | 204 ++++++++++++++++++ .../features/call/rtcNotificationParser.ts | 113 ++++++++++ src/app/plugins/call/CallEmbed.intent.test.ts | 39 ++++ .../call/matrixRtcMemberships.test.ts | 12 ++ .../fixtures/call/matrixRtcMemberships.ts | 50 +++++ 6 files changed, 483 insertions(+) create mode 100644 src/app/components/IncomingCallModal.test.tsx create mode 100644 src/app/features/call/rtcNotificationParser.test.ts create mode 100644 src/app/features/call/rtcNotificationParser.ts create mode 100644 src/app/plugins/call/CallEmbed.intent.test.ts create mode 100644 src/test/fixtures/call/matrixRtcMemberships.test.ts create mode 100644 src/test/fixtures/call/matrixRtcMemberships.ts diff --git a/src/app/components/IncomingCallModal.test.tsx b/src/app/components/IncomingCallModal.test.tsx new file mode 100644 index 000000000..974fe9e95 --- /dev/null +++ b/src/app/components/IncomingCallModal.test.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import type { Room } from '$types/matrix-sdk'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { IncomingCallInternal } from './IncomingCallModal'; + +const { navigateRoomMock } = vi.hoisted(() => ({ + navigateRoomMock: vi.fn<(roomId: string) => void>(), +})); + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => ({}), +})); + +vi.mock('$hooks/useRoomMeta', () => ({ + useRoomName: () => 'Direct Message', +})); + +vi.mock('$utils/room', () => ({ + getRoomAvatarUrl: () => null, +})); + +vi.mock('$hooks/useRoomNavigate', () => ({ + useRoomNavigate: () => ({ + navigateRoom: navigateRoomMock, + }), +})); + +vi.mock('./room-avatar', () => ({ + RoomAvatar: ({ alt }: { alt: string }) =>
{alt}
, +})); + +vi.mock('@sentry/react', () => ({ + addBreadcrumb: vi.fn(), + metrics: { + count: vi.fn(), + }, +})); + +describe('IncomingCallInternal', () => { + const room = { roomId: '!room:example.org' } as Room; + + beforeEach(() => { + navigateRoomMock.mockReset(); + }); + + it('closes the modal when decline is pressed', () => { + const onClose = vi.fn<() => void>(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /decline/i })); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(navigateRoomMock).not.toHaveBeenCalled(); + }); + + it('navigates and closes when answer is pressed', () => { + const onClose = vi.fn<() => void>(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /answer/i })); + + expect(navigateRoomMock).toHaveBeenCalledWith('!room:example.org'); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/features/call/rtcNotificationParser.test.ts b/src/app/features/call/rtcNotificationParser.test.ts new file mode 100644 index 000000000..f1f9f94b4 --- /dev/null +++ b/src/app/features/call/rtcNotificationParser.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest'; +import { + parseIncomingRtcNotification, + REFERENCE_REL_TYPE, + RTC_NOTIFICATION_EVENT_TYPE, + type RtcNotificationEventLike, +} from './rtcNotificationParser'; + +const NOW = 1_700_000_000_000; + +const createEvent = ( + overrides: Partial = {} +): RtcNotificationEventLike => ({ + type: RTC_NOTIFICATION_EVENT_TYPE, + sender: '@caller:example.org', + roomId: '!room:example.org', + eventId: '$notif', + originServerTs: NOW - 1_000, + isLiveEvent: true, + isEncrypted: false, + relation: { + rel_type: REFERENCE_REL_TYPE, + event_id: '$call', + }, + content: { + sender_ts: NOW - 500, + lifetime: 60_000, + notification_type: 'ring', + 'm.call.intent': 'start_call_dm_voice', + 'm.mentions': { + user_ids: ['@self:example.org'], + }, + }, + ...overrides, +}); + +describe('parseIncomingRtcNotification', () => { + it('parses a plain RTC notification event', async () => { + const parsed = await parseIncomingRtcNotification(createEvent(), { + myUserId: '@self:example.org', + now: NOW, + }); + + expect(parsed).toMatchObject({ + roomId: '!room:example.org', + notificationEventId: '$notif', + refEventId: '$call', + senderId: '@caller:example.org', + notificationType: 'ring', + intentKind: 'audio', + intentRaw: 'start_call_dm_voice', + }); + }); + + it('parses encrypted notification when decryption succeeds', async () => { + const parsed = await parseIncomingRtcNotification( + createEvent({ isEncrypted: true, content: { ciphertext: 'x' } }), + { + myUserId: '@self:example.org', + now: NOW, + decryptContent: async () => ({ + sender_ts: NOW - 500, + lifetime: 60_000, + notification_type: 'notification', + 'm.call.intent': 'start_call_dm', + 'm.mentions': { room: true }, + }), + } + ); + + expect(parsed?.notificationType).toBe('notification'); + expect(parsed?.intentKind).toBe('video'); + }); + + it('ignores expired notifications', async () => { + const parsed = await parseIncomingRtcNotification( + createEvent({ + content: { + sender_ts: NOW - 120_000, + lifetime: 10_000, + notification_type: 'ring', + 'm.mentions': { room: true }, + }, + }), + { + myUserId: '@self:example.org', + now: NOW, + } + ); + + expect(parsed).toBeUndefined(); + }); + + it('ignores events without reference relation', async () => { + const parsed = await parseIncomingRtcNotification( + createEvent({ relation: { rel_type: 'm.thread', event_id: '$call' } }), + { + myUserId: '@self:example.org', + now: NOW, + } + ); + + expect(parsed).toBeUndefined(); + }); + + it('ignores self-sent notifications', async () => { + const parsed = await parseIncomingRtcNotification(createEvent({ sender: '@self:example.org' }), { + myUserId: '@self:example.org', + now: NOW, + }); + + expect(parsed).toBeUndefined(); + }); + + it('ignores non-mentioned notifications', async () => { + const parsed = await parseIncomingRtcNotification( + createEvent({ + content: { + sender_ts: NOW - 500, + lifetime: 60_000, + notification_type: 'ring', + 'm.mentions': { user_ids: ['@someone-else:example.org'] }, + }, + }), + { + myUserId: '@self:example.org', + now: NOW, + } + ); + + expect(parsed).toBeUndefined(); + }); + + it('preserves ring vs notification type', async () => { + const ring = await parseIncomingRtcNotification( + createEvent({ + content: { + sender_ts: NOW - 500, + lifetime: 60_000, + notification_type: 'ring', + 'm.mentions': { room: true }, + }, + }), + { + myUserId: '@self:example.org', + now: NOW, + } + ); + const notification = await parseIncomingRtcNotification( + createEvent({ + content: { + sender_ts: NOW - 500, + lifetime: 60_000, + notification_type: 'notification', + 'm.mentions': { room: true }, + }, + }), + { + myUserId: '@self:example.org', + now: NOW, + } + ); + + expect(ring?.notificationType).toBe('ring'); + expect(notification?.notificationType).toBe('notification'); + }); + + it('maps voice and video intents', async () => { + const audio = await parseIncomingRtcNotification( + createEvent({ + content: { + sender_ts: NOW - 500, + lifetime: 60_000, + notification_type: 'ring', + 'm.call.intent': 'join_existing_dm_voice', + 'm.mentions': { room: true }, + }, + }), + { + myUserId: '@self:example.org', + now: NOW, + } + ); + + const video = await parseIncomingRtcNotification( + createEvent({ + content: { + sender_ts: NOW - 500, + lifetime: 60_000, + notification_type: 'ring', + 'm.call.intent': 'start_call_dm', + 'm.mentions': { room: true }, + }, + }), + { + myUserId: '@self:example.org', + now: NOW, + } + ); + + expect(audio?.intentKind).toBe('audio'); + expect(video?.intentKind).toBe('video'); + }); +}); diff --git a/src/app/features/call/rtcNotificationParser.ts b/src/app/features/call/rtcNotificationParser.ts new file mode 100644 index 000000000..3165c201e --- /dev/null +++ b/src/app/features/call/rtcNotificationParser.ts @@ -0,0 +1,113 @@ +export const RTC_NOTIFICATION_EVENT_TYPE = 'org.matrix.msc4075.rtc.notification'; +export const REFERENCE_REL_TYPE = 'm.reference'; + +export type NotificationType = 'ring' | 'notification'; +export type NotificationIntentKind = 'audio' | 'video'; + +export type RtcNotificationEventLike = { + type: string; + sender: string; + roomId: string; + eventId: string; + originServerTs: number; + content: unknown; + relation?: { + rel_type?: string; + event_id?: string; + }; + isLiveEvent: boolean; + isEncrypted: boolean; +}; + +type RtcMentions = { + room?: boolean; + user_ids?: string[]; +}; + +type RtcNotificationContent = { + sender_ts?: number; + lifetime?: number; + notification_type?: NotificationType; + 'm.mentions'?: RtcMentions; + 'm.call.intent'?: string; +}; + +export type ParseIncomingRtcNotificationOptions = { + myUserId: string; + now: number; + maxLifetimeMs?: number; + decryptContent?: () => Promise; +}; + +export type ParsedIncomingRtcNotification = { + roomId: string; + notificationEventId: string; + refEventId: string; + senderId: string; + senderTs: number; + expiresAt: number; + notificationType: NotificationType; + intentKind: NotificationIntentKind; + intentRaw?: string; +}; + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const normalizeIntentKind = (intent?: string): NotificationIntentKind => + intent && intent.includes('voice') ? 'audio' : 'video'; + +const isMentioned = (mentions: RtcMentions | undefined, myUserId: string): boolean => { + if (!mentions) return false; + if (mentions.room) return true; + return Array.isArray(mentions.user_ids) && mentions.user_ids.includes(myUserId); +}; + +const toNotificationType = (value: unknown): NotificationType | undefined => + value === 'ring' || value === 'notification' ? value : undefined; + +const getSenderTimestamp = (contentTs: number, originTs: number): number => + contentTs - originTs > 20_000 ? originTs : contentTs; + +export const parseIncomingRtcNotification = async ( + event: RtcNotificationEventLike, + options: ParseIncomingRtcNotificationOptions +): Promise => { + if (!event.isLiveEvent) return undefined; + if (event.type !== RTC_NOTIFICATION_EVENT_TYPE) return undefined; + if (event.sender === options.myUserId) return undefined; + if (event.relation?.rel_type !== REFERENCE_REL_TYPE || !event.relation.event_id) return undefined; + + const rawContent = event.isEncrypted ? await options.decryptContent?.() : event.content; + if (!isObject(rawContent)) return undefined; + + const content = rawContent as RtcNotificationContent; + if (!isMentioned(content['m.mentions'], options.myUserId)) return undefined; + + const senderTsCandidate = content.sender_ts; + const lifetimeCandidate = content.lifetime; + const notificationType = toNotificationType(content.notification_type); + + if (typeof senderTsCandidate !== 'number') return undefined; + if (typeof lifetimeCandidate !== 'number' || !Number.isFinite(lifetimeCandidate)) return undefined; + if (!notificationType) return undefined; + + const senderTs = getSenderTimestamp(senderTsCandidate, event.originServerTs); + const lifetime = Math.min(lifetimeCandidate, options.maxLifetimeMs ?? 120_000); + const expiresAt = senderTs + lifetime; + if (options.now >= expiresAt) return undefined; + + const intentRaw = typeof content['m.call.intent'] === 'string' ? content['m.call.intent'] : undefined; + + return { + roomId: event.roomId, + notificationEventId: event.eventId, + refEventId: event.relation.event_id, + senderId: event.sender, + senderTs, + expiresAt, + notificationType, + intentKind: normalizeIntentKind(intentRaw), + intentRaw, + }; +}; diff --git a/src/app/plugins/call/CallEmbed.intent.test.ts b/src/app/plugins/call/CallEmbed.intent.test.ts new file mode 100644 index 000000000..98bbe9f99 --- /dev/null +++ b/src/app/plugins/call/CallEmbed.intent.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { vi } from 'vitest'; + +vi.mock('../../utils/debugLogger', () => ({ + createDebugLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +import { CallEmbed } from './CallEmbed'; +import { ElementCallIntent } from './types'; + +type IntentCase = { + dm: boolean; + ongoing: boolean; + video: boolean; + expected: string; +}; + +const intentCases: IntentCase[] = [ + { dm: true, ongoing: false, video: true, expected: ElementCallIntent.StartCallDM }, + { dm: true, ongoing: false, video: false, expected: ElementCallIntent.StartCallDMVoice }, + { dm: true, ongoing: true, video: true, expected: ElementCallIntent.JoinExistingDM }, + { dm: true, ongoing: true, video: false, expected: ElementCallIntent.JoinExistingDMVoice }, + { dm: false, ongoing: false, video: true, expected: ElementCallIntent.StartCall }, + { dm: false, ongoing: false, video: false, expected: 'start_call_voice' }, + { dm: false, ongoing: true, video: true, expected: ElementCallIntent.JoinExisting }, + { dm: false, ongoing: true, video: false, expected: 'join_existing_voice' }, +]; + +describe('CallEmbed.getIntent', () => { + it.each(intentCases)('maps dm=$dm ongoing=$ongoing video=$video to $expected', (tc) => { + const intent = CallEmbed.getIntent(tc.dm, tc.ongoing, tc.video); + expect(intent).toBe(tc.expected); + }); +}); diff --git a/src/test/fixtures/call/matrixRtcMemberships.test.ts b/src/test/fixtures/call/matrixRtcMemberships.test.ts new file mode 100644 index 000000000..0fbb02d6f --- /dev/null +++ b/src/test/fixtures/call/matrixRtcMemberships.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { matrixRtcMembershipFixtures } from './matrixRtcMemberships'; + +describe('matrixRtcMembershipFixtures', () => { + it('includes all baseline membership scenarios for call signaling tests', () => { + expect(matrixRtcMembershipFixtures.noMembers).toHaveLength(0); + expect(matrixRtcMembershipFixtures.remoteOnly).toHaveLength(1); + expect(matrixRtcMembershipFixtures.selfOnly).toHaveLength(1); + expect(matrixRtcMembershipFixtures.selfAndRemote).toHaveLength(2); + expect(matrixRtcMembershipFixtures.staleSelfAfterActiveCall).toHaveLength(1); + }); +}); diff --git a/src/test/fixtures/call/matrixRtcMemberships.ts b/src/test/fixtures/call/matrixRtcMemberships.ts new file mode 100644 index 000000000..403905689 --- /dev/null +++ b/src/test/fixtures/call/matrixRtcMemberships.ts @@ -0,0 +1,50 @@ +export type MatrixRtcMembershipFixture = { + userId: string; + sender: string; + deviceId: string; + expiresTs: number; +}; + +const BASE_TS = 1_700_000_000_000; + +export const matrixRtcMembershipFixtures = { + noMembers: [] as MatrixRtcMembershipFixture[], + remoteOnly: [ + { + userId: '@remote:example.org', + sender: '@remote:example.org', + deviceId: 'REMOTE_DEVICE', + expiresTs: BASE_TS + 60_000, + }, + ] as MatrixRtcMembershipFixture[], + selfOnly: [ + { + userId: '@self:example.org', + sender: '@self:example.org', + deviceId: 'SELF_DEVICE', + expiresTs: BASE_TS + 60_000, + }, + ] as MatrixRtcMembershipFixture[], + selfAndRemote: [ + { + userId: '@self:example.org', + sender: '@self:example.org', + deviceId: 'SELF_DEVICE', + expiresTs: BASE_TS + 60_000, + }, + { + userId: '@remote:example.org', + sender: '@remote:example.org', + deviceId: 'REMOTE_DEVICE', + expiresTs: BASE_TS + 60_000, + }, + ] as MatrixRtcMembershipFixture[], + staleSelfAfterActiveCall: [ + { + userId: '@self:example.org', + sender: '@self:example.org', + deviceId: 'SELF_DEVICE', + expiresTs: BASE_TS - 10_000, + }, + ] as MatrixRtcMembershipFixture[], +}; From 0e2028fb7fa93607dde9700b0444c64fc537ecb8 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 11:31:34 -0500 Subject: [PATCH 02/27] restore call intents --- src/app/features/room/RoomCallButton.tsx | 216 +++++++++++++----- src/app/features/room/RoomViewHeader.tsx | 11 +- src/app/hooks/useAutoJoinCall.ts | 18 +- src/app/plugins/call/CallEmbed.intent.test.ts | 95 +++++++- src/app/plugins/call/CallEmbed.ts | 36 ++- src/app/plugins/call/types.ts | 2 + 6 files changed, 312 insertions(+), 66 deletions(-) diff --git a/src/app/features/room/RoomCallButton.tsx b/src/app/features/room/RoomCallButton.tsx index 3c60e5935..d17b75997 100644 --- a/src/app/features/room/RoomCallButton.tsx +++ b/src/app/features/room/RoomCallButton.tsx @@ -1,75 +1,181 @@ -import { IconButton, Icon, Icons, TooltipProvider, Tooltip, Text } from 'folds'; +import type { MouseEventHandler } from 'react'; +import { useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import type { RectCords } from 'folds'; +import { + Box, + IconButton, + Icon, + Icons, + TooltipProvider, + Tooltip, + Text, + PopOut, + Menu, + MenuItem, + config, +} from 'folds'; import { useAtomValue } from 'jotai'; -import type { Room, TimelineEvents } from '$types/matrix-sdk'; +import type { Room } from '$types/matrix-sdk'; import { useCallStart, useCallJoined } from '$hooks/useCallEmbed'; +import type { CallPreferences } from '$state/callPreferences'; import { callEmbedAtom } from '$state/callEmbed'; -import { useMatrixClient } from '$hooks/useMatrixClient'; -import { useCallPreferences } from '$state/hooks/callPreferences'; +import { stopPropagation } from '$utils/keyboard'; interface RoomCallButtonProps { room: Room; + direct: boolean; + defaultPreferences: CallPreferences; + allowVideoStart?: boolean; } -export function RoomCallButton({ room }: RoomCallButtonProps) { - const startCall = useCallStart(); +type CallStartMenuProps = { + onVoiceCall: () => void; + onVideoCall: () => void; + requestClose: () => void; + allowVideoStart: boolean; +}; + +function CallStartMenu({ + onVoiceCall, + onVideoCall, + requestClose, + allowVideoStart, +}: CallStartMenuProps) { + return ( + + + + + Voice Call + + + {allowVideoStart && ( + + + Video Call + + + )} + + + Cancel + + + + + ); +} + +export function RoomCallButton({ + room, + direct, + defaultPreferences, + allowVideoStart = true, +}: RoomCallButtonProps) { + const startCall = useCallStart(direct); const callEmbed = useAtomValue(callEmbedAtom); const joined = useCallJoined(callEmbed); - const mx = useMatrixClient(); - const { microphone, video, sound } = useCallPreferences(); + const [menuAnchor, setMenuAnchor] = useState(); const isJoinedInThisRoom = joined && callEmbed?.roomId === room.roomId; + const callStartingInThisRoom = !!callEmbed && callEmbed.roomId === room.roomId && !joined; + const inAnotherCall = !!callEmbed && callEmbed.roomId !== room.roomId; + const startDisabled = inAnotherCall || callStartingInThisRoom; if (isJoinedInThisRoom) return null; - const handleStartCall = async () => { - startCall(room, { microphone, video, sound }); - try { - const now = Date.now(); - // TODO not use as any one day someday i swear - await mx.sendEvent( - room.roomId, - 'org.matrix.msc4075.rtc.notification' as keyof TimelineEvents, - { - notification_type: 'ring', - sender_ts: now, - lifetime: 30000, - 'm.mentions': { - room: true, - }, - application: 'm.call', - call_id: room.roomId, - 'm.text': [ - { - body: `Call started by ${mx.getUser(mx.getSafeUserId())?.displayName || 'User'} 🎶`, - }, - ], - } as unknown as TimelineEvents[keyof TimelineEvents] - ); - } catch { - /* skill issue block */ - } + const startVoiceCall = () => { + startCall(room, { + microphone: defaultPreferences.microphone, + video: false, + sound: defaultPreferences.sound, + }); + setMenuAnchor(undefined); + }; + + const startVideoCall = () => { + startCall(room, { + microphone: defaultPreferences.microphone, + video: true, + sound: defaultPreferences.sound, + }); + setMenuAnchor(undefined); + }; + + const startDefaultCall = () => { + const resolvedVideo = allowVideoStart ? defaultPreferences.video : false; + startCall(room, { + microphone: defaultPreferences.microphone, + video: resolvedVideo, + sound: defaultPreferences.sound, + }); + }; + + const handleOpenMenu: MouseEventHandler = (evt) => { + setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; return ( - - Start Voice Call - - } - > - {(triggerRef) => ( - - - - )} - + <> + + {inAnotherCall ? ( + Already in another call + ) : callStartingInThisRoom ? ( + Call is starting + ) : ( + Start Call + )} + + } + > + {(triggerRef) => ( + { + evt.preventDefault(); + if (startDisabled) return; + startDefaultCall(); + }} + disabled={startDisabled} + aria-label="Start Call" + aria-pressed={!!menuAnchor} + > + + + )} + + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} + allowVideoStart={allowVideoStart} + /> + + } + /> + ); } diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index f45e6172f..da2a6a3c6 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -91,6 +91,7 @@ import { callChatAtom } from '$state/callEmbed'; import { RoomSettingsPage } from '$state/roomSettings'; import { roomIdToThreadBrowserAtomFamily } from '$state/room/roomToThreadBrowser'; import { roomIdToOpenThreadAtomFamily } from '$state/room/roomToOpenThread'; +import { useCallPreferences } from '$state/hooks/callPreferences'; import { JumpToTime } from './jump-to-time'; import { RoomPinMenu } from './room-pin-menu'; import * as css from './RoomViewHeader.css'; @@ -355,6 +356,7 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { const [pinMenuAnchor, setPinMenuAnchor] = useState(); const direct = useIsDirectRoom(); const [customDMCards] = useSetting(settingsAtom, 'customDMCards'); + const { microphone, video, sound } = useCallPreferences(); const [chat, setChat] = useAtom(callChatAtom); const [threadBrowserOpen, setThreadBrowserOpen] = useAtom( @@ -725,7 +727,14 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { )} - {canUseCalls && shouldShowCallButton && } + {canUseCalls && shouldShowCallButton && ( + + )} { if (selectedRoomId && autoJoinIntent && selectedRoomId === autoJoinIntent) { const room = mx.getRoom(selectedRoomId); if (room) { + const startCall = mDirects.has(room.roomId) ? startDirectCall : startRoomCall; startCall(room); setAutoJoinIntent(null); } } - }, [selectedRoomId, autoJoinIntent, startCall, setAutoJoinIntent, mx]); + }, [ + selectedRoomId, + autoJoinIntent, + setAutoJoinIntent, + mx, + mDirects, + startDirectCall, + startRoomCall, + ]); } diff --git a/src/app/plugins/call/CallEmbed.intent.test.ts b/src/app/plugins/call/CallEmbed.intent.test.ts index 98bbe9f99..10a62834d 100644 --- a/src/app/plugins/call/CallEmbed.intent.test.ts +++ b/src/app/plugins/call/CallEmbed.intent.test.ts @@ -26,9 +26,9 @@ const intentCases: IntentCase[] = [ { dm: true, ongoing: true, video: true, expected: ElementCallIntent.JoinExistingDM }, { dm: true, ongoing: true, video: false, expected: ElementCallIntent.JoinExistingDMVoice }, { dm: false, ongoing: false, video: true, expected: ElementCallIntent.StartCall }, - { dm: false, ongoing: false, video: false, expected: 'start_call_voice' }, + { dm: false, ongoing: false, video: false, expected: ElementCallIntent.StartCallVoice }, { dm: false, ongoing: true, video: true, expected: ElementCallIntent.JoinExisting }, - { dm: false, ongoing: true, video: false, expected: 'join_existing_voice' }, + { dm: false, ongoing: true, video: false, expected: ElementCallIntent.JoinExistingVoice }, ]; describe('CallEmbed.getIntent', () => { @@ -37,3 +37,94 @@ describe('CallEmbed.getIntent', () => { expect(intent).toBe(tc.expected); }); }); + +describe('CallEmbed.dmCall', () => { + it.each([ + ElementCallIntent.StartCallDM, + ElementCallIntent.StartCallDMVoice, + ElementCallIntent.JoinExistingDM, + ElementCallIntent.JoinExistingDMVoice, + ])('returns true for DM intent %s', (intent) => { + expect(CallEmbed.dmCall(intent)).toBe(true); + }); + + it.each([ + ElementCallIntent.StartCall, + ElementCallIntent.StartCallVoice, + ElementCallIntent.JoinExisting, + ElementCallIntent.JoinExistingVoice, + ])('returns false for room intent %s', (intent) => { + expect(CallEmbed.dmCall(intent)).toBe(false); + }); +}); + +describe('CallEmbed.startingCall', () => { + it.each([ + ElementCallIntent.StartCall, + ElementCallIntent.StartCallVoice, + ElementCallIntent.StartCallDM, + ElementCallIntent.StartCallDMVoice, + ])('returns true for start intent %s', (intent) => { + expect(CallEmbed.startingCall(intent)).toBe(true); + }); + + it.each([ + ElementCallIntent.JoinExisting, + ElementCallIntent.JoinExistingVoice, + ElementCallIntent.JoinExistingDM, + ElementCallIntent.JoinExistingDMVoice, + ])('returns false for join intent %s', (intent) => { + expect(CallEmbed.startingCall(intent)).toBe(false); + }); +}); + +describe('CallEmbed.getWidget', () => { + vi.stubGlobal('window', { + location: { origin: 'https://app.example.com' }, + }); + + const mx = { + baseUrl: 'https://matrix.example.com', + getSafeUserId: () => '@alice:example.com', + getDeviceId: () => 'ALICEDEVICE', + } as never; + + const createRoom = (isCallRoom: boolean) => + ({ + roomId: '!room:example.com', + hasEncryptionStateEvent: () => false, + isCallRoom: () => isCallRoom, + }) as never; + + it('adds ring notification delegation for starting DM calls in non-call rooms', () => { + const room = createRoom(false); + const widget = CallEmbed.getWidget(mx, room, ElementCallIntent.StartCallDMVoice, 'dark'); + const url = new URL(widget.getCompleteUrl({ currentUserId: '@alice:example.com' })); + + expect(url.searchParams.get('sendNotificationType')).toBe('ring'); + }); + + it('adds notification delegation for starting room calls in non-call rooms', () => { + const room = createRoom(false); + const widget = CallEmbed.getWidget(mx, room, ElementCallIntent.StartCallVoice, 'dark'); + const url = new URL(widget.getCompleteUrl({ currentUserId: '@alice:example.com' })); + + expect(url.searchParams.get('sendNotificationType')).toBe('notification'); + }); + + it('does not add notification delegation for join intents', () => { + const room = createRoom(false); + const widget = CallEmbed.getWidget(mx, room, ElementCallIntent.JoinExisting, 'dark'); + const url = new URL(widget.getCompleteUrl({ currentUserId: '@alice:example.com' })); + + expect(url.searchParams.get('sendNotificationType')).toBeNull(); + }); + + it('does not add notification delegation in call rooms', () => { + const room = createRoom(true); + const widget = CallEmbed.getWidget(mx, room, ElementCallIntent.StartCallDM, 'dark'); + const url = new URL(widget.getCompleteUrl({ currentUserId: '@alice:example.com' })); + + expect(url.searchParams.get('sendNotificationType')).toBeNull(); + }); +}); diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index b31323d4b..be87509e1 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -40,15 +40,36 @@ export class CallEmbed { private readonly disposables: Array<() => void> = []; static getIntent(dm: boolean, ongoing: boolean, video: boolean | undefined): ElementCallIntent { - if (!dm) { - return video ? ElementCallIntent.JoinExisting : ElementCallIntent.JoinExistingDMVoice; + if (ongoing) { + if (dm) { + return video ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExistingDMVoice; + } + return video ? ElementCallIntent.JoinExisting : ElementCallIntent.JoinExistingVoice; } - if (ongoing) { - return video ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExistingDMVoice; + if (dm) { + return video ? ElementCallIntent.StartCallDM : ElementCallIntent.StartCallDMVoice; } - return video ? ElementCallIntent.StartCallDM : ElementCallIntent.StartCallDMVoice; + return video ? ElementCallIntent.StartCall : ElementCallIntent.StartCallVoice; + } + + static dmCall(intent: ElementCallIntent): boolean { + return ( + intent === ElementCallIntent.JoinExistingDM || + intent === ElementCallIntent.JoinExistingDMVoice || + intent === ElementCallIntent.StartCallDM || + intent === ElementCallIntent.StartCallDMVoice + ); + } + + static startingCall(intent: ElementCallIntent): boolean { + return ( + intent === ElementCallIntent.StartCallDM || + intent === ElementCallIntent.StartCallDMVoice || + intent === ElementCallIntent.StartCall || + intent === ElementCallIntent.StartCallVoice + ); } static getWidget( @@ -77,8 +98,13 @@ export class CallEmbed { perParticipantE2EE: room.hasEncryptionStateEvent().toString(), lang: 'en-EN', theme: themeKind, + header: 'none', }); + if (!room.isCallRoom() && CallEmbed.startingCall(intent)) { + params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification'); + } + const widgetUrl = new URL( `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`, window.location.origin diff --git a/src/app/plugins/call/types.ts b/src/app/plugins/call/types.ts index 4f4fc3817..89a9e61d4 100644 --- a/src/app/plugins/call/types.ts +++ b/src/app/plugins/call/types.ts @@ -1,6 +1,8 @@ export enum ElementCallIntent { StartCall = 'start_call', JoinExisting = 'join_existing', + StartCallVoice = 'start_call_voice', + JoinExistingVoice = 'join_existing_voice', StartCallDM = 'start_call_dm', JoinExistingDM = 'join_existing_dm', StartCallDMVoice = 'start_call_dm_voice', From 9fe52e3515dcc53fd11d19d022a215046b3a0c1b Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 11:59:00 -0500 Subject: [PATCH 03/27] match spec notification handling better --- src/app/components/IncomingCallModal.test.tsx | 40 +- src/app/components/IncomingCallModal.tsx | 108 ++-- src/app/hooks/useAutoJoinCall.ts | 12 +- src/app/hooks/useCallSignaling.ts | 516 +++++++++++------- src/app/pages/client/ClientNonUIFeatures.tsx | 4 +- src/app/state/callEmbed.ts | 26 +- 6 files changed, 476 insertions(+), 230 deletions(-) diff --git a/src/app/components/IncomingCallModal.test.tsx b/src/app/components/IncomingCallModal.test.tsx index 974fe9e95..2b9ea2a87 100644 --- a/src/app/components/IncomingCallModal.test.tsx +++ b/src/app/components/IncomingCallModal.test.tsx @@ -1,14 +1,17 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import type { Room } from '$types/matrix-sdk'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { IncomingCallInternal } from './IncomingCallModal'; -const { navigateRoomMock } = vi.hoisted(() => ({ +const { navigateRoomMock, sendRtcDeclineMock } = vi.hoisted(() => ({ navigateRoomMock: vi.fn<(roomId: string) => void>(), + sendRtcDeclineMock: vi.fn<(roomId: string, eventId: string) => Promise>(), })); vi.mock('$hooks/useMatrixClient', () => ({ - useMatrixClient: () => ({}), + useMatrixClient: () => ({ + sendRtcDecline: sendRtcDeclineMock, + }), })); vi.mock('$hooks/useRoomMeta', () => ({ @@ -36,26 +39,49 @@ vi.mock('@sentry/react', () => ({ }, })); +vi.mock('$utils/debugLogger', () => ({ + createDebugLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + describe('IncomingCallInternal', () => { const room = { roomId: '!room:example.org' } as Room; + const incomingCall = { + roomId: room.roomId, + notificationEventId: '$notif', + refEventId: '$ref', + senderId: '@alice:example.org', + senderTs: Date.now(), + expiresAt: Date.now() + 60_000, + notificationType: 'ring' as const, + intentKind: 'audio' as const, + isDirect: true, + }; beforeEach(() => { navigateRoomMock.mockReset(); + sendRtcDeclineMock.mockReset().mockResolvedValue(undefined); }); - it('closes the modal when decline is pressed', () => { + it('closes the modal when decline is pressed', async () => { const onClose = vi.fn<() => void>(); - render(); + render(); fireEvent.click(screen.getByRole('button', { name: /decline/i })); - expect(onClose).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(onClose).toHaveBeenCalledTimes(1); + }); expect(navigateRoomMock).not.toHaveBeenCalled(); + expect(sendRtcDeclineMock).toHaveBeenCalledWith('!room:example.org', '$notif'); }); it('navigates and closes when answer is pressed', () => { const onClose = vi.fn<() => void>(); - render(); + render(); fireEvent.click(screen.getByRole('button', { name: /answer/i })); diff --git a/src/app/components/IncomingCallModal.tsx b/src/app/components/IncomingCallModal.tsx index 9652adf5d..7432841a9 100644 --- a/src/app/components/IncomingCallModal.tsx +++ b/src/app/components/IncomingCallModal.tsx @@ -1,17 +1,17 @@ import { + Avatar, Box, + Button, Dialog, Header, - IconButton, Icon, + IconButton, Icons, - Text, - Button, - Avatar, - config, Overlay, - OverlayCenter, OverlayBackdrop, + OverlayCenter, + Text, + config, } from 'folds'; import type { Room } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; @@ -22,11 +22,7 @@ import FocusTrap from 'focus-trap-react'; import { stopPropagation } from '$utils/keyboard'; import * as Sentry from '@sentry/react'; import { useAtom, useSetAtom } from 'jotai'; -import { - autoJoinCallIntentAtom, - incomingCallRoomIdAtom, - mutedCallRoomIdAtom, -} from '$state/callEmbed'; +import { autoJoinCallIntentAtom, incomingCallAtom, mutedCallRoomIdAtom, type IncomingCall } from '$state/callEmbed'; import { createDebugLogger } from '$utils/debugLogger'; import { RoomAvatar } from './room-avatar'; @@ -34,10 +30,11 @@ const debugLog = createDebugLogger('IncomingCall'); type IncomingCallInternalProps = { room: Room; + incomingCall: IncomingCall; onClose: () => void; }; -export function IncomingCallInternal({ room, onClose }: IncomingCallInternalProps) { +export function IncomingCallInternal({ room, incomingCall, onClose }: IncomingCallInternalProps) { const mx = useMatrixClient(); const roomName = useRoomName(room); const { navigateRoom } = useRoomNavigate(); @@ -45,28 +42,74 @@ export function IncomingCallInternal({ room, onClose }: IncomingCallInternalProp const setAutoJoinIntent = useSetAtom(autoJoinCallIntentAtom); const setMutedRoomId = useSetAtom(mutedCallRoomIdAtom); + const isDirectRing = incomingCall.isDirect && incomingCall.notificationType === 'ring'; + const isVideoIntent = incomingCall.intentKind === 'video'; + const handleAnswer = () => { - debugLog.info('call', 'Incoming call answered', { roomId: room.roomId }); + debugLog.info('call', 'Incoming call answered', { + roomId: room.roomId, + notificationEventId: incomingCall.notificationEventId, + notificationType: incomingCall.notificationType, + intent: incomingCall.intentRaw, + }); Sentry.addBreadcrumb({ category: 'call.signal', message: 'Incoming call answered', - data: { roomId: room.roomId }, + data: { + roomId: room.roomId, + notificationEventId: incomingCall.notificationEventId, + }, + }); + Sentry.metrics.count('sable.call.answered', 1, { + attributes: { + type: incomingCall.notificationType, + dm: String(incomingCall.isDirect), + intent: incomingCall.intentKind, + }, }); - Sentry.metrics.count('sable.call.answered', 1); + setMutedRoomId(room.roomId); - setAutoJoinIntent(room.roomId); + setAutoJoinIntent({ roomId: room.roomId, video: isVideoIntent }); onClose(); navigateRoom(room.roomId); }; - const handleDecline = async () => { - debugLog.info('call', 'Incoming call declined', { roomId: room.roomId }); + const handleDeclineOrIgnore = async () => { + const action = isDirectRing ? 'decline' : 'ignore'; + debugLog.info('call', 'Incoming call dismissed', { + roomId: room.roomId, + action, + notificationEventId: incomingCall.notificationEventId, + notificationType: incomingCall.notificationType, + }); Sentry.addBreadcrumb({ category: 'call.signal', - message: 'Incoming call declined', - data: { roomId: room.roomId }, + message: `Incoming call ${action}`, + data: { + roomId: room.roomId, + notificationEventId: incomingCall.notificationEventId, + }, + }); + Sentry.metrics.count(`sable.call.${action}d`, 1, { + attributes: { + type: incomingCall.notificationType, + dm: String(incomingCall.isDirect), + }, }); - Sentry.metrics.count('sable.call.declined', 1); + + if (isDirectRing) { + try { + await mx.sendRtcDecline(room.roomId, incomingCall.notificationEventId); + } catch (error) { + debugLog.warn('call', 'Failed to send RTC decline event', { + roomId: room.roomId, + notificationEventId: incomingCall.notificationEventId, + error: error instanceof Error ? error.message : String(error), + }); + Sentry.metrics.count('sable.call.decline.error', 1); + } + } + setMutedRoomId(room.roomId); onClose(); }; @@ -84,7 +127,7 @@ export function IncomingCallInternal({ room, onClose }: IncomingCallInternalProp Incoming Call - + @@ -104,7 +147,7 @@ export function IncomingCallInternal({ room, onClose }: IncomingCallInternalProp {roomName} - Incoming voice chat request + {isVideoIntent ? 'Incoming video chat request' : 'Incoming voice chat request'} @@ -113,16 +156,16 @@ export function IncomingCallInternal({ room, onClose }: IncomingCallInternalProp variant="Critical" fill="Soft" style={{ minWidth: '110px' }} - onClick={handleDecline} + onClick={handleDeclineOrIgnore} > - Decline + {isDirectRing ? 'Decline' : 'Ignore'} @@ -133,13 +176,13 @@ export function IncomingCallInternal({ room, onClose }: IncomingCallInternalProp } export function IncomingCallModal() { - const [ringingRoomId, setRingingRoomId] = useAtom(incomingCallRoomIdAtom); + const [incomingCall, setIncomingCall] = useAtom(incomingCallAtom); const mx = useMatrixClient(); - const room = ringingRoomId ? mx.getRoom(ringingRoomId) : null; + const room = incomingCall ? mx.getRoom(incomingCall.roomId) : null; - if (!ringingRoomId || !room) return null; + if (!incomingCall || !room) return null; - const close = () => setRingingRoomId(null); + const close = () => setIncomingCall(null); return ( }> @@ -147,13 +190,12 @@ export function IncomingCallModal() {
- +
diff --git a/src/app/hooks/useAutoJoinCall.ts b/src/app/hooks/useAutoJoinCall.ts index e6ddbf0f0..0f95d9485 100644 --- a/src/app/hooks/useAutoJoinCall.ts +++ b/src/app/hooks/useAutoJoinCall.ts @@ -5,22 +5,28 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { autoJoinCallIntentAtom } from '$state/callEmbed'; import { mDirectAtom } from '$state/mDirectList'; +import { useCallPreferences } from '$state/hooks/callPreferences'; export function useAutoJoinCall() { const mx = useMatrixClient(); const selectedRoomId = useSelectedRoom(); const [autoJoinIntent, setAutoJoinIntent] = useAtom(autoJoinCallIntentAtom); const mDirects = useAtomValue(mDirectAtom); + const callPreferences = useCallPreferences(); const startDirectCall = useCallStart(true); const startRoomCall = useCallStart(false); useEffect(() => { - if (selectedRoomId && autoJoinIntent && selectedRoomId === autoJoinIntent) { + if (selectedRoomId && autoJoinIntent && selectedRoomId === autoJoinIntent.roomId) { const room = mx.getRoom(selectedRoomId); if (room) { const startCall = mDirects.has(room.roomId) ? startDirectCall : startRoomCall; - startCall(room); + startCall(room, { + microphone: callPreferences.microphone, + video: autoJoinIntent.video, + sound: callPreferences.sound, + }); setAutoJoinIntent(null); } } @@ -30,6 +36,8 @@ export function useAutoJoinCall() { setAutoJoinIntent, mx, mDirects, + callPreferences.microphone, + callPreferences.sound, startDirectCall, startRoomCall, ]); diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index 73cf864e2..faf77ec21 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -1,247 +1,395 @@ -import { useEffect, useRef, useCallback } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import * as Sentry from '@sentry/react'; -import { RoomStateEvent } from '$types/matrix-sdk'; -import { MatrixRTCSession } from '$types/matrix-sdk'; -import { MatrixRTCSessionManagerEvents } from '$types/matrix-sdk'; -import { useSetAtom, useAtomValue } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; +import type { RoomEventHandlerMap, MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; +import { CryptoBackend, MatrixRTCSession, MatrixRTCSessionManagerEvents, RoomEvent } from '$types/matrix-sdk'; import { mDirectAtom } from '$state/mDirectList'; -import { incomingCallRoomIdAtom, mutedCallRoomIdAtom } from '$state/callEmbed'; +import { incomingCallAtom, mutedCallRoomIdAtom, type IncomingCall } from '$state/callEmbed'; import RingtoneSound from '$public/sound/ringtone.webm'; +import { + parseIncomingRtcNotification, + REFERENCE_REL_TYPE, + RTC_NOTIFICATION_EVENT_TYPE, +} from '$features/call/rtcNotificationParser'; import { useMatrixClient } from './useMatrixClient'; import { createDebugLogger } from '../utils/debugLogger'; const debugLog = createDebugLogger('CallSignaling'); -type CallPhase = 'IDLE' | 'RINGING_OUT' | 'RINGING_IN' | 'ACTIVE' | 'ENDED'; +const MAX_NOTIFICATION_LIFETIME_MS = 120_000; +const DECRYPT_TIMEOUT_MS = 8_000; +const FALLBACK_INTERVAL_MS = 5_000; +const OUTGOING_RING_TIMEOUT_MS = 30_000; -interface SignalState { - incoming: string | null; - outgoing: string | null; -} +type SessionDescription = Parameters[1]; -export function useCallSignaling() { +const getRoomMemberships = (room: Room, sessionDescription: SessionDescription) => + MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription); + +const isIncomingCallActive = ( + mxUserId: string, + room: Room, + sessionDescription: SessionDescription +): boolean => { + const memberships = getRoomMemberships(room, sessionDescription); + const remoteMembers = memberships.filter( + (m: { userId?: string; sender?: string }) => (m.userId || m.sender) !== mxUserId + ); + const selfMember = memberships.some( + (m: { userId?: string; sender?: string }) => (m.userId || m.sender) === mxUserId + ); + + return remoteMembers.length > 0 && !selfMember; +}; + +const isCallActive = ( + mxUserId: string, + room: Room, + sessionDescription: SessionDescription +): boolean => { + const memberships = getRoomMemberships(room, sessionDescription); + const remoteMembers = memberships.filter( + (m: { userId?: string; sender?: string }) => (m.userId || m.sender) !== mxUserId + ); + const selfMember = memberships.some( + (m: { userId?: string; sender?: string }) => (m.userId || m.sender) === mxUserId + ); + + return selfMember && remoteMembers.length > 0; +}; + +const decryptWithTimeout = async ( + event: MatrixEvent, + mx: MatrixClient +): Promise<{ type?: string; content?: unknown } | undefined> => { + const crypto = mx.getCrypto(); + if (!crypto) return undefined; + + try { + if (!event.isBeingDecrypted()) { + await event.attemptDecryption(crypto as CryptoBackend); + } + + const decryptionPromise = event.getDecryptionPromise(); + if (decryptionPromise) { + await Promise.race([ + decryptionPromise, + new Promise((resolve) => { + window.setTimeout(resolve, DECRYPT_TIMEOUT_MS); + }), + ]); + } + } catch (error) { + debugLog.warn('call', 'RTC notification decryption failed', { + eventId: event.getId(), + roomId: event.getRoomId(), + error: error instanceof Error ? error.message : String(error), + }); + return undefined; + } + + const effectiveEvent = event.getEffectiveEvent(); + return { + type: effectiveEvent.type, + content: effectiveEvent.content, + }; +}; + +const canSenderStartCalls = (room: Room, senderId: string): boolean => + room.currentState?.maySendStateEvent('org.matrix.msc3401.call.member', senderId) ?? false; + +export function useIncomingCallSignaling() { const mx = useMatrixClient(); - const setIncomingCall = useSetAtom(incomingCallRoomIdAtom); const mDirects = useAtomValue(mDirectAtom); + const incomingCall = useAtomValue(incomingCallAtom); + const mutedRoomId = useAtomValue(mutedCallRoomIdAtom); + const setIncomingCall = useSetAtom(incomingCallAtom); + const setMutedRoomId = useSetAtom(mutedCallRoomIdAtom); const incomingAudioRef = useRef(null); const outgoingAudioRef = useRef(null); - const ringingRoomIdRef = useRef(null); + const incomingCallRef = useRef(incomingCall); + const mutedRoomIdRef = useRef(mutedRoomId); + const seenNotificationIdsRef = useRef>(new Set()); + const outgoingRingRoomIdRef = useRef(null); const outgoingStartRef = useRef(null); - const callPhaseRef = useRef>({}); - const mutedRoomId = useAtomValue(mutedCallRoomIdAtom); - const setMutedRoomId = useSetAtom(mutedCallRoomIdAtom); - - // Stable refs so volatile values (mutedRoomId, ring callbacks) don't force - // the listener registration effect to re-run — which would cause the - // SessionEnded and RoomState.events listeners to accumulate when muting - // or when call state changes rapidly during a sync retry cycle. - const mutedRoomIdRef = useRef(mutedRoomId); + incomingCallRef.current = incomingCall; mutedRoomIdRef.current = mutedRoomId; useEffect(() => { - const inc = new Audio(RingtoneSound); - inc.loop = true; - incomingAudioRef.current = inc; + const incoming = new Audio(RingtoneSound); + incoming.loop = true; + incomingAudioRef.current = incoming; - const out = new Audio(RingtoneSound); - out.loop = true; - outgoingAudioRef.current = out; + const outgoing = new Audio(RingtoneSound); + outgoing.loop = true; + outgoingAudioRef.current = outgoing; return () => { - inc.pause(); - out.pause(); + incoming.pause(); + outgoing.pause(); }; }, []); - const stopRinging = useCallback(() => { + const stopIncomingRing = useCallback(() => { incomingAudioRef.current?.pause(); - outgoingAudioRef.current?.pause(); if (incomingAudioRef.current) incomingAudioRef.current.currentTime = 0; + }, []); + + const stopOutgoingRing = useCallback(() => { + outgoingAudioRef.current?.pause(); if (outgoingAudioRef.current) outgoingAudioRef.current.currentTime = 0; + outgoingRingRoomIdRef.current = null; + outgoingStartRef.current = null; + }, []); - ringingRoomIdRef.current = null; + const clearIncomingCall = useCallback(() => { + stopIncomingRing(); setIncomingCall(null); - }, [setIncomingCall]); - - const playOutgoingRinging = useCallback((roomId: string) => { - if (outgoingAudioRef.current && ringingRoomIdRef.current !== roomId) { - outgoingAudioRef.current.play().catch(() => {}); - ringingRoomIdRef.current = roomId; - } - }, []); + }, [setIncomingCall, stopIncomingRing]); + + const handleIncomingCall = useCallback( + (nextIncomingCall: IncomingCall) => { + if (mutedRoomIdRef.current === nextIncomingCall.roomId) return; + if (seenNotificationIdsRef.current.has(nextIncomingCall.notificationEventId)) return; + + seenNotificationIdsRef.current.add(nextIncomingCall.notificationEventId); + setIncomingCall(nextIncomingCall); + + debugLog.info('call', 'Incoming RTC notification accepted', { + roomId: nextIncomingCall.roomId, + notificationType: nextIncomingCall.notificationType, + intent: nextIncomingCall.intentRaw, + }); + Sentry.addBreadcrumb({ + category: 'call.signal', + message: 'Incoming RTC notification', + data: { + roomId: nextIncomingCall.roomId, + notificationType: nextIncomingCall.notificationType, + intent: nextIncomingCall.intentRaw, + }, + }); + Sentry.metrics.count('sable.call.incoming.shown', 1, { + attributes: { + type: nextIncomingCall.notificationType, + dm: String(nextIncomingCall.isDirect), + }, + }); - const playRinging = useCallback( - (roomId: string) => { - if (incomingAudioRef.current && ringingRoomIdRef.current !== roomId) { - incomingAudioRef.current.play().catch(() => {}); - ringingRoomIdRef.current = roomId; - setIncomingCall(roomId); + if (nextIncomingCall.notificationType === 'ring') { + incomingAudioRef.current?.play().catch(() => { + Sentry.metrics.count('sable.call.ringtone.blocked', 1); + }); + } else { + stopIncomingRing(); } }, - [setIncomingCall] + [setIncomingCall, stopIncomingRing] ); - // Must be declared after the callbacks above so the initial useRef(value) call - // sees their current identity. Updated on every render so the effect closure - // always calls the latest version without needing them in the dep array. - const playRingingRef = useRef(playRinging); - playRingingRef.current = playRinging; - const stopRingingRef = useRef(stopRinging); - stopRingingRef.current = stopRinging; - const playOutgoingRingingRef = useRef(playOutgoingRinging); - playOutgoingRingingRef.current = playOutgoingRinging; - useEffect(() => { if (!mx || !mx.matrixRTC) return undefined; - const checkDMsForActiveCalls = () => { - const myUserId = mx.getUserId(); - const now = Date.now(); + const myUserId = mx.getSafeUserId(); + + const parseEvent = async ( + event: MatrixEvent, + room: Room, + liveEvent: boolean + ): Promise => { + const relation = event.getRelation(); + if (relation?.rel_type !== REFERENCE_REL_TYPE || !relation.event_id) return undefined; + + let eventType = event.getType(); + let content = event.getContent(); + + if (event.isEncrypted()) { + const decrypted = await decryptWithTimeout(event, mx); + if (!decrypted?.content || !decrypted.type) { + Sentry.metrics.count('sable.call.signal.decrypt_timeout', 1); + return undefined; + } + eventType = decrypted.type; + content = decrypted.content; + } - const signal = Array.from(mDirects).reduce( - (acc, roomId) => { - if (acc.incoming || mutedRoomIdRef.current === roomId) return acc; - - const room = mx.getRoom(roomId); - if (!room) return acc; - - const session = mx.matrixRTC.getRoomSession(room); - const memberships = MatrixRTCSession.sessionMembershipsForRoom( - room, - session.sessionDescription - ); - - const remoteMembers = memberships.filter( - (m: { userId?: string; sender?: string }) => (m.userId || m.sender) !== myUserId - ); - const isSelfInCall = memberships.some( - (m: { userId?: string; sender?: string }) => (m.userId || m.sender) === myUserId - ); - const currentPhase = callPhaseRef.current[roomId] || 'IDLE'; - - // no one here - if (!isSelfInCall && remoteMembers.length === 0) { - callPhaseRef.current[roomId] = 'IDLE'; - return acc; - } + const parsed = await parseIncomingRtcNotification( + { + type: eventType, + sender: event.getSender() ?? '', + roomId: room.roomId, + eventId: event.getId() ?? '', + originServerTs: event.getTs(), + content, + relation: { + rel_type: relation.rel_type, + event_id: relation.event_id, + }, + isLiveEvent: liveEvent, + isEncrypted: false, + }, + { + myUserId, + now: Date.now(), + maxLifetimeMs: MAX_NOTIFICATION_LIFETIME_MS, + } + ); - // being called - if (remoteMembers.length > 0 && !isSelfInCall) { - if (currentPhase !== 'RINGING_IN') { - debugLog.info('call', 'Incoming call detected', { - roomId, - remoteCount: remoteMembers.length, - }); - Sentry.addBreadcrumb({ - category: 'call.signal', - message: 'Incoming call ringing', - data: { roomId }, - }); - } - callPhaseRef.current[roomId] = 'RINGING_IN'; - acc.incoming = roomId; - return acc; - } + if (!parsed) return undefined; + if (!canSenderStartCalls(room, parsed.senderId)) { + debugLog.warn('call', 'Rejected incoming call without call-member permission', { + roomId: room.roomId, + senderId: parsed.senderId, + }); + return undefined; + } - // multiple people no ringtone - if (isSelfInCall && remoteMembers.length > 0) { - if (currentPhase !== 'ACTIVE') { - debugLog.info('call', 'Call became active', { roomId }); - Sentry.addBreadcrumb({ - category: 'call.signal', - message: 'Call active', - data: { roomId }, - }); - Sentry.metrics.count('sable.call.active', 1); - } - callPhaseRef.current[roomId] = 'ACTIVE'; - return acc; - } + return { + ...parsed, + isDirect: mDirects.has(room.roomId), + }; + }; - // alone in call - if (isSelfInCall && remoteMembers.length === 0) { - // Check if post call - if (currentPhase === 'ACTIVE' || currentPhase === 'ENDED') { - if (currentPhase !== 'ENDED') { - debugLog.info('call', 'Call ended', { roomId }); - Sentry.addBreadcrumb({ - category: 'call.signal', - message: 'Call ended', - data: { roomId }, - }); - Sentry.metrics.count('sable.call.ended', 1); - } - callPhaseRef.current[roomId] = 'ENDED'; - return acc; - } + const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = async ( + event, + room, + _toStartOfTimeline, + _removed, + data + ) => { + if (!room || !data.liveEvent) return; - // Check if new call - if (currentPhase === 'IDLE' || currentPhase === 'RINGING_OUT') { - if (!outgoingStartRef.current) outgoingStartRef.current = now; - - if (now - outgoingStartRef.current < 30000) { - if (currentPhase !== 'RINGING_OUT') { - debugLog.info('call', 'Outgoing call ringing', { roomId }); - Sentry.addBreadcrumb({ - category: 'call.signal', - message: 'Outgoing call ringing', - data: { roomId }, - }); - } - callPhaseRef.current[roomId] = 'RINGING_OUT'; - acc.outgoing = roomId; - return acc; - } - - debugLog.info('call', 'Outgoing call timed out (unanswered)', { - roomId, - }); - Sentry.metrics.count('sable.call.timeout', 1); - callPhaseRef.current[roomId] = 'ENDED'; - } - } + const relation = event.getRelation(); + if (relation?.rel_type !== REFERENCE_REL_TYPE) return; - return acc; - }, - { incoming: null, outgoing: null } - ); + const type = event.getType(); + if (type !== RTC_NOTIFICATION_EVENT_TYPE && !event.isEncrypted()) return; + if (event.getSender() === myUserId) return; + if (!event.getId()) return; - if (signal.incoming) { - playRingingRef.current(signal.incoming); - } else if (signal.outgoing) { - playOutgoingRingingRef.current(signal.outgoing); - } else { - stopRingingRef.current(); - if (!signal.outgoing) outgoingStartRef.current = null; - } + const incoming = await parseEvent(event, room, data.liveEvent); + if (!incoming) return; + + handleIncomingCall(incoming); }; - const interval = setInterval(checkDMsForActiveCalls, 1000); + const evaluateFallbackState = () => { + const currentIncoming = incomingCallRef.current; + if (currentIncoming) { + if (Date.now() >= currentIncoming.expiresAt) { + debugLog.info('call', 'Incoming call timed out', { + roomId: currentIncoming.roomId, + notificationEventId: currentIncoming.notificationEventId, + }); + Sentry.metrics.count('sable.call.timeout', 1); + clearIncomingCall(); + return; + } + + const incomingRoom = mx.getRoom(currentIncoming.roomId); + if (!incomingRoom) { + clearIncomingCall(); + return; + } + + const session = mx.matrixRTC.getRoomSession(incomingRoom); + if (!isIncomingCallActive(myUserId, incomingRoom, session.sessionDescription)) { + debugLog.info('call', 'Incoming call cleared after membership drop', { + roomId: currentIncoming.roomId, + }); + clearIncomingCall(); + return; + } + } + + const outgoingRoomId = outgoingRingRoomIdRef.current; + if (outgoingRoomId) { + const outgoingRoom = mx.getRoom(outgoingRoomId); + if (!outgoingRoom) { + stopOutgoingRing(); + return; + } + const session = mx.matrixRTC.getRoomSession(outgoingRoom); + if (isCallActive(myUserId, outgoingRoom, session.sessionDescription)) { + stopOutgoingRing(); + return; + } + } - const handleUpdate = () => checkDMsForActiveCalls(); + if (outgoingRingRoomIdRef.current) return; + + const now = Date.now(); + const localUserId = mx.getUserId(); + if (!localUserId) return; + + for (const roomId of mDirects) { + if (mutedRoomIdRef.current === roomId) continue; + + const room = mx.getRoom(roomId); + if (!room) continue; + + const session = mx.matrixRTC.getRoomSession(room); + const memberships = getRoomMemberships(room, session.sessionDescription); + const remoteMembers = memberships.filter( + (m: { userId?: string; sender?: string }) => (m.userId || m.sender) !== localUserId + ); + const selfMember = memberships.some( + (m: { userId?: string; sender?: string }) => (m.userId || m.sender) === localUserId + ); + + if (selfMember && remoteMembers.length === 0) { + if (!outgoingStartRef.current) outgoingStartRef.current = now; + if (now - outgoingStartRef.current < OUTGOING_RING_TIMEOUT_MS) { + if (outgoingRingRoomIdRef.current !== roomId) { + outgoingAudioRef.current?.play().catch(() => {}); + outgoingRingRoomIdRef.current = roomId; + debugLog.info('call', 'Outgoing ringing fallback started', { roomId }); + } + } else { + stopOutgoingRing(); + } + return; + } + } + + stopOutgoingRing(); + }; const handleSessionEnded = (roomId: string) => { if (mutedRoomIdRef.current === roomId) setMutedRoomId(null); - callPhaseRef.current[roomId] = 'IDLE'; - checkDMsForActiveCalls(); + evaluateFallbackState(); }; - mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, handleUpdate); + mx.on(RoomEvent.Timeline, handleTimelineEvent); + mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, evaluateFallbackState); mx.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded); - mx.on(RoomStateEvent.Events, handleUpdate); - checkDMsForActiveCalls(); + const intervalId = window.setInterval(evaluateFallbackState, FALLBACK_INTERVAL_MS); + evaluateFallbackState(); return () => { - clearInterval(interval); - mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, handleUpdate); + mx.off(RoomEvent.Timeline, handleTimelineEvent); + mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, evaluateFallbackState); mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded); - mx.off(RoomStateEvent.Events, handleUpdate); - stopRingingRef.current(); + window.clearInterval(intervalId); + stopIncomingRing(); + stopOutgoingRing(); }; - }, [mx, mDirects, setMutedRoomId]); // stable: volatile deps accessed via refs above + }, [ + mx, + mDirects, + handleIncomingCall, + clearIncomingCall, + stopIncomingRing, + stopOutgoingRing, + setMutedRoomId, + ]); return null; } + +export function useCallSignaling() { + return useIncomingCallSignaling(); +} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index f847e0856..e97798894 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -56,7 +56,7 @@ import { getSlidingSyncManager } from '$client/initMatrix'; import { NotificationBanner } from '$components/notification-banner'; import { ThemeMigrationBanner } from '$components/theme/ThemeMigrationBanner'; import { TelemetryConsentBanner } from '$components/telemetry-consent'; -import { useCallSignaling } from '$hooks/useCallSignaling'; +import { useIncomingCallSignaling } from '$hooks/useCallSignaling'; import { getBlobCacheStats } from '$hooks/useBlobCache'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; @@ -862,7 +862,7 @@ function SettingsSyncFeature() { } export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { - useCallSignaling(); + useIncomingCallSignaling(); return ( <> diff --git a/src/app/state/callEmbed.ts b/src/app/state/callEmbed.ts index 84bc0748f..bd217cc83 100644 --- a/src/app/state/callEmbed.ts +++ b/src/app/state/callEmbed.ts @@ -35,6 +35,28 @@ export const callEmbedAtom = atom(null); -export const autoJoinCallIntentAtom = atom(null); +export type IncomingCallNotificationType = 'ring' | 'notification'; +export type IncomingCallIntentKind = 'audio' | 'video'; + +export type IncomingCall = { + roomId: string; + notificationEventId: string; + refEventId: string; + senderId: string; + senderTs: number; + expiresAt: number; + notificationType: IncomingCallNotificationType; + intentKind: IncomingCallIntentKind; + intentRaw?: string; + isDirect: boolean; +}; + +export type AutoJoinCallIntent = { + roomId: string; + video: boolean; +}; + +export const incomingCallAtom = atom(null); +export const incomingCallRoomIdAtom = atom((get) => get(incomingCallAtom)?.roomId ?? null); +export const autoJoinCallIntentAtom = atom(null); export const mutedCallRoomIdAtom = atom(null); From ec9711ff28ab7e1ddc02f7810870b6a1e91f613c Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 12:32:52 -0500 Subject: [PATCH 04/27] incoming call modal ui improvements --- src/app/components/IncomingCallModal.test.tsx | 48 ++++- src/app/components/IncomingCallModal.tsx | 189 ++++++++++++++++-- src/app/utils/rtc.ts | 10 + 3 files changed, 229 insertions(+), 18 deletions(-) create mode 100644 src/app/utils/rtc.ts diff --git a/src/app/components/IncomingCallModal.test.tsx b/src/app/components/IncomingCallModal.test.tsx index 2b9ea2a87..7952f048e 100644 --- a/src/app/components/IncomingCallModal.test.tsx +++ b/src/app/components/IncomingCallModal.test.tsx @@ -3,17 +3,34 @@ import type { Room } from '$types/matrix-sdk'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { IncomingCallInternal } from './IncomingCallModal'; -const { navigateRoomMock, sendRtcDeclineMock } = vi.hoisted(() => ({ +const { navigateRoomMock, sendRtcDeclineMock, webRtcSupportedMock, livekitSupportedMock } = vi.hoisted(() => ({ navigateRoomMock: vi.fn<(roomId: string) => void>(), sendRtcDeclineMock: vi.fn<(roomId: string, eventId: string) => Promise>(), + webRtcSupportedMock: vi.fn<() => boolean>(), + livekitSupportedMock: vi.fn<() => boolean>(), })); vi.mock('$hooks/useMatrixClient', () => ({ useMatrixClient: () => ({ sendRtcDecline: sendRtcDeclineMock, + getSafeUserId: () => '@me:example.org', + mxcUrlToHttp: () => undefined, }), })); +vi.mock('$hooks/useLivekitSupport', () => ({ + useLivekitSupport: () => livekitSupportedMock(), +})); + +vi.mock('$hooks/useCallEmbed', () => ({ + useCallEmbed: () => undefined, +})); + +vi.mock('$hooks/useScreenSize', () => ({ + ScreenSize: { Desktop: 'Desktop', Tablet: 'Tablet', Mobile: 'Mobile' }, + useScreenSizeContext: () => 'Desktop', +})); + vi.mock('$hooks/useRoomMeta', () => ({ useRoomName: () => 'Direct Message', })); @@ -28,10 +45,18 @@ vi.mock('$hooks/useRoomNavigate', () => ({ }), })); +vi.mock('$utils/rtc', () => ({ + webRTCSupported: () => webRtcSupportedMock(), +})); + vi.mock('./room-avatar', () => ({ RoomAvatar: ({ alt }: { alt: string }) =>
{alt}
, })); +vi.mock('./user-avatar', () => ({ + UserAvatar: ({ alt }: { alt?: string }) =>
{alt}
, +})); + vi.mock('@sentry/react', () => ({ addBreadcrumb: vi.fn(), metrics: { @@ -48,7 +73,16 @@ vi.mock('$utils/debugLogger', () => ({ })); describe('IncomingCallInternal', () => { - const room = { roomId: '!room:example.org' } as Room; + const room = { + roomId: '!room:example.org', + getMember: () => ({ + getMxcAvatarUrl: () => undefined, + rawDisplayName: 'Alice', + }), + currentState: { + maySendStateEvent: () => true, + }, + } as unknown as Room; const incomingCall = { roomId: room.roomId, notificationEventId: '$notif', @@ -64,6 +98,8 @@ describe('IncomingCallInternal', () => { beforeEach(() => { navigateRoomMock.mockReset(); sendRtcDeclineMock.mockReset().mockResolvedValue(undefined); + webRtcSupportedMock.mockReset().mockReturnValue(true); + livekitSupportedMock.mockReset().mockReturnValue(true); }); it('closes the modal when decline is pressed', async () => { @@ -88,4 +124,12 @@ describe('IncomingCallInternal', () => { expect(navigateRoomMock).toHaveBeenCalledWith('!room:example.org'); expect(onClose).toHaveBeenCalledTimes(1); }); + + it('disables answer when WebRTC is unavailable', () => { + webRtcSupportedMock.mockReturnValue(false); + const onClose = vi.fn<() => void>(); + render(); + + expect(screen.getByRole('button', { name: /answer voice call/i })).toBeDisabled(); + }); }); diff --git a/src/app/components/IncomingCallModal.tsx b/src/app/components/IncomingCallModal.tsx index 7432841a9..66e40b8a4 100644 --- a/src/app/components/IncomingCallModal.tsx +++ b/src/app/components/IncomingCallModal.tsx @@ -2,6 +2,7 @@ import { Avatar, Box, Button, + color, Dialog, Header, Icon, @@ -12,19 +13,26 @@ import { OverlayCenter, Text, config, + toRem, } from 'folds'; +import { useMemo, type KeyboardEvent as ReactKeyboardEvent } from 'react'; import type { Room } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useLivekitSupport } from '$hooks/useLivekitSupport'; import { useRoomName } from '$hooks/useRoomMeta'; -import { getRoomAvatarUrl } from '$utils/room'; +import { useCallEmbed } from '$hooks/useCallEmbed'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { getMxIdLocalPart } from '$utils/matrix'; +import { getMemberDisplayName, getRoomAvatarUrl } from '$utils/room'; +import { webRTCSupported } from '$utils/rtc'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; import FocusTrap from 'focus-trap-react'; -import { stopPropagation } from '$utils/keyboard'; import * as Sentry from '@sentry/react'; import { useAtom, useSetAtom } from 'jotai'; import { autoJoinCallIntentAtom, incomingCallAtom, mutedCallRoomIdAtom, type IncomingCall } from '$state/callEmbed'; import { createDebugLogger } from '$utils/debugLogger'; import { RoomAvatar } from './room-avatar'; +import { UserAvatar } from './user-avatar'; const debugLog = createDebugLogger('IncomingCall'); @@ -34,18 +42,93 @@ type IncomingCallInternalProps = { onClose: () => void; }; +type CapabilityIssue = { + id: string; + message: string; + shortReason: string; +}; + export function IncomingCallInternal({ room, incomingCall, onClose }: IncomingCallInternalProps) { const mx = useMatrixClient(); + const screenSize = useScreenSizeContext(); + const compact = screenSize === ScreenSize.Mobile; const roomName = useRoomName(room); + const livekitSupported = useLivekitSupport(); + const callEmbed = useCallEmbed(); const { navigateRoom } = useRoomNavigate(); - const avatarUrl = getRoomAvatarUrl(mx, room, 96); + const roomAvatarUrl = getRoomAvatarUrl(mx, room, 96); const setAutoJoinIntent = useSetAtom(autoJoinCallIntentAtom); const setMutedRoomId = useSetAtom(mutedCallRoomIdAtom); + const callerDisplayName = + getMemberDisplayName(room, incomingCall.senderId) ?? + getMxIdLocalPart(incomingCall.senderId) ?? + incomingCall.senderId; + const callerAvatarMxc = room.getMember(incomingCall.senderId)?.getMxcAvatarUrl(); + const callerAvatarUrl = callerAvatarMxc + ? (mx.mxcUrlToHttp(callerAvatarMxc, 96, 96, 'crop') ?? undefined) + : undefined; + const isRingNotification = incomingCall.notificationType === 'ring'; const isDirectRing = incomingCall.isDirect && incomingCall.notificationType === 'ring'; const isVideoIntent = incomingCall.intentKind === 'video'; + const inAnotherCall = Boolean(callEmbed && callEmbed.roomId !== room.roomId); + const canUseWebRTC = webRTCSupported(); + const myUserId = mx.getSafeUserId(); + const hasCallMemberPermission = + room.currentState?.maySendStateEvent('org.matrix.msc3401.call.member', myUserId) ?? false; + + const capabilityIssues = useMemo(() => { + const issues: CapabilityIssue[] = []; + + if (!canUseWebRTC) { + issues.push({ + id: 'webrtc', + message: 'Your browser does not support WebRTC calling.', + shortReason: 'WebRTC is unavailable in this browser.', + }); + } + if (!livekitSupported) { + issues.push({ + id: 'livekit', + message: 'Your homeserver does not expose a LiveKit call focus.', + shortReason: 'Homeserver call focus is unavailable.', + }); + } + if (!hasCallMemberPermission) { + issues.push({ + id: 'permission', + message: "You don't have permission to join this room's call.", + shortReason: "Missing permission to join this call.", + }); + } + if (inAnotherCall) { + issues.push({ + id: 'another_call', + message: 'You are already in another call.', + shortReason: 'Finish your current call first.', + }); + } + + return issues; + }, [canUseWebRTC, livekitSupported, hasCallMemberPermission, inAnotherCall]); + + const canAnswer = capabilityIssues.length === 0; + const primaryBlockedReason = capabilityIssues[0]?.shortReason; + + const incomingLabel = isRingNotification + ? isVideoIntent + ? 'Incoming video call' + : 'Incoming voice call' + : 'Incoming room call notification'; + const dismissLabel = isDirectRing ? 'Decline' : 'Ignore'; + const closeLabel = isDirectRing ? 'Close and decline call' : 'Close and ignore notification'; + const showCallerAvatar = incomingCall.isDirect; + const title = showCallerAvatar ? callerDisplayName : roomName; + const subtitle = showCallerAvatar ? roomName : callerDisplayName; const handleAnswer = () => { + if (!canAnswer) return; + debugLog.info('call', 'Incoming call answered', { roomId: room.roomId, notificationEventId: incomingCall.notificationEventId, @@ -114,8 +197,27 @@ export function IncomingCallInternal({ room, incomingCall, onClose }: IncomingCa onClose(); }; + const handleModalKeyDown = (evt: ReactKeyboardEvent) => { + if (evt.key === 'Escape') { + evt.preventDefault(); + evt.stopPropagation(); + void handleDeclineOrIgnore(); + return; + } + if (evt.key === 'Enter' && canAnswer) { + evt.preventDefault(); + evt.stopPropagation(); + handleAnswer(); + } + }; + return ( - +
Incoming Call - +
- + - } - /> + {showCallerAvatar ? ( + } + /> + ) : ( + } + /> + )} - {roomName} + {title} - {isVideoIntent ? 'Incoming video chat request' : 'Incoming voice chat request'} + {incomingLabel} + + + {showCallerAvatar ? `Room: ${subtitle}` : `Caller: ${subtitle}`} + {capabilityIssues.length > 0 && ( + + {capabilityIssues.map((issue) => ( + + {issue.message} + + ))} + + )} + + {!canAnswer && primaryBlockedReason && ( + + {primaryBlockedReason} + + )}
); @@ -191,7 +348,7 @@ export function IncomingCallModal() { focusTrapOptions={{ initialFocus: false, clickOutsideDeactivates: false, - escapeDeactivates: stopPropagation, + escapeDeactivates: false, }} >
diff --git a/src/app/utils/rtc.ts b/src/app/utils/rtc.ts new file mode 100644 index 000000000..f0f3b6bcc --- /dev/null +++ b/src/app/utils/rtc.ts @@ -0,0 +1,10 @@ +export const webRTCSupported = (): boolean => { + if (typeof window === 'undefined') return false; + + return ( + 'RTCPeerConnection' in window || + 'webkitRTCPeerConnection' in window || + 'mozRTCPeerConnection' in window || + 'RTCIceGatherer' in window + ); +}; From 8d3a909762a583efae84b4b3aa2c7f66de10eb72 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 12:56:37 -0500 Subject: [PATCH 05/27] improve call capability detection --- src/app/features/call/CallView.tsx | 78 +++++++++++------- .../features/call/callStartCapabilities.ts | 63 ++++++++++++++ src/app/features/room/RoomViewHeader.tsx | 10 +-- .../hooks/useCallStartCapabilities.test.ts | 82 +++++++++++++++++++ src/app/hooks/useCallStartCapabilities.ts | 30 +++++++ 5 files changed, 229 insertions(+), 34 deletions(-) create mode 100644 src/app/features/call/callStartCapabilities.ts create mode 100644 src/app/hooks/useCallStartCapabilities.test.ts create mode 100644 src/app/hooks/useCallStartCapabilities.ts diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 861af9175..19d571e8e 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -1,12 +1,8 @@ -import { useCallback, useRef, useState, type RefObject } from 'react'; +import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'; import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds'; import { ContainerColor } from '$styles/ContainerColor.css'; -import { usePowerLevelsContext } from '$hooks/usePowerLevels'; -import { useRoomCreators } from '$hooks/useRoomCreators'; -import { useRoomPermissions } from '$hooks/useRoomPermissions'; -import { useMatrixClient } from '$hooks/useMatrixClient'; import { useRoom } from '$hooks/useRoom'; -import { useLivekitSupport } from '$hooks/useLivekitSupport'; +import { useCallStartCapabilities } from '$hooks/useCallStartCapabilities'; import { useCallMembers, useCallSession } from '$hooks/useCall'; import { useCallEmbed, useCallEmbedPlacementSync, useCallJoined } from '$hooks/useCallEmbed'; @@ -15,12 +11,19 @@ import * as css from './styles.css'; import { CallMemberRenderer } from './CallMemberCard'; import { PrescreenControls } from './PrescreenControls'; import { CallControls } from './CallControls'; -import { EventType } from '$types/matrix-sdk'; function LivekitServerMissingMessage() { return ( - Your homeserver does not support calling. But you can still join call started by others. + Your homeserver does not support calling. + + ); +} + +function WebRTCMissingError() { + return ( + + Your browser does not support WebRTC, which is required for calling. ); } @@ -28,19 +31,25 @@ function LivekitServerMissingMessage() { function JoinMessage({ hasParticipant, livekitSupported, + rtcSupported, }: { hasParticipant?: boolean; livekitSupported?: boolean; + rtcSupported?: boolean; }) { - if (hasParticipant) return null; + if (rtcSupported === false) { + return ; + } if (livekitSupported === false) { return ; } + if (hasParticipant) return null; + return ( - Voice chat's empty — Be the first to hop in! + Voice chat's empty - be the first to hop in! ); } @@ -56,30 +65,20 @@ function NoPermissionMessage() { function AlreadyInCallMessage() { return ( - Already in another call — End the current call to join! + Already in another call - end the current call to join. ); } function CallPrescreen() { - const mx = useMatrixClient(); const room = useRoom(); - const livekitSupported = useLivekitSupport(); - - const powerLevels = usePowerLevelsContext(); - const creators = useRoomCreators(room); - - const permissions = useRoomPermissions(creators, powerLevels); - const hasPermission = permissions.event(EventType.GroupCallMemberPrefix, mx.getSafeUserId()); + const callStartCapabilities = useCallStartCapabilities(room); const callSession = useCallSession(room); const callMembers = useCallMembers(room, callSession); const hasParticipant = callMembers.length > 0; - const callEmbed = useCallEmbed(); - const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId; - - const canJoin = hasPermission && (livekitSupported || hasParticipant); + const canJoin = callStartCapabilities.canStart; return ( @@ -100,13 +99,17 @@ function CallPrescreen() { - {!inOtherCall && - (hasPermission ? ( - + {!callStartCapabilities.inAnotherCall && + (callStartCapabilities.hasCallMemberPermission ? ( + ) : ( ))} - {inOtherCall && } + {callStartCapabilities.inAnotherCall && } @@ -169,6 +172,7 @@ export function CallView({ resizable }: CallViewProps) { const [height, setHeight] = useState(isMobile ? 240 : 380); const [isDragging, setIsDragging] = useState(false); const isResizing = useRef(false); + const previousBodyUserSelect = useRef(null); const handleMove = useCallback( (clientY: number) => { @@ -195,12 +199,16 @@ export function CallView({ resizable }: CallViewProps) { document.removeEventListener('mouseup', stopResizing); document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener('touchend', stopResizing); - document.body.style.userSelect = 'auto'; + document.body.style.userSelect = previousBodyUserSelect.current ?? ''; + previousBodyUserSelect.current = null; }, [handleMouseMove, handleTouchMove]); const startResizing = useCallback(() => { isResizing.current = true; setIsDragging(true); + if (previousBodyUserSelect.current === null) { + previousBodyUserSelect.current = document.body.style.userSelect; + } document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', stopResizing); document.addEventListener('touchmove', handleTouchMove, { passive: false }); @@ -208,6 +216,19 @@ export function CallView({ resizable }: CallViewProps) { document.body.style.userSelect = 'none'; }, [handleMouseMove, handleTouchMove, stopResizing]); + useEffect( + () => () => { + isResizing.current = false; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', stopResizing); + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', stopResizing); + document.body.style.userSelect = previousBodyUserSelect.current ?? ''; + previousBodyUserSelect.current = null; + }, + [handleMouseMove, handleTouchMove, stopResizing] + ); + return ( {isDragging && ( diff --git a/src/app/features/call/callStartCapabilities.ts b/src/app/features/call/callStartCapabilities.ts new file mode 100644 index 000000000..0c7c45913 --- /dev/null +++ b/src/app/features/call/callStartCapabilities.ts @@ -0,0 +1,63 @@ +import type { Room } from '$types/matrix-sdk'; + +const CALL_MEMBER_EVENT_TYPE = 'org.matrix.msc3401.call.member'; + +export type CallStartBlocker = + | 'missing_webrtc' + | 'missing_livekit' + | 'missing_call_member_permission' + | 'already_in_another_call'; + +export type CallStartCapabilities = { + canStart: boolean; + canRenderCallButton: boolean; + blockers: CallStartBlocker[]; + webRTCSupported: boolean; + livekitSupported: boolean; + hasCallMemberPermission: boolean; + inAnotherCall: boolean; +}; + +type EvaluateCallStartCapabilitiesInput = { + room: Room; + myUserId: string; + activeCallRoomId?: string; + livekitSupported: boolean; + rtcSupported: boolean; +}; + +export const evaluateCallStartCapabilities = ({ + room, + myUserId, + activeCallRoomId, + livekitSupported, + rtcSupported, +}: EvaluateCallStartCapabilitiesInput): CallStartCapabilities => { + const blockers: CallStartBlocker[] = []; + const hasCallMemberPermission = + room.currentState?.maySendStateEvent(CALL_MEMBER_EVENT_TYPE, myUserId) ?? false; + const inAnotherCall = !!activeCallRoomId && activeCallRoomId !== room.roomId; + + if (!rtcSupported) blockers.push('missing_webrtc'); + if (!livekitSupported) blockers.push('missing_livekit'); + if (!hasCallMemberPermission) blockers.push('missing_call_member_permission'); + if (inAnotherCall) blockers.push('already_in_another_call'); + + const canRenderCallButton = !blockers.some((blocker) => + [ + 'missing_webrtc', + 'missing_livekit', + 'missing_call_member_permission', + ].includes(blocker) + ); + + return { + canStart: blockers.length === 0, + canRenderCallButton, + blockers, + webRTCSupported: rtcSupported, + livekitSupported, + hasCallMemberPermission, + inAnotherCall, + }; +}; diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index da2a6a3c6..889feab3f 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -92,6 +92,7 @@ import { RoomSettingsPage } from '$state/roomSettings'; import { roomIdToThreadBrowserAtomFamily } from '$state/room/roomToThreadBrowser'; import { roomIdToOpenThreadAtomFamily } from '$state/room/roomToOpenThread'; import { useCallPreferences } from '$state/hooks/callPreferences'; +import { useCallStartCapabilities } from '$hooks/useCallStartCapabilities'; import { JumpToTime } from './jump-to-time'; import { RoomPinMenu } from './room-pin-menu'; import * as css from './RoomViewHeader.css'; @@ -364,10 +365,7 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { ); const [openThreadId, setOpenThread] = useAtom(roomIdToOpenThreadAtomFamily(room.roomId)); - const canUseCalls = room - .getLiveTimeline() - .getState(EventTimeline.FORWARDS) - ?.maySendStateEvent('org.matrix.msc3401.call.member', mx.getUserId()!); + const callStartCapabilities = useCallStartCapabilities(room); const [alwaysShowCallButton] = useSetting(settingsAtom, 'alwaysShowCallButton'); const shouldShowCallButton = alwaysShowCallButton || room.getJoinedMemberCount() <= 10; @@ -727,7 +725,9 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { )} - {canUseCalls && shouldShowCallButton && ( + {!room.isCallRoom() && + callStartCapabilities.canRenderCallButton && + shouldShowCallButton && ( + ({ + roomId, + currentState: { + maySendStateEvent: () => canSend, + }, + }) as unknown as Room; + +describe('evaluateCallStartCapabilities', () => { + it('allows call start when all capabilities are available', () => { + const capabilities = evaluateCallStartCapabilities({ + room: createRoom('!room:example.org'), + myUserId: '@me:example.org', + activeCallRoomId: undefined, + livekitSupported: true, + rtcSupported: true, + }); + + expect(capabilities.canStart).toBe(true); + expect(capabilities.canRenderCallButton).toBe(true); + expect(capabilities.blockers).toHaveLength(0); + }); + + it('blocks and hides button when WebRTC is unsupported', () => { + const capabilities = evaluateCallStartCapabilities({ + room: createRoom('!room:example.org'), + myUserId: '@me:example.org', + activeCallRoomId: undefined, + livekitSupported: true, + rtcSupported: false, + }); + + expect(capabilities.canStart).toBe(false); + expect(capabilities.canRenderCallButton).toBe(false); + expect(capabilities.blockers).toContain('missing_webrtc'); + }); + + it('blocks and hides button when call-member permission is missing', () => { + const capabilities = evaluateCallStartCapabilities({ + room: createRoom('!room:example.org', false), + myUserId: '@me:example.org', + activeCallRoomId: undefined, + livekitSupported: true, + rtcSupported: true, + }); + + expect(capabilities.canStart).toBe(false); + expect(capabilities.canRenderCallButton).toBe(false); + expect(capabilities.blockers).toContain('missing_call_member_permission'); + }); + + it('blocks start but keeps button visible when already in another call', () => { + const capabilities = evaluateCallStartCapabilities({ + room: createRoom('!room:example.org'), + myUserId: '@me:example.org', + activeCallRoomId: '!other:example.org', + livekitSupported: true, + rtcSupported: true, + }); + + expect(capabilities.canStart).toBe(false); + expect(capabilities.canRenderCallButton).toBe(true); + expect(capabilities.blockers).toEqual(['already_in_another_call']); + }); + + it('does not block when active call is in the same room', () => { + const capabilities = evaluateCallStartCapabilities({ + room: createRoom('!room:example.org'), + myUserId: '@me:example.org', + activeCallRoomId: '!room:example.org', + livekitSupported: true, + rtcSupported: true, + }); + + expect(capabilities.canStart).toBe(true); + expect(capabilities.inAnotherCall).toBe(false); + }); +}); diff --git a/src/app/hooks/useCallStartCapabilities.ts b/src/app/hooks/useCallStartCapabilities.ts new file mode 100644 index 000000000..8e2d55813 --- /dev/null +++ b/src/app/hooks/useCallStartCapabilities.ts @@ -0,0 +1,30 @@ +import { useMemo } from 'react'; +import type { Room } from '$types/matrix-sdk'; +import { useCallEmbed } from '$hooks/useCallEmbed'; +import { useLivekitSupport } from '$hooks/useLivekitSupport'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { webRTCSupported } from '$utils/rtc'; +import { + evaluateCallStartCapabilities, + type CallStartCapabilities, +} from '$features/call/callStartCapabilities'; + +export const useCallStartCapabilities = (room: Room): CallStartCapabilities => { + const mx = useMatrixClient(); + const callEmbed = useCallEmbed(); + const livekitSupported = useLivekitSupport(); + const rtcSupported = webRTCSupported(); + const myUserId = mx.getSafeUserId(); + + return useMemo( + () => + evaluateCallStartCapabilities({ + room, + myUserId, + activeCallRoomId: callEmbed?.roomId, + livekitSupported, + rtcSupported, + }), + [room, myUserId, callEmbed?.roomId, livekitSupported, rtcSupported] + ); +}; From 9e1ddb10e934254f772f73550923b25c8d6b41c1 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 13:48:09 -0500 Subject: [PATCH 06/27] hardening things --- src/app/components/CallEmbedProvider.tsx | 24 +++- src/app/features/call/CallView.tsx | 22 ++++ src/app/hooks/useCallEmbed.ts | 22 +++- src/app/plugins/call/CallControl.ts | 57 ++++----- src/app/plugins/call/CallEmbed.intent.test.ts | 29 +++++ src/app/plugins/call/CallEmbed.ts | 31 +++-- src/app/plugins/call/CallWidgetDriver.ts | 20 +++- src/app/plugins/call/callEmbedError.ts | 21 ++++ .../call/elementCallDomAdapter.test.ts | 72 ++++++++++++ src/app/plugins/call/elementCallDomAdapter.ts | 110 ++++++++++++++++++ src/app/plugins/call/utils.test.ts | 70 +++++++++++ src/app/plugins/call/utils.ts | 2 + src/app/state/callEmbed.ts | 17 +++ 13 files changed, 450 insertions(+), 47 deletions(-) create mode 100644 src/app/plugins/call/callEmbedError.ts create mode 100644 src/app/plugins/call/elementCallDomAdapter.test.ts create mode 100644 src/app/plugins/call/elementCallDomAdapter.ts create mode 100644 src/app/plugins/call/utils.test.ts diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 611b27566..0ddd51afd 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { useCallback, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; import { useAutoJoinCall } from '$hooks/useAutoJoinCall'; import { @@ -12,13 +12,15 @@ import { } from '$hooks/useCallEmbed'; import type { CallEmbed } from '$plugins/call'; import { useClientWidgetApiEvent, ElementWidgetActions } from '$plugins/call'; -import { callChatAtom, callEmbedAtom } from '$state/callEmbed'; +import { callChatAtom, callEmbedAtom, callEmbedStartErrorAtom } from '$state/callEmbed'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { IncomingCallModal } from './IncomingCallModal'; +import { toCallEmbedStartError } from '$plugins/call/callEmbedError'; function CallUtils({ embed }: { embed: CallEmbed }) { const setCallEmbed = useSetAtom(callEmbedAtom); + const setCallEmbedStartError = useSetAtom(callEmbedStartErrorAtom); useCallMemberSoundSync(embed); useCallThemeSync(embed); @@ -30,6 +32,24 @@ function CallUtils({ embed }: { embed: CallEmbed }) { useCallHangupEvent(embed, handleCallEnd); useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, handleCallEnd); + useEffect(() => { + const disposeOnReady = embed.onReady(() => { + setCallEmbedStartError(null); + }); + const disposeOnCapabilitiesNotified = embed.onCapabilitiesNotified(() => { + setCallEmbedStartError(null); + }); + const disposeOnPreparingError = embed.onPreparingError((error) => { + setCallEmbedStartError(toCallEmbedStartError(error)); + }); + + return () => { + disposeOnReady(); + disposeOnCapabilitiesNotified(); + disposeOnPreparingError(); + }; + }, [embed, setCallEmbedStartError]); + return null; } diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 19d571e8e..43f976906 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'; import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds'; +import { useAtomValue } from 'jotai'; import { ContainerColor } from '$styles/ContainerColor.css'; import { useRoom } from '$hooks/useRoom'; import { useCallStartCapabilities } from '$hooks/useCallStartCapabilities'; @@ -11,6 +12,7 @@ import * as css from './styles.css'; import { CallMemberRenderer } from './CallMemberCard'; import { PrescreenControls } from './PrescreenControls'; import { CallControls } from './CallControls'; +import { callEmbedAtom, callEmbedStartErrorAtom } from '$state/callEmbed'; function LivekitServerMissingMessage() { return ( @@ -70,13 +72,30 @@ function AlreadyInCallMessage() { ); } +function WidgetPreparationErrorMessage({ message }: { message: string }) { + return ( + + {message} + + ); +} + function CallPrescreen() { const room = useRoom(); + const callEmbed = useAtomValue(callEmbedAtom); + const callEmbedStartError = useAtomValue(callEmbedStartErrorAtom); + const callJoined = useCallJoined(callEmbed); const callStartCapabilities = useCallStartCapabilities(room); const callSession = useCallSession(room); const callMembers = useCallMembers(room, callSession); const hasParticipant = callMembers.length > 0; + const showEmbedError = + callEmbed?.roomId === room.roomId && !callJoined && callEmbedStartError !== null; + const embedErrorMessage = + callEmbedStartError?.kind === 'capability' + ? 'Call setup failed because required call capabilities were rejected.' + : 'Call setup failed while preparing the embedded call app.'; const canJoin = callStartCapabilities.canStart; @@ -110,6 +129,9 @@ function CallPrescreen() { ))} {callStartCapabilities.inAnotherCall && } + {showEmbedError && ( + + )} diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts index fef17cdde..edcc55738 100644 --- a/src/app/hooks/useCallEmbed.ts +++ b/src/app/hooks/useCallEmbed.ts @@ -14,6 +14,8 @@ import { CallControlState } from '../plugins/call/CallControlState'; import { useCallMembersChange, useCallSession } from './useCall'; import type { CallPreferences } from '../state/callPreferences'; import { createDebugLogger } from '../utils/debugLogger'; +import { useClientConfig } from './useClientConfig'; +import { callEmbedStartErrorAtom } from '$state/callEmbed'; const debugLog = createDebugLogger('useCallEmbed'); @@ -43,14 +45,15 @@ export const createCallEmbed = ( dm: boolean, themeKind: ElementCallThemeKind, container: HTMLElement, - pref?: CallPreferences + pref?: CallPreferences, + elementCallUrl?: string ): CallEmbed => { const rtcSession = mx.matrixRTC.getRoomSession(room); const ongoing = MatrixRTCSession.sessionMembershipsForRoom(room, rtcSession.sessionDescription).length > 0; const intent = CallEmbed.getIntent(dm, ongoing, pref?.video); - const widget = CallEmbed.getWidget(mx, room, intent, themeKind); + const widget = CallEmbed.getWidget(mx, room, intent, themeKind, elementCallUrl); const controlState = pref && new CallControlState(pref.microphone, pref.video, pref.sound); const embed = new CallEmbed(mx, room, widget, container, controlState); @@ -61,7 +64,9 @@ export const createCallEmbed = ( export const useCallStart = (dm = false) => { const mx = useMatrixClient(); const theme = useTheme(); + const clientConfig = useClientConfig(); const setCallEmbed = useSetAtom(callEmbedAtom); + const setCallEmbedStartError = useSetAtom(callEmbedStartErrorAtom); const callEmbedRef = useCallEmbedRef(); const startCall = useCallback( @@ -81,7 +86,16 @@ export const useCallStart = (dm = false) => { Sentry.metrics.count('sable.call.start.attempt', 1, { attributes: { dm: String(dm) }, }); - const callEmbed = createCallEmbed(mx, room, dm, theme.kind, container, pref); + setCallEmbedStartError(null); + const callEmbed = createCallEmbed( + mx, + room, + dm, + theme.kind, + container, + pref, + clientConfig.elementCallUrl + ); setCallEmbed(callEmbed); } catch (err) { debugLog.error('call', 'Call embed creation failed', { @@ -94,7 +108,7 @@ export const useCallStart = (dm = false) => { throw err; } }, - [mx, dm, theme, setCallEmbed, callEmbedRef] + [mx, dm, theme, setCallEmbed, callEmbedRef, clientConfig.elementCallUrl, setCallEmbedStartError] ); return startCall; diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index fb2e6ff44..f8c4e9816 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -3,6 +3,14 @@ import EventEmitter from 'eventemitter3'; import { CallControlState } from './CallControlState'; import type { ElementMediaStateDetail, ElementMediaStatePayload } from './types'; import { ElementWidgetActions } from './types'; +import { + getGridControl, + getReactionsButton, + getScreenshareButton, + getSettingsButton, + getSpotlightControl, + isElementToggledOn, +} from './elementCallDomAdapter'; export enum CallControlEvent { StateUpdate = 'state_update', @@ -22,41 +30,23 @@ export class CallControl extends EventEmitter implements CallControlState { } private get screenshareButton(): HTMLElement | undefined { - const screenshareBtn = this.document?.querySelector( - '[data-testid="incall_screenshare"]' - ) as HTMLElement | null; - - return screenshareBtn ?? undefined; + return getScreenshareButton(this.document); } private get settingsButton(): HTMLElement | undefined { - const leaveBtn = this.document?.querySelector('[data-testid="incall_leave"]'); - - const settingsButton = leaveBtn?.previousElementSibling as HTMLElement | null; - - return settingsButton ?? undefined; + return getSettingsButton(this.document); } private get reactionsButton(): HTMLElement | undefined { - const reactionsButton = this.settingsButton?.previousElementSibling as HTMLElement | null; - - return reactionsButton ?? undefined; + return getReactionsButton(this.document); } - private get spotlightButton(): HTMLInputElement | undefined { - const spotlightButton = this.document?.querySelector( - 'input[value="spotlight"]' - ) as HTMLInputElement | null; - - return spotlightButton ?? undefined; + private get spotlightControl(): HTMLElement | undefined { + return getSpotlightControl(this.document); } - private get gridButton(): HTMLInputElement | undefined { - const gridButton = this.document?.querySelector( - 'input[value="grid"]' - ) as HTMLInputElement | null; - - return gridButton ?? undefined; + private get gridControl(): HTMLElement | undefined { + return getGridControl(this.document); } constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) { @@ -109,13 +99,14 @@ export class CallControl extends EventEmitter implements CallControlState { if (screenshareBtn) { this.controlMutationObserver.observe(screenshareBtn, { attributes: true, - attributeFilter: ['data-kind'], + attributeFilter: ['data-kind', 'aria-pressed', 'aria-checked', 'class'], }); } - const spotlightBtn = this.spotlightButton; - if (spotlightBtn) { - this.controlMutationObserver.observe(spotlightBtn, { + const spotlightControl = this.spotlightControl; + if (spotlightControl) { + this.controlMutationObserver.observe(spotlightControl, { attributes: true, + attributeFilter: ['checked', 'aria-pressed', 'aria-checked', 'data-kind', 'class'], }); } @@ -160,8 +151,8 @@ export class CallControl extends EventEmitter implements CallControlState { } public onControlMutation() { - const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary'; - const spotlight: boolean = this.spotlightButton?.checked ?? false; + const screenshare: boolean = isElementToggledOn(this.screenshareButton); + const spotlight: boolean = isElementToggledOn(this.spotlightControl); this.state = new CallControlState( this.microphone, @@ -215,10 +206,10 @@ export class CallControl extends EventEmitter implements CallControlState { public toggleSpotlight() { if (this.spotlight) { - this.gridButton?.click(); + this.gridControl?.click(); return; } - this.spotlightButton?.click(); + this.spotlightControl?.click(); } public toggleReactions() { diff --git a/src/app/plugins/call/CallEmbed.intent.test.ts b/src/app/plugins/call/CallEmbed.intent.test.ts index 10a62834d..983b8e78f 100644 --- a/src/app/plugins/call/CallEmbed.intent.test.ts +++ b/src/app/plugins/call/CallEmbed.intent.test.ts @@ -127,4 +127,33 @@ describe('CallEmbed.getWidget', () => { expect(url.searchParams.get('sendNotificationType')).toBeNull(); }); + + it('uses elementCallUrl from config when provided', () => { + const room = createRoom(false); + const widget = CallEmbed.getWidget( + mx, + room, + ElementCallIntent.StartCallDM, + 'dark', + 'https://calls.example.com/embed/index.html' + ); + const url = new URL(widget.getCompleteUrl({ currentUserId: '@alice:example.com' })); + + expect(url.origin).toBe('https://calls.example.com'); + expect(url.pathname).toBe('/embed/index.html'); + }); + + it('falls back to bundled element call app when elementCallUrl is invalid', () => { + const room = createRoom(false); + const widget = CallEmbed.getWidget( + mx, + room, + ElementCallIntent.StartCallDM, + 'dark', + 'http://[::1' + ); + const url = new URL(widget.getCompleteUrl({ currentUserId: '@alice:example.com' })); + + expect(url.pathname).toContain('/public/element-call/index.html'); + }); }); diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index be87509e1..02c31fbe0 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -15,6 +15,7 @@ import { ElementCallIntent, ElementWidgetActions } from './types'; import { CallControl } from './CallControl'; import { CallControlState } from './CallControlState'; import { createDebugLogger } from '../../utils/debugLogger'; +import { getInCallControlsContainer } from './elementCallDomAdapter'; const debugLog = createDebugLogger('CallEmbed'); @@ -76,7 +77,8 @@ export class CallEmbed { mx: MatrixClient, room: Room, intent: ElementCallIntent, - themeKind: ElementCallThemeKind + themeKind: ElementCallThemeKind, + elementCallUrl?: string ): Widget { const userId = mx.getSafeUserId(); const deviceId = mx.getDeviceId() ?? ''; @@ -105,10 +107,26 @@ export class CallEmbed { params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification'); } - const widgetUrl = new URL( - `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`, - window.location.origin - ); + let widgetUrl: URL; + if (elementCallUrl && elementCallUrl.trim()) { + try { + widgetUrl = new URL(elementCallUrl, window.location.origin); + } catch (error) { + debugLog.warn('call', 'Invalid elementCallUrl in client config, falling back to bundled call app', { + elementCallUrl, + error: error instanceof Error ? error.message : String(error), + }); + widgetUrl = new URL( + `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`, + window.location.origin + ); + } + } else { + widgetUrl = new URL( + `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`, + window.location.origin + ); + } widgetUrl.search = params.toString(); const options: IWidget = { @@ -308,8 +326,7 @@ export class CallEmbed { if (!doc) return; doc.body.style.setProperty('background', 'none', 'important'); - const controls = doc.body.querySelector('[data-testid="incall_leave"]')?.parentElement - ?.parentElement; + const controls = getInCallControlsContainer(doc); if (controls) { controls.style.setProperty('position', 'absolute'); controls.style.setProperty('visibility', 'hidden'); diff --git a/src/app/plugins/call/CallWidgetDriver.ts b/src/app/plugins/call/CallWidgetDriver.ts index 2678bf583..ad0a0a2ba 100644 --- a/src/app/plugins/call/CallWidgetDriver.ts +++ b/src/app/plugins/call/CallWidgetDriver.ts @@ -52,7 +52,25 @@ export class CallWidgetDriver extends WidgetDriver { } public async validateCapabilities(requested: Set): Promise> { - const allow = Array.from(requested).filter((cap) => this.allowedCapabilities.has(cap)); + const requestedArray = Array.from(requested); + const allow = requestedArray.filter((cap) => this.allowedCapabilities.has(cap)); + const denied = requestedArray.filter((cap) => !this.allowedCapabilities.has(cap)); + + if (denied.length > 0) { + debugLog.warn('call', 'Call widget requested unsupported capabilities', { + roomId: this.inRoomId, + deniedCapabilities: denied, + }); + } + + const requestedSableCapabilities = ['moe.sable.thumbnails', 'moe.sable.media_proxy'].filter((cap) => + requested.has(cap) + ); + debugLog.info('call', 'Sable-only capability request status', { + roomId: this.inRoomId, + requestedSableCapabilities, + }); + return new Set(allow); } diff --git a/src/app/plugins/call/callEmbedError.ts b/src/app/plugins/call/callEmbedError.ts new file mode 100644 index 000000000..4283bb18d --- /dev/null +++ b/src/app/plugins/call/callEmbedError.ts @@ -0,0 +1,21 @@ +export type CallEmbedStartErrorKind = 'capability' | 'preparing'; + +export type CallEmbedStartError = { + kind: CallEmbedStartErrorKind; + message: string; +}; + +const defaultPreparingMessage = 'Could not prepare the call embed.'; +const capabilityMessage = 'Call start was blocked by capability negotiation.'; + +export const toCallEmbedStartError = (error: unknown): CallEmbedStartError => { + const rawMessage = error instanceof Error ? error.message : String(error ?? ''); + const normalized = rawMessage.toLowerCase(); + const looksLikeCapabilityError = + normalized.includes('capabilit') || normalized.includes('org.matrix.msc'); + + return { + kind: looksLikeCapabilityError ? 'capability' : 'preparing', + message: rawMessage || (looksLikeCapabilityError ? capabilityMessage : defaultPreparingMessage), + }; +}; diff --git a/src/app/plugins/call/elementCallDomAdapter.test.ts b/src/app/plugins/call/elementCallDomAdapter.test.ts new file mode 100644 index 000000000..01f322f42 --- /dev/null +++ b/src/app/plugins/call/elementCallDomAdapter.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../../utils/debugLogger', () => ({ + createDebugLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); +import { + getInCallControlsContainer, + getLeaveButton, + getScreenshareButton, + isElementToggledOn, +} from './elementCallDomAdapter'; + +type FakeElement = { + checked?: boolean; + parentElement?: FakeElement; + previousElementSibling?: FakeElement; + getAttribute: (name: string) => string | null; +}; + +const createFakeElement = ( + attrs: Record = {}, + extra: Partial = {} +): FakeElement => ({ + ...extra, + getAttribute: (name: string) => attrs[name] ?? null, +}); + +describe('elementCallDomAdapter', () => { + it('finds call controls container from leave button ancestry', () => { + const container = createFakeElement(); + const row = createFakeElement({ }, { parentElement: container }); + const leave = createFakeElement({}, { parentElement: row }); + const doc = { + querySelector: (selector: string) => + selector === '[data-testid="incall_leave"]' ? leave : null, + } as Document; + + expect(getLeaveButton(doc)).toBe(leave); + expect(getInCallControlsContainer(doc)).toBe(container); + }); + + it('falls back to aria-label selectors when test ids are missing', () => { + const leave = createFakeElement(); + const screenshare = createFakeElement(); + const doc = { + querySelector: (selector: string) => { + if (selector === 'button[aria-label*="Leave" i]') return leave; + if (selector === 'button[aria-label*="screen" i]') return screenshare; + return null; + }, + } as Document; + + expect(getLeaveButton(doc)).toBe(leave); + expect(getScreenshareButton(doc)).toBe(screenshare); + }); + + it('detects toggled state for input, aria and data-kind controls', () => { + const checkbox = createFakeElement({}, { checked: true }); + expect(isElementToggledOn(checkbox as unknown as HTMLElement)).toBe(true); + + const pressedButton = createFakeElement({ 'aria-pressed': 'true' }); + expect(isElementToggledOn(pressedButton as unknown as HTMLElement)).toBe(true); + + const dataKindButton = createFakeElement({ 'data-kind': 'primary' }); + expect(isElementToggledOn(dataKindButton as unknown as HTMLElement)).toBe(true); + }); +}); diff --git a/src/app/plugins/call/elementCallDomAdapter.ts b/src/app/plugins/call/elementCallDomAdapter.ts new file mode 100644 index 000000000..21129815b --- /dev/null +++ b/src/app/plugins/call/elementCallDomAdapter.ts @@ -0,0 +1,110 @@ +import { createDebugLogger } from '$utils/debugLogger'; + +const debugLog = createDebugLogger('ElementCallDomAdapter'); + +const missingSelectorWarnings = new Set(); + +type SelectorQueryOptions = { + key: string; + selectors: string[]; +}; + +const queryFirst = (doc: Document | undefined, { key, selectors }: SelectorQueryOptions) => { + if (!doc) return undefined; + + for (const selector of selectors) { + const element = doc.querySelector(selector) as HTMLElement | null; + if (element) return element; + } + + if (!missingSelectorWarnings.has(key)) { + missingSelectorWarnings.add(key); + debugLog.warn('call', 'Element Call selector(s) not found', { key, selectors }); + } + + return undefined; +}; + +export const getLeaveButton = (doc: Document | undefined): HTMLElement | undefined => + queryFirst(doc, { + key: 'leave_button', + selectors: ['[data-testid="incall_leave"]', 'button[aria-label*="Leave" i]'], + }); + +export const getScreenshareButton = (doc: Document | undefined): HTMLElement | undefined => + queryFirst(doc, { + key: 'screenshare_button', + selectors: ['[data-testid="incall_screenshare"]', 'button[aria-label*="screen" i]'], + }); + +export const getSettingsButton = (doc: Document | undefined): HTMLElement | undefined => { + const leaveButton = getLeaveButton(doc); + const sibling = leaveButton?.previousElementSibling as HTMLElement | null; + if (sibling) return sibling; + return queryFirst(doc, { + key: 'settings_button', + selectors: ['[data-testid="incall_settings"]', 'button[aria-label*="Settings" i]'], + }); +}; + +export const getReactionsButton = (doc: Document | undefined): HTMLElement | undefined => { + const settingsButton = getSettingsButton(doc); + const sibling = settingsButton?.previousElementSibling as HTMLElement | null; + if (sibling) return sibling; + return queryFirst(doc, { + key: 'reactions_button', + selectors: ['[data-testid="incall_reactions"]', 'button[aria-label*="Reaction" i]'], + }); +}; + +export const getSpotlightControl = (doc: Document | undefined): HTMLElement | undefined => + queryFirst(doc, { + key: 'spotlight_control', + selectors: [ + 'input[value="spotlight"]', + 'button[value="spotlight"]', + '[data-testid="layout_spotlight"]', + 'button[aria-label*="spotlight" i]', + ], + }); + +export const getGridControl = (doc: Document | undefined): HTMLElement | undefined => + queryFirst(doc, { + key: 'grid_control', + selectors: [ + 'input[value="grid"]', + 'button[value="grid"]', + '[data-testid="layout_grid"]', + 'button[aria-label*="grid" i]', + ], + }); + +export const getInCallControlsContainer = (doc: Document | undefined): HTMLElement | undefined => { + const leaveButton = getLeaveButton(doc); + + const container = leaveButton?.parentElement?.parentElement; + if (container) return container; + + return queryFirst(doc, { + key: 'incall_controls_container', + selectors: ['[data-testid="incall_controls"]', '[data-testid="incall_toolbar"]'], + }); +}; + +export const isElementToggledOn = (element: HTMLElement | undefined): boolean => { + if (!element) return false; + if ('checked' in element && typeof (element as HTMLInputElement).checked === 'boolean') { + return (element as HTMLInputElement).checked; + } + + const ariaPressed = element.getAttribute('aria-pressed'); + if (ariaPressed !== null) return ariaPressed === 'true'; + + const ariaChecked = element.getAttribute('aria-checked'); + if (ariaChecked !== null) return ariaChecked === 'true'; + + const dataKind = element.getAttribute('data-kind'); + if (dataKind !== null) return dataKind === 'primary'; + + return false; +}; diff --git a/src/app/plugins/call/utils.test.ts b/src/app/plugins/call/utils.test.ts new file mode 100644 index 000000000..c79665937 --- /dev/null +++ b/src/app/plugins/call/utils.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { EventDirection, MatrixCapabilities, WidgetEventCapability } from 'matrix-widget-api'; +import { getCallCapabilities } from './utils'; + +describe('getCallCapabilities', () => { + const roomId = '!room:example.org'; + const userId = '@alice:example.org'; + const deviceId = 'ALICEDEVICE'; + + it('includes delayed-event capabilities', () => { + const capabilities = getCallCapabilities(roomId, userId, deviceId); + + expect(capabilities.has(MatrixCapabilities.MSC4157SendDelayedEvent)).toBe(true); + expect(capabilities.has(MatrixCapabilities.MSC4157UpdateDelayedEvent)).toBe(true); + }); + + it('includes upload and download media capabilities', () => { + const capabilities = getCallCapabilities(roomId, userId, deviceId); + + expect(capabilities.has(MatrixCapabilities.MSC4039UploadFile)).toBe(true); + expect(capabilities.has(MatrixCapabilities.MSC4039DownloadFile)).toBe(true); + }); + + it('includes call member state send/receive capabilities', () => { + const capabilities = getCallCapabilities(roomId, userId, deviceId); + + expect( + capabilities.has( + WidgetEventCapability.forStateEvent( + EventDirection.Send, + 'org.matrix.msc3401.call.member', + userId + ).raw + ) + ).toBe(true); + expect( + capabilities.has( + WidgetEventCapability.forStateEvent(EventDirection.Receive, 'org.matrix.msc3401.call.member') + .raw + ) + ).toBe(true); + }); + + it('includes rtc notification and decline send/receive capabilities', () => { + const capabilities = getCallCapabilities(roomId, userId, deviceId); + + expect( + capabilities.has( + WidgetEventCapability.forRoomEvent(EventDirection.Send, 'org.matrix.msc4075.rtc.notification') + .raw + ) + ).toBe(true); + expect( + capabilities.has( + WidgetEventCapability.forRoomEvent(EventDirection.Receive, 'org.matrix.msc4075.rtc.notification') + .raw + ) + ).toBe(true); + expect( + capabilities.has( + WidgetEventCapability.forRoomEvent(EventDirection.Send, 'org.matrix.msc4310.rtc.decline').raw + ) + ).toBe(true); + expect( + capabilities.has( + WidgetEventCapability.forRoomEvent(EventDirection.Receive, 'org.matrix.msc4310.rtc.decline').raw + ) + ).toBe(true); + }); +}); diff --git a/src/app/plugins/call/utils.ts b/src/app/plugins/call/utils.ts index bb63a2652..b61f87aa9 100644 --- a/src/app/plugins/call/utils.ts +++ b/src/app/plugins/call/utils.ts @@ -16,6 +16,8 @@ export function getCallCapabilities( capabilities.add(MatrixCapabilities.Screenshots); capabilities.add(MatrixCapabilities.AlwaysOnScreen); capabilities.add(MatrixCapabilities.MSC3846TurnServers); + capabilities.add(MatrixCapabilities.MSC4039UploadFile); + capabilities.add(MatrixCapabilities.MSC4039DownloadFile); capabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent); capabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent); capabilities.add('moe.sable.thumbnails'); diff --git a/src/app/state/callEmbed.ts b/src/app/state/callEmbed.ts index bd217cc83..25e45d524 100644 --- a/src/app/state/callEmbed.ts +++ b/src/app/state/callEmbed.ts @@ -1,8 +1,10 @@ import { atom } from 'jotai'; import * as Sentry from '@sentry/react'; import type { CallEmbed } from '../plugins/call'; +import type { CallEmbedStartError } from '$plugins/call/callEmbedError'; const baseCallEmbedAtom = atom(undefined); +const baseCallEmbedStartErrorAtom = atom(null); // Tracks when the active call embed was created, for lifetime measurement. let embedCreatedAt: number | null = null; @@ -29,10 +31,25 @@ export const callEmbedAtom = atom( + (get) => get(baseCallEmbedStartErrorAtom), + (_get, set, nextError) => { + set(baseCallEmbedStartErrorAtom, nextError); + } +); + export const callChatAtom = atom(false); export type IncomingCallNotificationType = 'ring' | 'notification'; From 0ba0b7f67c9d64b3ed553a2bea1255e56794529c Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 14:39:23 -0500 Subject: [PATCH 07/27] more call settings --- src/app/components/IncomingCallModal.tsx | 19 +- src/app/features/call/callRingtone.test.ts | 72 +++ src/app/features/call/callRingtone.ts | 114 +++++ src/app/features/call/callRingtoneStorage.ts | 72 +++ .../settings/general/CallSoundSettings.tsx | 439 ++++++++++++++++++ src/app/features/settings/general/General.tsx | 2 + src/app/hooks/useCallSignaling.ts | 130 +++++- src/app/state/callEmbed.ts | 1 + src/app/state/settings.ts | 43 ++ src/app/utils/settingsSync.test.ts | 12 + src/app/utils/settingsSync.ts | 10 + 11 files changed, 902 insertions(+), 12 deletions(-) create mode 100644 src/app/features/call/callRingtone.test.ts create mode 100644 src/app/features/call/callRingtone.ts create mode 100644 src/app/features/call/callRingtoneStorage.ts create mode 100644 src/app/features/settings/general/CallSoundSettings.tsx diff --git a/src/app/components/IncomingCallModal.tsx b/src/app/components/IncomingCallModal.tsx index 66e40b8a4..b96a52549 100644 --- a/src/app/components/IncomingCallModal.tsx +++ b/src/app/components/IncomingCallModal.tsx @@ -28,8 +28,14 @@ import { webRTCSupported } from '$utils/rtc'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; import FocusTrap from 'focus-trap-react'; import * as Sentry from '@sentry/react'; -import { useAtom, useSetAtom } from 'jotai'; -import { autoJoinCallIntentAtom, incomingCallAtom, mutedCallRoomIdAtom, type IncomingCall } from '$state/callEmbed'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { + autoJoinCallIntentAtom, + callSoundBlockedAtom, + incomingCallAtom, + mutedCallRoomIdAtom, + type IncomingCall, +} from '$state/callEmbed'; import { createDebugLogger } from '$utils/debugLogger'; import { RoomAvatar } from './room-avatar'; import { UserAvatar } from './user-avatar'; @@ -59,6 +65,8 @@ export function IncomingCallInternal({ room, incomingCall, onClose }: IncomingCa const roomAvatarUrl = getRoomAvatarUrl(mx, room, 96); const setAutoJoinIntent = useSetAtom(autoJoinCallIntentAtom); const setMutedRoomId = useSetAtom(mutedCallRoomIdAtom); + const setCallSoundBlocked = useSetAtom(callSoundBlockedAtom); + const callSoundBlocked = useAtomValue(callSoundBlockedAtom); const callerDisplayName = getMemberDisplayName(room, incomingCall.senderId) ?? getMxIdLocalPart(incomingCall.senderId) ?? @@ -128,6 +136,7 @@ export function IncomingCallInternal({ room, incomingCall, onClose }: IncomingCa const handleAnswer = () => { if (!canAnswer) return; + setCallSoundBlocked(false); debugLog.info('call', 'Incoming call answered', { roomId: room.roomId, @@ -158,6 +167,7 @@ export function IncomingCallInternal({ room, incomingCall, onClose }: IncomingCa }; const handleDeclineOrIgnore = async () => { + setCallSoundBlocked(false); const action = isDirectRing ? 'decline' : 'ignore'; debugLog.info('call', 'Incoming call dismissed', { roomId: room.roomId, @@ -327,6 +337,11 @@ export function IncomingCallInternal({ room, incomingCall, onClose }: IncomingCa {primaryBlockedReason} )} + {callSoundBlocked && ( + + Call sound was blocked by your browser. Click any call action to re-enable sound. + + )}
); diff --git a/src/app/features/call/callRingtone.test.ts b/src/app/features/call/callRingtone.test.ts new file mode 100644 index 000000000..b12f20429 --- /dev/null +++ b/src/app/features/call/callRingtone.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { + callRingtoneVolumeToGain, + canPlayCallAudio, + clampCallRingtoneVolume, + resolveIncomingCallToneUrl, + resolveOutgoingRingbackToneUrl, +} from './callRingtone'; + +describe('callRingtone', () => { + it('clamps ringtone volume to 0-100', () => { + expect(clampCallRingtoneVolume(-5)).toBe(0); + expect(clampCallRingtoneVolume(42.4)).toBe(42); + expect(clampCallRingtoneVolume(121)).toBe(100); + }); + + it('converts volume to audio gain', () => { + expect(callRingtoneVolumeToGain(0)).toBe(0); + expect(callRingtoneVolumeToGain(80)).toBe(0.8); + expect(callRingtoneVolumeToGain(100)).toBe(1); + }); + + it('resolves incoming tone with custom override', () => { + expect( + resolveIncomingCallToneUrl({ callRingtoneId: 'custom' }, 'blob:https://example.test/custom') + ).toBe('blob:https://example.test/custom'); + }); + + it('falls back to default incoming tone when custom asset is missing', () => { + const incoming = resolveIncomingCallToneUrl({ callRingtoneId: 'custom' }); + expect(typeof incoming).toBe('string'); + expect(incoming).not.toBeNull(); + }); + + it('resolves outgoing ringback modes', () => { + expect( + resolveOutgoingRingbackToneUrl( + { callRingbackTone: 'same-as-ringtone', callRingtoneId: 'minimal-ping' }, + 'blob:https://example.test/custom' + ) + ).toContain('/public/sound/notification.ogg'); + expect( + resolveOutgoingRingbackToneUrl( + { callRingbackTone: 'same-as-ringtone', callRingtoneId: 'custom' }, + 'blob:https://example.test/custom' + ) + ).toBe('blob:https://example.test/custom'); + + expect( + resolveOutgoingRingbackToneUrl({ + callRingbackTone: 'silent', + callRingtoneId: 'sable-default', + }) + ).toBeNull(); + }); + + it('respects call sound override against global notification sounds', () => { + expect( + canPlayCallAudio({ + isNotificationSounds: false, + callSoundOverrideGlobalNotifications: false, + }) + ).toBe(false); + + expect( + canPlayCallAudio({ + isNotificationSounds: false, + callSoundOverrideGlobalNotifications: true, + }) + ).toBe(true); + }); +}); diff --git a/src/app/features/call/callRingtone.ts b/src/app/features/call/callRingtone.ts new file mode 100644 index 000000000..b155138d9 --- /dev/null +++ b/src/app/features/call/callRingtone.ts @@ -0,0 +1,114 @@ +import InviteSound from '$public/sound/invite.ogg'; +import NotificationSound from '$public/sound/notification.ogg'; +import RingtoneSound from '$public/sound/ringtone.webm'; +import type { CallRingbackTone, CallRingtoneId, Settings } from '$state/settings'; + +export type CallToneOption = { + value: T; + label: string; + description?: string; + disabled?: boolean; +}; + +export const CUSTOM_CALL_RINGTONE_MAX_BYTES = 3_000_000; +export const CUSTOM_CALL_RINGTONE_MAX_DURATION_MS = 45_000; + +export const CALL_RINGTONE_OPTIONS: CallToneOption[] = [ + { value: 'sable-default', label: 'Sable Default' }, + { value: 'classic-soft', label: 'Classic Soft Ring' }, + { value: 'minimal-ping', label: 'Minimal Ping Loop' }, + { value: 'silent', label: 'Silent (Visual Only)' }, + { value: 'custom', label: 'Custom File' }, +]; + +export const CALL_RINGBACK_OPTIONS: CallToneOption[] = [ + { value: 'same-as-ringtone', label: 'Same As Ringtone' }, + { value: 'default-ringback', label: 'Default Ringback' }, + { value: 'silent', label: 'Silent' }, +]; + +type ToneSettings = Pick< + Settings, + | 'isNotificationSounds' + | 'callSoundOverrideGlobalNotifications' +>; + +export const clampCallRingtoneVolume = (volume: number): number => + Math.max(0, Math.min(100, Math.round(volume))); + +export const callRingtoneVolumeToGain = (volume: number): number => + clampCallRingtoneVolume(volume) / 100; + +export const canPlayCallAudio = (settings: ToneSettings): boolean => + settings.callSoundOverrideGlobalNotifications || settings.isNotificationSounds; + +const resolveBuiltInTone = (id: Exclude): string | null => { + switch (id) { + case 'sable-default': + return RingtoneSound; + case 'classic-soft': + return InviteSound; + case 'minimal-ping': + return NotificationSound; + case 'silent': + return null; + default: + return RingtoneSound; + } +}; + +export const resolveIncomingCallToneUrl = ( + settings: Pick, + customRingtoneUrl?: string +): string | null => { + if (settings.callRingtoneId === 'custom') { + return customRingtoneUrl ?? RingtoneSound; + } + + return resolveBuiltInTone(settings.callRingtoneId); +}; + +export const resolveOutgoingRingbackToneUrl = ( + settings: Pick, + customRingtoneUrl?: string +): string | null => { + if (settings.callRingbackTone === 'silent') return null; + + if (settings.callRingbackTone === 'default-ringback') { + return InviteSound; + } + + return resolveIncomingCallToneUrl(settings, customRingtoneUrl); +}; + +export const readAudioDurationMs = async (file: Blob): Promise => + new Promise((resolve, reject) => { + const objectUrl = URL.createObjectURL(file); + const audio = document.createElement('audio'); + audio.preload = 'metadata'; + + const cleanup = () => { + audio.src = ''; + URL.revokeObjectURL(objectUrl); + }; + + audio.addEventListener( + 'loadedmetadata', + () => { + const duration = Number.isFinite(audio.duration) ? Math.round(audio.duration * 1000) : 0; + cleanup(); + resolve(duration); + }, + { once: true } + ); + audio.addEventListener( + 'error', + () => { + cleanup(); + reject(new Error('Unable to read audio duration.')); + }, + { once: true } + ); + + audio.src = objectUrl; + }); diff --git a/src/app/features/call/callRingtoneStorage.ts b/src/app/features/call/callRingtoneStorage.ts new file mode 100644 index 000000000..e5ddac8e4 --- /dev/null +++ b/src/app/features/call/callRingtoneStorage.ts @@ -0,0 +1,72 @@ +const DB_NAME = 'sable-call-audio'; +const DB_VERSION = 1; +const STORE = 'ringtones'; +const CUSTOM_RINGTONE_KEY = 'custom-ringtone'; + +export type StoredCallRingtone = { + id: string; + fileName: string; + mimeType: string; + sizeBytes: number; + durationMs: number; + savedAt: number; + blob: Blob; +}; + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.addEventListener('error', () => reject(req.error)); + req.addEventListener('success', () => resolve(req.result)); + req.addEventListener('upgradeneeded', () => { + req.result.createObjectStore(STORE, { keyPath: 'id' }); + }); + }); +} + +export async function putCustomCallRingtone( + file: File, + durationMs: number +): Promise { + const db = await openDb(); + const entry: StoredCallRingtone = { + id: CUSTOM_RINGTONE_KEY, + fileName: file.name, + mimeType: file.type, + sizeBytes: file.size, + durationMs, + savedAt: Date.now(), + blob: file, + }; + + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).put(entry); + tx.addEventListener('complete', () => resolve()); + tx.addEventListener('error', () => reject(tx.error)); + }); + + return entry; +} + +export async function getCustomCallRingtone(): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readonly'); + const req = tx.objectStore(STORE).get(CUSTOM_RINGTONE_KEY); + req.addEventListener('error', () => reject(req.error)); + req.addEventListener('success', () => { + resolve(req.result as StoredCallRingtone | undefined); + }); + }); +} + +export async function clearCustomCallRingtone(): Promise { + const db = await openDb(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).delete(CUSTOM_RINGTONE_KEY); + tx.addEventListener('complete', () => resolve()); + tx.addEventListener('error', () => reject(tx.error)); + }); +} diff --git a/src/app/features/settings/general/CallSoundSettings.tsx b/src/app/features/settings/general/CallSoundSettings.tsx new file mode 100644 index 000000000..d750a23fc --- /dev/null +++ b/src/app/features/settings/general/CallSoundSettings.tsx @@ -0,0 +1,439 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Box, Button, Icon, Icons, Input, Spinner, Switch, Text, toRem } from 'folds'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { SettingMenuSelector } from '$components/setting-menu-selector'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom, type CallRingtoneId } from '$state/settings'; +import { + CALL_RINGBACK_OPTIONS, + CALL_RINGTONE_OPTIONS, + CUSTOM_CALL_RINGTONE_MAX_BYTES, + CUSTOM_CALL_RINGTONE_MAX_DURATION_MS, + callRingtoneVolumeToGain, + clampCallRingtoneVolume, + readAudioDurationMs, + resolveIncomingCallToneUrl, + resolveOutgoingRingbackToneUrl, +} from '$features/call/callRingtone'; +import { + clearCustomCallRingtone, + getCustomCallRingtone, + putCustomCallRingtone, +} from '$features/call/callRingtoneStorage'; +import { SequenceCardStyle } from '$features/settings/styles.css'; +import { bytesToSize, millisecondsToMinutesAndSeconds } from '$utils/common'; + +function CustomRingtoneMeta({ + fileName, + sizeBytes, + durationMs, +}: { + fileName?: string; + sizeBytes?: number; + durationMs?: number; +}) { + if (!fileName) { + return ( + + No custom ringtone imported. + + ); + } + + return ( + + {fileName} + {typeof sizeBytes === 'number' && ` • ${bytesToSize(sizeBytes)}`} + {typeof durationMs === 'number' && ` • ${millisecondsToMinutesAndSeconds(durationMs)}`} + + ); +} + +export function CallSoundSettings() { + const [incomingCallSoundEnabled, setIncomingCallSoundEnabled] = useSetting( + settingsAtom, + 'incomingCallSoundEnabled' + ); + const [outgoingRingbackEnabled, setOutgoingRingbackEnabled] = useSetting( + settingsAtom, + 'outgoingRingbackEnabled' + ); + const [callRingtoneId, setCallRingtoneId] = useSetting(settingsAtom, 'callRingtoneId'); + const [callRingbackTone, setCallRingbackTone] = useSetting(settingsAtom, 'callRingbackTone'); + const [callRingtoneVolume, setCallRingtoneVolume] = useSetting(settingsAtom, 'callRingtoneVolume'); + const [callSoundOverrideGlobalNotifications, setCallSoundOverrideGlobalNotifications] = + useSetting(settingsAtom, 'callSoundOverrideGlobalNotifications'); + const [callCustomRingtoneName, setCallCustomRingtoneName] = useSetting( + settingsAtom, + 'callCustomRingtoneName' + ); + const [callCustomRingtoneSizeBytes, setCallCustomRingtoneSizeBytes] = useSetting( + settingsAtom, + 'callCustomRingtoneSizeBytes' + ); + const [callCustomRingtoneDurationMs, setCallCustomRingtoneDurationMs] = useSetting( + settingsAtom, + 'callCustomRingtoneDurationMs' + ); + + const [previewing, setPreviewing] = useState(false); + const [loadingCustomState, setLoadingCustomState] = useState(true); + const [hasCustomRingtone, setHasCustomRingtone] = useState(false); + const [customError, setCustomError] = useState(null); + const previewAudioRef = useRef(null); + + useEffect(() => { + let mounted = true; + getCustomCallRingtone() + .then((entry) => { + if (!mounted) return; + setHasCustomRingtone(Boolean(entry)); + }) + .finally(() => { + if (!mounted) return; + setLoadingCustomState(false); + }); + + return () => { + mounted = false; + previewAudioRef.current?.pause(); + previewAudioRef.current = null; + }; + }, []); + + useEffect(() => { + if (!loadingCustomState && !hasCustomRingtone && callRingtoneId === 'custom') { + setCustomError('Custom ringtone is not available on this device. Falling back to default.'); + } + }, [callRingtoneId, hasCustomRingtone, loadingCustomState]); + + const ringtoneOptions = useMemo( + () => + CALL_RINGTONE_OPTIONS.map((option) => + option.value === 'custom' + ? { + ...option, + label: callCustomRingtoneName ? 'Custom File (Imported)' : 'Custom File', + disabled: loadingCustomState ? true : false, + } + : option + ), + [callCustomRingtoneName, loadingCustomState] + ); + + const resolveToneForPreview = useCallback( + async (tone: 'incoming' | 'outgoing'): Promise => { + let customUrl: string | undefined; + if (callRingtoneId === 'custom' || callRingbackTone === 'same-as-ringtone') { + const custom = await getCustomCallRingtone(); + if (custom?.blob) { + customUrl = URL.createObjectURL(custom.blob); + } + } + + const source = + tone === 'incoming' + ? resolveIncomingCallToneUrl({ callRingtoneId }, customUrl) + : resolveOutgoingRingbackToneUrl({ callRingbackTone, callRingtoneId }, customUrl); + + if (customUrl && source !== customUrl) { + URL.revokeObjectURL(customUrl); + } + + return source; + }, + [callRingtoneId, callRingbackTone] + ); + + const playPreviewTone = useCallback( + async (tone: 'incoming' | 'outgoing') => { + setCustomError(null); + setPreviewing(true); + try { + const source = await resolveToneForPreview(tone); + if (!source) return; + const revokeSource = source.startsWith('blob:'); + + previewAudioRef.current?.pause(); + const audio = new Audio(source); + audio.loop = true; + audio.volume = callRingtoneVolumeToGain(callRingtoneVolume); + previewAudioRef.current = audio; + await audio.play(); + window.setTimeout(() => { + if (previewAudioRef.current === audio) { + audio.pause(); + audio.currentTime = 0; + } + if (revokeSource) URL.revokeObjectURL(source); + }, 2500); + } catch { + setCustomError('Unable to preview this ringtone in your browser.'); + } finally { + setPreviewing(false); + } + }, + [callRingtoneVolume, resolveToneForPreview] + ); + + const handleImportCustomRingtone = useCallback(() => { + setCustomError(null); + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'audio/*'; + input.addEventListener('change', async () => { + const file = input.files?.[0]; + if (!file) return; + + if (!file.type.startsWith('audio/')) { + setCustomError('Only audio files are supported.'); + return; + } + if (file.size > CUSTOM_CALL_RINGTONE_MAX_BYTES) { + setCustomError( + `File is too large. Max ${bytesToSize(CUSTOM_CALL_RINGTONE_MAX_BYTES)} allowed.` + ); + return; + } + + try { + const durationMs = await readAudioDurationMs(file); + if (durationMs <= 0 || durationMs > CUSTOM_CALL_RINGTONE_MAX_DURATION_MS) { + setCustomError( + `Ringtone must be between 1s and ${millisecondsToMinutesAndSeconds( + CUSTOM_CALL_RINGTONE_MAX_DURATION_MS + )}.` + ); + return; + } + + await putCustomCallRingtone(file, durationMs); + setHasCustomRingtone(true); + setCallRingtoneId('custom'); + setCallCustomRingtoneName(file.name); + setCallCustomRingtoneSizeBytes(file.size); + setCallCustomRingtoneDurationMs(durationMs); + } catch { + setCustomError('Could not import this file. Try a different audio format.'); + } + }); + + input.click(); + }, [ + setCallCustomRingtoneDurationMs, + setCallCustomRingtoneName, + setCallCustomRingtoneSizeBytes, + setCallRingtoneId, + ]); + + const handleResetCustomRingtone = useCallback(async () => { + setCustomError(null); + await clearCustomCallRingtone(); + setHasCustomRingtone(false); + setCallCustomRingtoneName(undefined); + setCallCustomRingtoneSizeBytes(undefined); + setCallCustomRingtoneDurationMs(undefined); + if (callRingtoneId === 'custom') { + setCallRingtoneId('sable-default'); + } + }, [ + callRingtoneId, + setCallCustomRingtoneDurationMs, + setCallCustomRingtoneName, + setCallCustomRingtoneSizeBytes, + setCallRingtoneId, + ]); + + const handleRingtoneSelection = (next: CallRingtoneId) => { + if (next === 'custom' && !hasCustomRingtone) { + setCustomError('Import a custom ringtone file first.'); + return; + } + setCustomError(null); + setCallRingtoneId(next); + }; + + const handleVolumeChange = (value: string) => { + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed)) return; + setCallRingtoneVolume(clampCallRingtoneVolume(parsed)); + }; + + return ( + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + handleVolumeChange(evt.currentTarget.value)} + outlined + /> + } + /> + + + + } + /> + + + + + + + + + + + + + Max file size: {bytesToSize(CUSTOM_CALL_RINGTONE_MAX_BYTES)}. Max duration:{' '} + {millisecondsToMinutesAndSeconds(CUSTOM_CALL_RINGTONE_MAX_DURATION_MS)}. + + {customError && ( + + {customError} + + )} + + + + + ); +} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 29361bdd6..5a56ac91f 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -47,6 +47,7 @@ import { isKeyHotkey } from 'is-hotkey'; import { settingsSyncLastSyncedAtom, settingsSyncStatusAtom } from '$hooks/useSettingsSync'; import { exportSettingsAsJson, importSettingsFromJson } from '$utils/settingsSync'; import { SettingsSectionPage } from '../SettingsSectionPage'; +import { CallSoundSettings } from './CallSoundSettings'; type DateHintProps = { hasChanges: boolean; @@ -879,6 +880,7 @@ function Calls() { } /> + ); } diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index faf77ec21..769c49f9e 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -4,13 +4,25 @@ import { useAtomValue, useSetAtom } from 'jotai'; import type { RoomEventHandlerMap, MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; import { CryptoBackend, MatrixRTCSession, MatrixRTCSessionManagerEvents, RoomEvent } from '$types/matrix-sdk'; import { mDirectAtom } from '$state/mDirectList'; -import { incomingCallAtom, mutedCallRoomIdAtom, type IncomingCall } from '$state/callEmbed'; -import RingtoneSound from '$public/sound/ringtone.webm'; +import { + callSoundBlockedAtom, + incomingCallAtom, + mutedCallRoomIdAtom, + type IncomingCall, +} from '$state/callEmbed'; +import { settingsAtom } from '$state/settings'; import { parseIncomingRtcNotification, REFERENCE_REL_TYPE, RTC_NOTIFICATION_EVENT_TYPE, } from '$features/call/rtcNotificationParser'; +import { + callRingtoneVolumeToGain, + canPlayCallAudio, + resolveIncomingCallToneUrl, + resolveOutgoingRingbackToneUrl, +} from '$features/call/callRingtone'; +import { getCustomCallRingtone } from '$features/call/callRingtoneStorage'; import { useMatrixClient } from './useMatrixClient'; import { createDebugLogger } from '../utils/debugLogger'; @@ -101,10 +113,12 @@ const canSenderStartCalls = (room: Room, senderId: string): boolean => export function useIncomingCallSignaling() { const mx = useMatrixClient(); const mDirects = useAtomValue(mDirectAtom); + const settings = useAtomValue(settingsAtom); const incomingCall = useAtomValue(incomingCallAtom); const mutedRoomId = useAtomValue(mutedCallRoomIdAtom); const setIncomingCall = useSetAtom(incomingCallAtom); const setMutedRoomId = useSetAtom(mutedCallRoomIdAtom); + const setCallSoundBlocked = useSetAtom(callSoundBlockedAtom); const incomingAudioRef = useRef(null); const outgoingAudioRef = useRef(null); @@ -118,11 +132,11 @@ export function useIncomingCallSignaling() { mutedRoomIdRef.current = mutedRoomId; useEffect(() => { - const incoming = new Audio(RingtoneSound); + const incoming = new Audio(); incoming.loop = true; incomingAudioRef.current = incoming; - const outgoing = new Audio(RingtoneSound); + const outgoing = new Audio(); outgoing.loop = true; outgoingAudioRef.current = outgoing; @@ -132,10 +146,74 @@ export function useIncomingCallSignaling() { }; }, []); + useEffect(() => { + let canceled = false; + let customToneUrl: string | undefined; + + const incoming = incomingAudioRef.current; + const outgoing = outgoingAudioRef.current; + if (!incoming || !outgoing) return undefined; + + const syncSources = async () => { + const needsCustomTone = + settings.callRingtoneId === 'custom' || settings.callRingbackTone === 'same-as-ringtone'; + if (needsCustomTone) { + const custom = await getCustomCallRingtone().catch(() => undefined); + if (custom?.blob) { + customToneUrl = URL.createObjectURL(custom.blob); + } + } + + if (canceled) return; + + incoming.pause(); + incoming.currentTime = 0; + outgoing.pause(); + outgoing.currentTime = 0; + + const incomingTone = resolveIncomingCallToneUrl( + { + callRingtoneId: settings.callRingtoneId, + }, + customToneUrl + ); + const outgoingTone = resolveOutgoingRingbackToneUrl( + { + callRingtoneId: settings.callRingtoneId, + callRingbackTone: settings.callRingbackTone, + }, + customToneUrl + ); + const gain = callRingtoneVolumeToGain(settings.callRingtoneVolume); + + if (incomingTone) { + incoming.src = incomingTone; + } else { + incoming.removeAttribute('src'); + } + if (outgoingTone) { + outgoing.src = outgoingTone; + } else { + outgoing.removeAttribute('src'); + } + + incoming.volume = gain; + outgoing.volume = gain; + }; + + syncSources(); + + return () => { + canceled = true; + if (customToneUrl) URL.revokeObjectURL(customToneUrl); + }; + }, [settings.callRingtoneId, settings.callRingbackTone, settings.callRingtoneVolume]); + const stopIncomingRing = useCallback(() => { incomingAudioRef.current?.pause(); if (incomingAudioRef.current) incomingAudioRef.current.currentTime = 0; - }, []); + setCallSoundBlocked(false); + }, [setCallSoundBlocked]); const stopOutgoingRing = useCallback(() => { outgoingAudioRef.current?.pause(); @@ -149,6 +227,22 @@ export function useIncomingCallSignaling() { setIncomingCall(null); }, [setIncomingCall, stopIncomingRing]); + const callAudioAllowed = canPlayCallAudio({ + isNotificationSounds: settings.isNotificationSounds, + callSoundOverrideGlobalNotifications: settings.callSoundOverrideGlobalNotifications, + }); + const incomingRingtoneAllowed = settings.incomingCallSoundEnabled && callAudioAllowed; + const outgoingRingbackAllowed = settings.outgoingRingbackEnabled && callAudioAllowed; + + useEffect(() => { + if (!incomingRingtoneAllowed) { + stopIncomingRing(); + } + if (!outgoingRingbackAllowed) { + stopOutgoingRing(); + } + }, [incomingRingtoneAllowed, outgoingRingbackAllowed, stopIncomingRing, stopOutgoingRing]); + const handleIncomingCall = useCallback( (nextIncomingCall: IncomingCall) => { if (mutedRoomIdRef.current === nextIncomingCall.roomId) return; @@ -179,14 +273,25 @@ export function useIncomingCallSignaling() { }); if (nextIncomingCall.notificationType === 'ring') { - incomingAudioRef.current?.play().catch(() => { - Sentry.metrics.count('sable.call.ringtone.blocked', 1); - }); + if (!incomingRingtoneAllowed) { + stopIncomingRing(); + return; + } + + incomingAudioRef.current + ?.play() + .then(() => { + setCallSoundBlocked(false); + }) + .catch(() => { + setCallSoundBlocked(true); + Sentry.metrics.count('sable.call.ringtone.blocked', 1); + }); } else { stopIncomingRing(); } }, - [setIncomingCall, stopIncomingRing] + [incomingRingtoneAllowed, setCallSoundBlocked, setIncomingCall, stopIncomingRing] ); useEffect(() => { @@ -343,7 +448,11 @@ export function useIncomingCallSignaling() { if (!outgoingStartRef.current) outgoingStartRef.current = now; if (now - outgoingStartRef.current < OUTGOING_RING_TIMEOUT_MS) { if (outgoingRingRoomIdRef.current !== roomId) { - outgoingAudioRef.current?.play().catch(() => {}); + if (outgoingRingbackAllowed) { + outgoingAudioRef.current?.play().catch(() => { + Sentry.metrics.count('sable.call.ringback.blocked', 1); + }); + } outgoingRingRoomIdRef.current = roomId; debugLog.info('call', 'Outgoing ringing fallback started', { roomId }); } @@ -380,6 +489,7 @@ export function useIncomingCallSignaling() { }, [ mx, mDirects, + outgoingRingbackAllowed, handleIncomingCall, clearIncomingCall, stopIncomingRing, diff --git a/src/app/state/callEmbed.ts b/src/app/state/callEmbed.ts index 25e45d524..0dbe3dd69 100644 --- a/src/app/state/callEmbed.ts +++ b/src/app/state/callEmbed.ts @@ -77,3 +77,4 @@ export const incomingCallAtom = atom(null); export const incomingCallRoomIdAtom = atom((get) => get(incomingCallAtom)?.roomId ?? null); export const autoJoinCallIntentAtom = atom(null); export const mutedCallRoomIdAtom = atom(null); +export const callSoundBlockedAtom = atom(false); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index b1b744c1f..c89808700 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -29,6 +29,13 @@ export enum ShowRoomIcon { Never = 'never', } export type JumboEmojiSize = 'none' | 'extraSmall' | 'small' | 'normal' | 'large' | 'extraLarge'; +export type CallRingtoneId = + | 'sable-default' + | 'classic-soft' + | 'minimal-ping' + | 'silent' + | 'custom'; +export type CallRingbackTone = 'same-as-ringtone' | 'default-ringback' | 'silent'; export type ThemeRemoteFavorite = { fullUrl: string; @@ -153,6 +160,15 @@ export interface Settings { subspaceHierarchyLimit: number; alwaysShowCallButton: boolean; joinCallOnSingleClick: boolean; + incomingCallSoundEnabled: boolean; + outgoingRingbackEnabled: boolean; + callRingtoneVolume: number; + callRingtoneId: CallRingtoneId; + callRingbackTone: CallRingbackTone; + callSoundOverrideGlobalNotifications: boolean; + callCustomRingtoneName?: string; + callCustomRingtoneSizeBytes?: number; + callCustomRingtoneDurationMs?: number; faviconForMentionsOnly: boolean; highlightMentions: boolean; pkCompat: boolean; @@ -285,6 +301,15 @@ export const defaultSettings: Settings = { subspaceHierarchyLimit: 3, alwaysShowCallButton: false, joinCallOnSingleClick: true, + incomingCallSoundEnabled: true, + outgoingRingbackEnabled: true, + callRingtoneVolume: 80, + callRingtoneId: 'sable-default', + callRingbackTone: 'same-as-ringtone', + callSoundOverrideGlobalNotifications: false, + callCustomRingtoneName: undefined, + callCustomRingtoneSizeBytes: undefined, + callCustomRingtoneDurationMs: undefined, faviconForMentionsOnly: false, highlightMentions: true, pkCompat: false, @@ -471,6 +496,24 @@ function sanitizeSettingsKey(key: keyof Settings, val: unknown): unknown { : undefined; case 'rightSwipeAction': return val === RightSwipeAction.Members || val === RightSwipeAction.Reply ? val : undefined; + case 'callRingtoneId': + return val === 'sable-default' || + val === 'classic-soft' || + val === 'minimal-ping' || + val === 'silent' || + val === 'custom' + ? val + : undefined; + case 'callRingbackTone': + return val === 'same-as-ringtone' || val === 'default-ringback' || val === 'silent' + ? val + : undefined; + case 'callRingtoneVolume': + if (typeof val !== 'number' || !Number.isFinite(val)) return undefined; + return Math.max(0, Math.min(100, Math.round(val))); + case 'callCustomRingtoneSizeBytes': + case 'callCustomRingtoneDurationMs': + return typeof val === 'number' && Number.isFinite(val) && val >= 0 ? Math.round(val) : undefined; case 'renderUserCards': return val === 'both' || val === 'light' || val === 'dark' || val === 'none' ? val diff --git a/src/app/utils/settingsSync.test.ts b/src/app/utils/settingsSync.test.ts index 608a94343..6edd6bd3b 100644 --- a/src/app/utils/settingsSync.test.ts +++ b/src/app/utils/settingsSync.test.ts @@ -31,6 +31,15 @@ describe('NON_SYNCABLE_KEYS', () => { 'isPeopleDrawer', 'isWidgetDrawer', 'memberSortFilterIndex', + 'incomingCallSoundEnabled', + 'outgoingRingbackEnabled', + 'callRingtoneVolume', + 'callRingtoneId', + 'callRingbackTone', + 'callSoundOverrideGlobalNotifications', + 'callCustomRingtoneName', + 'callCustomRingtoneSizeBytes', + 'callCustomRingtoneDurationMs', 'developerTools', 'settingsSyncEnabled', ] as const; @@ -138,6 +147,7 @@ describe('deserializeFromSync', () => { settings: { pageZoom: 200, isPeopleDrawer: false, + callRingtoneVolume: 20, settingsSyncEnabled: true, developerTools: true, }, @@ -146,12 +156,14 @@ describe('deserializeFromSync', () => { ...base, pageZoom: 100, isPeopleDrawer: true, + callRingtoneVolume: 80, settingsSyncEnabled: false, }; const result = deserializeFromSync(remote, local); expect(result).not.toBeNull(); expect(result!.pageZoom).toBe(100); expect(result!.isPeopleDrawer).toBe(true); + expect(result!.callRingtoneVolume).toBe(80); expect(result!.settingsSyncEnabled).toBe(false); expect(result!.developerTools).toBe(false); }); diff --git a/src/app/utils/settingsSync.ts b/src/app/utils/settingsSync.ts index 83c8ff11f..65f1d6cfe 100644 --- a/src/app/utils/settingsSync.ts +++ b/src/app/utils/settingsSync.ts @@ -14,6 +14,16 @@ export const NON_SYNCABLE_KEYS = new Set([ 'isPeopleDrawer', 'isWidgetDrawer', 'memberSortFilterIndex', + // Call audio is device-local (speaker setup + custom files in IndexedDB) + 'incomingCallSoundEnabled', + 'outgoingRingbackEnabled', + 'callRingtoneVolume', + 'callRingtoneId', + 'callRingbackTone', + 'callSoundOverrideGlobalNotifications', + 'callCustomRingtoneName', + 'callCustomRingtoneSizeBytes', + 'callCustomRingtoneDurationMs', // Developer / diagnostic 'developerTools', // Sync toggle itself must never be uploaded (it's device-local) From b74a8406fc0bf5189ee4115f74b590a34e844fd7 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 15:27:03 -0500 Subject: [PATCH 08/27] call notif improvements --- src/app/components/IncomingCallModal.tsx | 3 + .../call/callNotificationBridge.test.ts | 84 +++++++++ .../features/call/callNotificationBridge.ts | 138 ++++++++++++++ src/app/hooks/useCallSignaling.ts | 12 ++ src/app/pages/client/ClientNonUIFeatures.tsx | 14 +- src/app/pages/client/ToRoomEvent.tsx | 23 ++- src/sw.ts | 39 +++- src/sw/pushNotification.ts | 168 ++++++++++++++++-- 8 files changed, 463 insertions(+), 18 deletions(-) create mode 100644 src/app/features/call/callNotificationBridge.test.ts create mode 100644 src/app/features/call/callNotificationBridge.ts diff --git a/src/app/components/IncomingCallModal.tsx b/src/app/components/IncomingCallModal.tsx index b96a52549..acb84ccaa 100644 --- a/src/app/components/IncomingCallModal.tsx +++ b/src/app/components/IncomingCallModal.tsx @@ -37,6 +37,7 @@ import { type IncomingCall, } from '$state/callEmbed'; import { createDebugLogger } from '$utils/debugLogger'; +import { dismissSystemCallNotifications } from '$features/call/callNotificationBridge'; import { RoomAvatar } from './room-avatar'; import { UserAvatar } from './user-avatar'; @@ -162,6 +163,7 @@ export function IncomingCallInternal({ room, incomingCall, onClose }: IncomingCa setMutedRoomId(room.roomId); setAutoJoinIntent({ roomId: room.roomId, video: isVideoIntent }); + void dismissSystemCallNotifications(room.roomId); onClose(); navigateRoom(room.roomId); }; @@ -204,6 +206,7 @@ export function IncomingCallInternal({ room, incomingCall, onClose }: IncomingCa } setMutedRoomId(room.roomId); + void dismissSystemCallNotifications(room.roomId); onClose(); }; diff --git a/src/app/features/call/callNotificationBridge.test.ts b/src/app/features/call/callNotificationBridge.test.ts new file mode 100644 index 000000000..b8569df83 --- /dev/null +++ b/src/app/features/call/callNotificationBridge.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { + resolveIncomingCallFromNotificationData, + resolveIncomingCallFromSearchParams, +} from './callNotificationBridge'; + +describe('callNotificationBridge', () => { + it('hydrates incoming call from notification click payload', () => { + const now = 1_000_000; + const incoming = resolveIncomingCallFromNotificationData( + { + isCall: true, + roomId: '!room:test', + eventId: '$notif', + callNotificationType: 'ring', + callIntentKind: 'video', + callRefEventId: '$ref', + callSenderId: '@alice:test', + callSenderTs: now - 1_000, + callExpiresAt: now + 10_000, + }, + true, + now + ); + + expect(incoming).toMatchObject({ + roomId: '!room:test', + notificationEventId: '$notif', + refEventId: '$ref', + senderId: '@alice:test', + notificationType: 'ring', + intentKind: 'video', + isDirect: true, + }); + }); + + it('drops expired call notifications', () => { + const now = 1_000_000; + const incoming = resolveIncomingCallFromNotificationData( + { + isCall: true, + roomId: '!room:test', + eventId: '$notif', + callNotificationType: 'ring', + callExpiresAt: now - 1, + }, + false, + now + ); + + expect(incoming).toBeUndefined(); + }); + + it('hydrates incoming call from /to/ deep-link search params', () => { + const now = 1_000_000; + const params = new URLSearchParams({ + call: '1', + callType: 'notification', + callIntentKind: 'audio', + callRefEventId: '$ref', + callSenderId: '@bob:test', + callSenderTs: String(now - 500), + callExpiresAt: String(now + 5_000), + }); + + const incoming = resolveIncomingCallFromSearchParams( + params, + '!room:test', + '$notif', + false, + now + ); + + expect(incoming).toMatchObject({ + roomId: '!room:test', + notificationEventId: '$notif', + refEventId: '$ref', + senderId: '@bob:test', + notificationType: 'notification', + intentKind: 'audio', + isDirect: false, + }); + }); +}); diff --git a/src/app/features/call/callNotificationBridge.ts b/src/app/features/call/callNotificationBridge.ts new file mode 100644 index 000000000..d8ba080a4 --- /dev/null +++ b/src/app/features/call/callNotificationBridge.ts @@ -0,0 +1,138 @@ +import type { IncomingCall, IncomingCallIntentKind, IncomingCallNotificationType } from '$state/callEmbed'; + +const MAX_CALL_NOTIFICATION_LIFETIME_MS = 120_000; + +type CallCandidate = { + roomId: string; + notificationEventId: string; + notificationTypeRaw?: string; + intentKindRaw?: string; + intentRaw?: string; + refEventIdRaw?: string; + senderIdRaw?: string; + senderTsRaw?: number; + expiresAtRaw?: number; + isDirect: boolean; +}; + +const toNotificationType = ( + value: string | undefined +): IncomingCallNotificationType | undefined => { + if (value === 'ring' || value === 'notification') return value; + return undefined; +}; + +const normalizeIntentKind = ( + intentKindRaw: string | undefined, + intentRaw: string | undefined +): IncomingCallIntentKind => { + if (intentKindRaw === 'audio' || intentKindRaw === 'video') { + return intentKindRaw; + } + const normalized = intentRaw?.toLowerCase(); + if (normalized?.includes('video')) return 'video'; + return 'audio'; +}; + +const fromCandidate = (candidate: CallCandidate, now = Date.now()): IncomingCall | undefined => { + const notificationType = toNotificationType(candidate.notificationTypeRaw) ?? 'ring'; + const senderTs = + typeof candidate.senderTsRaw === 'number' && Number.isFinite(candidate.senderTsRaw) + ? candidate.senderTsRaw + : now; + const expiresAt = + typeof candidate.expiresAtRaw === 'number' && Number.isFinite(candidate.expiresAtRaw) + ? candidate.expiresAtRaw + : senderTs + MAX_CALL_NOTIFICATION_LIFETIME_MS; + + if (now >= expiresAt) return undefined; + + return { + roomId: candidate.roomId, + notificationEventId: candidate.notificationEventId, + refEventId: candidate.refEventIdRaw || candidate.notificationEventId, + senderId: candidate.senderIdRaw || 'unknown', + senderTs, + expiresAt, + notificationType, + intentKind: normalizeIntentKind(candidate.intentKindRaw, candidate.intentRaw), + intentRaw: candidate.intentRaw, + isDirect: candidate.isDirect, + }; +}; + +export const resolveIncomingCallFromNotificationData = ( + data: Record, + isDirect: boolean, + now = Date.now() +): IncomingCall | undefined => { + const roomId = typeof data.roomId === 'string' ? data.roomId : undefined; + const eventId = typeof data.eventId === 'string' ? data.eventId : undefined; + const callType = typeof data.callNotificationType === 'string' ? data.callNotificationType : undefined; + + if (!roomId || !eventId) return undefined; + if (data.isCall !== true && !callType) return undefined; + + return fromCandidate( + { + roomId, + notificationEventId: eventId, + notificationTypeRaw: callType, + intentKindRaw: typeof data.callIntentKind === 'string' ? data.callIntentKind : undefined, + intentRaw: typeof data.callIntentRaw === 'string' ? data.callIntentRaw : undefined, + refEventIdRaw: typeof data.callRefEventId === 'string' ? data.callRefEventId : undefined, + senderIdRaw: typeof data.callSenderId === 'string' ? data.callSenderId : undefined, + senderTsRaw: typeof data.callSenderTs === 'number' ? data.callSenderTs : undefined, + expiresAtRaw: typeof data.callExpiresAt === 'number' ? data.callExpiresAt : undefined, + isDirect, + }, + now + ); +}; + +export const resolveIncomingCallFromSearchParams = ( + searchParams: URLSearchParams, + roomId: string, + notificationEventId: string | undefined, + isDirect: boolean, + now = Date.now() +): IncomingCall | undefined => { + if (searchParams.get('call') !== '1') return undefined; + if (!notificationEventId) return undefined; + + const senderTsRaw = Number(searchParams.get('callSenderTs')); + const expiresAtRaw = Number(searchParams.get('callExpiresAt')); + + return fromCandidate( + { + roomId, + notificationEventId, + notificationTypeRaw: searchParams.get('callType') ?? undefined, + intentKindRaw: searchParams.get('callIntentKind') ?? undefined, + intentRaw: searchParams.get('callIntentRaw') ?? undefined, + refEventIdRaw: searchParams.get('callRefEventId') ?? undefined, + senderIdRaw: searchParams.get('callSenderId') ?? undefined, + senderTsRaw: Number.isFinite(senderTsRaw) ? senderTsRaw : undefined, + expiresAtRaw: Number.isFinite(expiresAtRaw) ? expiresAtRaw : undefined, + isDirect, + }, + now + ); +}; + +export const dismissSystemCallNotifications = async (roomId?: string): Promise => { + if (!('serviceWorker' in navigator)) return; + try { + const registration = await navigator.serviceWorker.ready; + const notifications = roomId + ? await registration.getNotifications({ tag: `call-${roomId}` }) + : await registration.getNotifications(); + notifications.forEach((notification) => { + if (!roomId || notification?.data?.room_id === roomId || notification?.data?.roomId === roomId) { + notification.close(); + } + }); + } catch { + // Best-effort cleanup; ignore unsupported browsers and transient SW errors. + } +}; diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index 769c49f9e..7dcd9b2d8 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -22,6 +22,7 @@ import { resolveIncomingCallToneUrl, resolveOutgoingRingbackToneUrl, } from '$features/call/callRingtone'; +import { dismissSystemCallNotifications } from '$features/call/callNotificationBridge'; import { getCustomCallRingtone } from '$features/call/callRingtoneStorage'; import { useMatrixClient } from './useMatrixClient'; import { createDebugLogger } from '../utils/debugLogger'; @@ -223,8 +224,12 @@ export function useIncomingCallSignaling() { }, []); const clearIncomingCall = useCallback(() => { + const activeIncomingCall = incomingCallRef.current; stopIncomingRing(); setIncomingCall(null); + if (activeIncomingCall) { + void dismissSystemCallNotifications(activeIncomingCall.roomId); + } }, [setIncomingCall, stopIncomingRing]); const callAudioAllowed = canPlayCallAudio({ @@ -273,6 +278,13 @@ export function useIncomingCallSignaling() { }); if (nextIncomingCall.notificationType === 'ring') { + const appVisible = document.visibilityState === 'visible'; + if (!appVisible) { + stopIncomingRing(); + setCallSoundBlocked(false); + return; + } + if (!incomingRingtoneAllowed) { stopIncomingRing(); return; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index e97798894..9ee1d3a76 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -45,6 +45,7 @@ import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { registrationAtom } from '$state/serviceWorkerRegistration'; import { pendingNotificationAtom, inAppBannerAtom, activeSessionIdAtom } from '$state/sessions'; +import { incomingCallAtom } from '$state/callEmbed'; import { buildRoomMessageNotification, resolveNotificationPreviewText, @@ -60,6 +61,7 @@ import { useIncomingCallSignaling } from '$hooks/useCallSignaling'; import { getBlobCacheStats } from '$hooks/useBlobCache'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; +import { resolveIncomingCallFromNotificationData } from '$features/call/callNotificationBridge'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; @@ -609,6 +611,8 @@ type ClientNonUIFeaturesProps = { export function HandleNotificationClick() { const setPending = useSetAtom(pendingNotificationAtom); const setActiveSessionId = useSetAtom(activeSessionIdAtom); + const setIncomingCall = useSetAtom(incomingCallAtom); + const mDirects = useAtomValue(mDirectAtom); const navigate = useNavigate(); useEffect(() => { @@ -634,11 +638,19 @@ export function HandleNotificationClick() { if (!roomId) return; setPending({ roomId, eventId, targetSessionId: userId }); + + const incomingCall = resolveIncomingCallFromNotificationData( + data as Record, + mDirects.has(roomId) + ); + if (incomingCall) { + setIncomingCall(incomingCall); + } }; navigator.serviceWorker.addEventListener('message', handleMessage); return () => navigator.serviceWorker.removeEventListener('message', handleMessage); - }, [setPending, setActiveSessionId, navigate]); + }, [mDirects, navigate, setActiveSessionId, setIncomingCall, setPending]); return null; } diff --git a/src/app/pages/client/ToRoomEvent.tsx b/src/app/pages/client/ToRoomEvent.tsx index 3073b44e9..a7c9b2141 100644 --- a/src/app/pages/client/ToRoomEvent.tsx +++ b/src/app/pages/client/ToRoomEvent.tsx @@ -1,7 +1,10 @@ import { useEffect } from 'react'; -import { useParams } from 'react-router-dom'; -import { useSetAtom } from 'jotai'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { useAtomValue, useSetAtom } from 'jotai'; import { activeSessionIdAtom, pendingNotificationAtom } from '$state/sessions'; +import { mDirectAtom } from '$state/mDirectList'; +import { incomingCallAtom } from '$state/callEmbed'; +import { resolveIncomingCallFromSearchParams } from '$features/call/callNotificationBridge'; // ToRoomEvent handles /to/:user_id/:room_id/:event_id? — the canonical deep-link // URL used by the service worker's notificationclick handler. @@ -17,8 +20,11 @@ import { activeSessionIdAtom, pendingNotificationAtom } from '$state/sessions'; // setActiveSessionId() triggers an account switch. export function ToRoomEvent() { const { user_id: userId, room_id: roomId, event_id: eventId } = useParams(); + const [searchParams] = useSearchParams(); + const mDirects = useAtomValue(mDirectAtom); const setActiveSessionId = useSetAtom(activeSessionIdAtom); const setPending = useSetAtom(pendingNotificationAtom); + const setIncomingCall = useSetAtom(incomingCallAtom); useEffect(() => { if (!roomId) return; @@ -26,9 +32,20 @@ export function ToRoomEvent() { // under the correct session. if (userId) setActiveSessionId(userId); setPending({ roomId, eventId, targetSessionId: userId }); + + const incomingCall = resolveIncomingCallFromSearchParams( + searchParams, + roomId, + eventId, + mDirects.has(roomId) + ); + if (incomingCall) { + setIncomingCall(incomingCall); + } + // Replace /to/… in history so the back button doesn't return to this route. window.history.replaceState({}, '', '/'); - }, [userId, roomId, eventId, setActiveSessionId, setPending]); + }, [eventId, mDirects, roomId, searchParams, setActiveSessionId, setIncomingCall, setPending, userId]); return null; } diff --git a/src/sw.ts b/src/sw.ts index 78255b701..f51e5001e 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -481,6 +481,7 @@ async function handleMinimalPushPayload( room_id: roomId, event_id: eventId, user_id: session.userId, + sender_id: sender, }; if (eventType === 'm.room.encrypted') { @@ -837,6 +838,15 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { const pushRoomId: string | undefined = data?.room_id ?? undefined; const pushEventId: string | undefined = data?.event_id ?? undefined; const isInvite = data?.content?.membership === 'invite'; + const callNotificationType: string | undefined = data?.callNotificationType ?? undefined; + const callIntentKind: string | undefined = data?.callIntentKind ?? undefined; + const callIntentRaw: string | undefined = data?.callIntentRaw ?? undefined; + const callRefEventId: string | undefined = data?.callRefEventId ?? undefined; + const callSenderId: string | undefined = data?.sender_id ?? data?.callSenderId ?? undefined; + const callSenderTs: number | undefined = + typeof data?.callSenderTs === 'number' ? data.callSenderTs : undefined; + const callExpiresAt: number | undefined = + typeof data?.callExpiresAt === 'number' ? data.callExpiresAt : undefined; console.debug('[SW notificationclick] notification data:', JSON.stringify(data, null, 2)); console.debug('[SW notificationclick] resolved fields:', { @@ -864,11 +874,25 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { if (pushUserId) u.searchParams.set('uid', pushUserId); targetUrl = u.href; } else if (pushUserId && pushRoomId) { - const callParam = isCall ? '?joinCall=true' : ''; const segments = pushEventId - ? `to/${encodeURIComponent(pushUserId)}/${encodeURIComponent(pushRoomId)}/${encodeURIComponent(pushEventId)}/${callParam}` - : `to/${encodeURIComponent(pushUserId)}/${encodeURIComponent(pushRoomId)}/${callParam}`; - targetUrl = new URL(segments, scope).href; + ? `to/${encodeURIComponent(pushUserId)}/${encodeURIComponent(pushRoomId)}/${encodeURIComponent(pushEventId)}` + : `to/${encodeURIComponent(pushUserId)}/${encodeURIComponent(pushRoomId)}`; + const target = new URL(segments, scope); + if (isCall) { + target.searchParams.set('call', '1'); + if (callNotificationType) target.searchParams.set('callType', callNotificationType); + if (callIntentKind) target.searchParams.set('callIntentKind', callIntentKind); + if (callIntentRaw) target.searchParams.set('callIntentRaw', callIntentRaw); + if (callRefEventId) target.searchParams.set('callRefEventId', callRefEventId); + if (callSenderId) target.searchParams.set('callSenderId', callSenderId); + if (typeof callSenderTs === 'number') { + target.searchParams.set('callSenderTs', String(callSenderTs)); + } + if (typeof callExpiresAt === 'number') { + target.searchParams.set('callExpiresAt', String(callExpiresAt)); + } + } + targetUrl = target.href; } else { // Fallback: no room ID or no user ID in payload. targetUrl = new URL('inbox/notifications/', scope).href; @@ -906,6 +930,13 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { eventId: pushEventId, isInvite, isCall, + callNotificationType, + callIntentKind, + callIntentRaw, + callRefEventId, + callSenderId, + callSenderTs, + callExpiresAt, }); // oxlint-disable-next-line no-await-in-loop await wc.focus(); diff --git a/src/sw/pushNotification.ts b/src/sw/pushNotification.ts index d040d066e..175207a49 100644 --- a/src/sw/pushNotification.ts +++ b/src/sw/pushNotification.ts @@ -15,8 +15,16 @@ type NotificationSettings = { interface MatrixPushData { type?: string; - content?: { notification_type?: string; membership?: string }; + content?: { + notification_type?: string; + membership?: string; + sender_ts?: number; + lifetime?: number; + 'm.call.intent'?: string; + 'm.relates_to'?: { event_id?: string }; + }; sender_display_name?: string; + sender_id?: string; room_name?: string; room_id?: string; room_avatar_url?: string; @@ -27,6 +35,113 @@ interface MatrixPushData { } const resolveSilent = (): boolean => false; +const MAX_CALL_NOTIFICATION_LIFETIME_MS = 120_000; + +const normalizeCallIntentKind = (intentRaw: string | undefined): 'audio' | 'video' => { + if (!intentRaw) return 'audio'; + const normalized = intentRaw.toLowerCase(); + if (normalized.includes('video')) return 'video'; + return 'audio'; +}; + +const isCallNotificationType = (value: unknown): value is 'ring' | 'notification' => + value === 'ring' || value === 'notification'; + +const getCallTiming = ( + content: MatrixPushData['content'], + originTs: number +): { senderTs: number; expiresAt: number } => { + const senderTsCandidate = content?.sender_ts; + const lifetimeCandidate = content?.lifetime; + + if (typeof senderTsCandidate !== 'number' || !Number.isFinite(senderTsCandidate)) { + const senderTs = originTs; + return { + senderTs, + expiresAt: senderTs + MAX_CALL_NOTIFICATION_LIFETIME_MS, + }; + } + + const senderTs = senderTsCandidate - originTs > 20_000 ? originTs : senderTsCandidate; + const lifetime = + typeof lifetimeCandidate === 'number' && Number.isFinite(lifetimeCandidate) + ? Math.min(Math.max(lifetimeCandidate, 0), MAX_CALL_NOTIFICATION_LIFETIME_MS) + : MAX_CALL_NOTIFICATION_LIFETIME_MS; + + return { + senderTs, + expiresAt: senderTs + lifetime, + }; +}; + +const resolveCallNotificationCopy = ( + notificationType: 'ring' | 'notification', + intentKind: 'audio' | 'video', + senderDisplayName: string | undefined, + roomName: string | undefined, + showPreviewDetails: boolean +): { title: string; body: string | undefined } => { + if (notificationType === 'notification') { + if (!showPreviewDetails) { + return { + title: 'Room call started', + body: 'Open Sable to join.', + }; + } + if (roomName && senderDisplayName) { + return { + title: 'Room call started', + body: `${senderDisplayName} started a call in ${roomName}`, + }; + } + if (roomName) { + return { + title: 'Room call started', + body: `A call started in ${roomName}`, + }; + } + if (senderDisplayName) { + return { + title: 'Room call started', + body: `${senderDisplayName} started a call`, + }; + } + return { + title: 'Room call started', + body: 'A room call started.', + }; + } + + const title = intentKind === 'video' ? 'Incoming video call' : 'Incoming voice call'; + if (!showPreviewDetails) { + return { + title, + body: 'Open Sable to answer.', + }; + } + if (senderDisplayName && roomName) { + return { + title, + body: `${senderDisplayName} is calling you in ${roomName}`, + }; + } + if (senderDisplayName) { + return { + title, + body: `${senderDisplayName} is calling you`, + }; + } + if (roomName) { + return { + title, + body: `Incoming call in ${roomName}`, + }; + } + return { + title, + body: 'Incoming call', + }; +}; export const createPushNotifications = ( self: ServiceWorkerGlobalScope, @@ -38,13 +153,15 @@ export const createPushNotifications = ( data: Record, silent?: boolean, icon?: string, - badge?: string + badge?: string, + tagOverride?: string ) => { const roomId: string | undefined = data?.room_id as string | undefined; // Group by room so new messages in the same room replace the previous // notification rather than stacking individually. renotify: true ensures // the user is still alerted when the existing tag is replaced. - const tag: string = roomId ? `room-${roomId}` : ((data?.event_id as string) ?? 'Cinny'); + const tag: string = + tagOverride ?? (roomId ? `room-${roomId}` : ((data?.event_id as string) ?? 'Cinny')); const renotify = !!roomId; // `renotify` is a valid Web API property absent from TypeScript's NotificationOptions type. // Build the options object separately to avoid the excess-property check, then cast. @@ -62,26 +179,57 @@ export const createPushNotifications = ( }; const handleCallNotification = async (pushData: MatrixPushData) => { - const content = pushData?.content as { notification_type?: string } | undefined; - if (content?.notification_type !== 'ring') return; + if (pushData.type === EventType.RoomMessageEncrypted) return; + + const notificationTypeRaw = pushData?.content?.notification_type; + if (!isCallNotificationType(notificationTypeRaw)) return; + const intentRaw = + typeof pushData?.content?.['m.call.intent'] === 'string' + ? pushData.content['m.call.intent'] + : undefined; + const intentKind = normalizeCallIntentKind(intentRaw); const senderDisplayName = pushData?.sender_display_name; const roomName = pushData?.room_name; - const title = 'Incoming Call'; - const body = senderDisplayName - ? `${senderDisplayName} is calling you ${roomName ? `in ${roomName}` : ''}` - : 'Incoming voice chat'; + const showPreviewDetails = getNotificationSettings().showMessageContent; + const copy = resolveCallNotificationCopy( + notificationTypeRaw, + intentKind, + senderDisplayName, + roomName, + showPreviewDetails + ); + const originTs = typeof pushData.timestamp === 'number' ? pushData.timestamp : Date.now(); + const { senderTs, expiresAt } = getCallTiming(pushData.content, originTs); const data = { type: pushData?.type, room_id: pushData?.room_id, + event_id: pushData?.event_id, user_id: pushData?.user_id, + sender_id: pushData?.sender_id, timestamp: Date.now(), isCall: true, + callNotificationType: notificationTypeRaw, + callIntentKind: intentKind, + callIntentRaw: intentRaw, + callNotificationEventId: pushData?.event_id, + callRefEventId: pushData?.content?.['m.relates_to']?.event_id, + callSenderTs: senderTs, + callExpiresAt: expiresAt, ...pushData.data, }; - await showNotificationWithData(title, body, data, resolveSilent(), pushData?.room_avatar_url); + const callTag = pushData?.room_id ? `call-${pushData.room_id}` : undefined; + await showNotificationWithData( + copy.title, + copy.body, + data, + resolveSilent(), + pushData?.room_avatar_url, + undefined, + callTag + ); }; const handleRoomMessageNotification = async (pushData: MatrixPushData) => { From 0b0af754a6c848845953964e667e722416efe471 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 16:19:51 -0500 Subject: [PATCH 09/27] more tests --- src/app/components/IncomingCallModal.test.tsx | 33 ++++- src/app/features/call/callRingtone.test.ts | 39 ++++++ src/app/features/call/callRingtone.ts | 23 ++++ src/app/features/room/RoomCallButton.test.tsx | 85 +++++++++++++ .../general/CallSoundSettings.test.tsx | 49 ++++++++ .../settings/general/CallSoundSettings.tsx | 30 +++-- .../client/HandleNotificationClick.test.tsx | 113 ++++++++++++++++++ src/app/state/callEmbed.test.ts | 46 +++++++ src/app/state/settings.defaults.test.ts | 51 ++++++++ src/app/state/settings.ts | 36 ++++++ 10 files changed, 492 insertions(+), 13 deletions(-) create mode 100644 src/app/features/room/RoomCallButton.test.tsx create mode 100644 src/app/features/settings/general/CallSoundSettings.test.tsx create mode 100644 src/app/pages/client/HandleNotificationClick.test.tsx create mode 100644 src/app/state/callEmbed.test.ts diff --git a/src/app/components/IncomingCallModal.test.tsx b/src/app/components/IncomingCallModal.test.tsx index 7952f048e..f9e74059c 100644 --- a/src/app/components/IncomingCallModal.test.tsx +++ b/src/app/components/IncomingCallModal.test.tsx @@ -37,6 +37,7 @@ vi.mock('$hooks/useRoomMeta', () => ({ vi.mock('$utils/room', () => ({ getRoomAvatarUrl: () => null, + getMemberDisplayName: () => 'Alice', })); vi.mock('$hooks/useRoomNavigate', () => ({ @@ -106,7 +107,7 @@ describe('IncomingCallInternal', () => { const onClose = vi.fn<() => void>(); render(); - fireEvent.click(screen.getByRole('button', { name: /decline/i })); + fireEvent.click(screen.getByRole('button', { name: 'Decline call' })); await waitFor(() => { expect(onClose).toHaveBeenCalledTimes(1); @@ -132,4 +133,34 @@ describe('IncomingCallInternal', () => { expect(screen.getByRole('button', { name: /answer voice call/i })).toBeDisabled(); }); + + it('ignores room call notifications without sending RTC decline', async () => { + const onClose = vi.fn<() => void>(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Ignore call notification' })); + + await waitFor(() => { + expect(onClose).toHaveBeenCalledTimes(1); + }); + expect(sendRtcDeclineMock).not.toHaveBeenCalled(); + }); + + it('shows homeserver capability issues and blocks answer when LiveKit is unavailable', () => { + livekitSupportedMock.mockReturnValue(false); + const onClose = vi.fn<() => void>(); + render(); + + expect( + screen.getByText(/homeserver does not expose a livekit call focus/i) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /answer voice call/i })).toBeDisabled(); + expect(screen.getByText(/homeserver call focus is unavailable/i)).toBeInTheDocument(); + }); }); diff --git a/src/app/features/call/callRingtone.test.ts b/src/app/features/call/callRingtone.test.ts index b12f20429..98173eb0e 100644 --- a/src/app/features/call/callRingtone.test.ts +++ b/src/app/features/call/callRingtone.test.ts @@ -5,6 +5,7 @@ import { clampCallRingtoneVolume, resolveIncomingCallToneUrl, resolveOutgoingRingbackToneUrl, + validateCustomCallRingtone, } from './callRingtone'; describe('callRingtone', () => { @@ -69,4 +70,42 @@ describe('callRingtone', () => { }) ).toBe(true); }); + + it('validates custom ringtone type, size, and duration constraints', () => { + expect( + validateCustomCallRingtone({ + fileName: 'ring.txt', + mimeType: 'text/plain', + sizeBytes: 10, + durationMs: 1_000, + }) + ).toEqual({ valid: false, reason: 'type' }); + + expect( + validateCustomCallRingtone({ + fileName: 'ring.ogg', + mimeType: 'audio/ogg', + sizeBytes: 9_999_999, + durationMs: 1_000, + }) + ).toEqual({ valid: false, reason: 'size' }); + + expect( + validateCustomCallRingtone({ + fileName: 'ring.ogg', + mimeType: 'audio/ogg', + sizeBytes: 10_000, + durationMs: 0, + }) + ).toEqual({ valid: false, reason: 'duration' }); + + expect( + validateCustomCallRingtone({ + fileName: 'ring.ogg', + mimeType: 'audio/ogg', + sizeBytes: 10_000, + durationMs: 4_000, + }) + ).toEqual({ valid: true }); + }); }); diff --git a/src/app/features/call/callRingtone.ts b/src/app/features/call/callRingtone.ts index b155138d9..d550789fa 100644 --- a/src/app/features/call/callRingtone.ts +++ b/src/app/features/call/callRingtone.ts @@ -112,3 +112,26 @@ export const readAudioDurationMs = async (file: Blob): Promise => audio.src = objectUrl; }); + +type CustomRingtoneValidationInput = { + fileName: string; + mimeType: string; + sizeBytes: number; + durationMs: number; +}; + +export const validateCustomCallRingtone = ( + input: CustomRingtoneValidationInput +): { valid: true } | { valid: false; reason: 'type' | 'size' | 'duration' } => { + if (!input.mimeType.startsWith('audio/')) { + return { valid: false, reason: 'type' }; + } + if (input.sizeBytes > CUSTOM_CALL_RINGTONE_MAX_BYTES) { + return { valid: false, reason: 'size' }; + } + if (input.durationMs <= 0 || input.durationMs > CUSTOM_CALL_RINGTONE_MAX_DURATION_MS) { + return { valid: false, reason: 'duration' }; + } + + return { valid: true }; +}; diff --git a/src/app/features/room/RoomCallButton.test.tsx b/src/app/features/room/RoomCallButton.test.tsx new file mode 100644 index 000000000..a4fc446e9 --- /dev/null +++ b/src/app/features/room/RoomCallButton.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { Room } from '$types/matrix-sdk'; +import { RoomCallButton } from './RoomCallButton'; + +const { startCallMock, useCallJoinedMock } = vi.hoisted(() => ({ + startCallMock: vi.fn(), + useCallJoinedMock: vi.fn(), +})); + +vi.mock('$hooks/useCallEmbed', () => ({ + useCallStart: () => startCallMock, + useCallJoined: () => useCallJoinedMock(), +})); + +vi.mock('jotai', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAtomValue: () => undefined, + }; +}); + +describe('RoomCallButton', () => { + const room = { roomId: '!room:example.org' } as Room; + + beforeEach(() => { + startCallMock.mockReset(); + useCallJoinedMock.mockReset().mockReturnValue(false); + }); + + it('opens a voice/video start menu', async () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /start call/i })); + + await waitFor(() => { + expect(screen.getByText('Voice Call')).toBeInTheDocument(); + }); + expect(screen.getByText('Video Call')).toBeInTheDocument(); + }); + + it('hides video start when video start is disabled', async () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /start call/i })); + + await waitFor(() => { + expect(screen.getByText('Voice Call')).toBeInTheDocument(); + }); + expect(screen.queryByText('Video Call')).toBeNull(); + }); + + it('starts the default mode on context-click', () => { + render( + + ); + + fireEvent.contextMenu(screen.getByRole('button', { name: /start call/i })); + + expect(startCallMock).toHaveBeenCalledWith(room, { + microphone: true, + video: false, + sound: true, + }); + }); +}); + diff --git a/src/app/features/settings/general/CallSoundSettings.test.tsx b/src/app/features/settings/general/CallSoundSettings.test.tsx new file mode 100644 index 000000000..de31d4193 --- /dev/null +++ b/src/app/features/settings/general/CallSoundSettings.test.tsx @@ -0,0 +1,49 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { CallSoundSettings } from './CallSoundSettings'; + +vi.mock('$state/settings', () => ({ + settingsAtom: {}, +})); + +vi.mock('$state/hooks/settings', () => ({ + useSetting: (_atom: unknown, key: string) => { + const values: Record = { + incomingCallSoundEnabled: true, + outgoingRingbackEnabled: true, + callRingtoneId: 'sable-default', + callRingbackTone: 'same-as-ringtone', + callRingtoneVolume: 80, + callSoundOverrideGlobalNotifications: false, + callCustomRingtoneName: undefined, + callCustomRingtoneSizeBytes: undefined, + callCustomRingtoneDurationMs: undefined, + }; + return [values[key], vi.fn()] as const; + }, +})); + +vi.mock('$features/call/callRingtoneStorage', () => ({ + getCustomCallRingtone: vi.fn(async () => undefined), + putCustomCallRingtone: vi.fn(), + clearCustomCallRingtone: vi.fn(), +})); + +describe('CallSoundSettings', () => { + it('renders expected call sound setting controls', async () => { + render(); + + expect(screen.getByText('Incoming Call Sound')).toBeInTheDocument(); + expect(screen.getByText('Outgoing Ringback Sound')).toBeInTheDocument(); + expect(screen.getByText('Ringtone')).toBeInTheDocument(); + expect(screen.getByText('Ringback Tone')).toBeInTheDocument(); + expect(screen.getByText('Ringtone Volume')).toBeInTheDocument(); + expect(screen.getByText('Always Play Call Sound')).toBeInTheDocument(); + expect(screen.getByText('Custom Ringtone')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText('No custom ringtone imported.')).toBeInTheDocument(); + }); + }); +}); + diff --git a/src/app/features/settings/general/CallSoundSettings.tsx b/src/app/features/settings/general/CallSoundSettings.tsx index d750a23fc..de62ac087 100644 --- a/src/app/features/settings/general/CallSoundSettings.tsx +++ b/src/app/features/settings/general/CallSoundSettings.tsx @@ -15,6 +15,7 @@ import { readAudioDurationMs, resolveIncomingCallToneUrl, resolveOutgoingRingbackToneUrl, + validateCustomCallRingtone, } from '$features/call/callRingtone'; import { clearCustomCallRingtone, @@ -186,20 +187,25 @@ export function CallSoundSettings() { const file = input.files?.[0]; if (!file) return; - if (!file.type.startsWith('audio/')) { - setCustomError('Only audio files are supported.'); - return; - } - if (file.size > CUSTOM_CALL_RINGTONE_MAX_BYTES) { - setCustomError( - `File is too large. Max ${bytesToSize(CUSTOM_CALL_RINGTONE_MAX_BYTES)} allowed.` - ); - return; - } - try { const durationMs = await readAudioDurationMs(file); - if (durationMs <= 0 || durationMs > CUSTOM_CALL_RINGTONE_MAX_DURATION_MS) { + const validation = validateCustomCallRingtone({ + fileName: file.name, + mimeType: file.type, + sizeBytes: file.size, + durationMs, + }); + if (!validation.valid) { + if (validation.reason === 'type') { + setCustomError('Only audio files are supported.'); + return; + } + if (validation.reason === 'size') { + setCustomError( + `File is too large. Max ${bytesToSize(CUSTOM_CALL_RINGTONE_MAX_BYTES)} allowed.` + ); + return; + } setCustomError( `Ringtone must be between 1s and ${millisecondsToMinutesAndSeconds( CUSTOM_CALL_RINGTONE_MAX_DURATION_MS diff --git a/src/app/pages/client/HandleNotificationClick.test.tsx b/src/app/pages/client/HandleNotificationClick.test.tsx new file mode 100644 index 000000000..0aecdd45f --- /dev/null +++ b/src/app/pages/client/HandleNotificationClick.test.tsx @@ -0,0 +1,113 @@ +import { render, waitFor } from '@testing-library/react'; +import { Provider, createStore } from 'jotai'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { HandleNotificationClick } from './ClientNonUIFeatures'; +import { pendingNotificationAtom, activeSessionIdAtom } from '$state/sessions'; +import { incomingCallAtom } from '$state/callEmbed'; +import { mDirectAtom } from '$state/mDirectList'; + +type TestServiceWorkerContainer = EventTarget & Partial; + +describe('HandleNotificationClick', () => { + let swContainer: TestServiceWorkerContainer; + + beforeEach(() => { + swContainer = new EventTarget() as TestServiceWorkerContainer; + Object.defineProperty(window.navigator, 'serviceWorker', { + configurable: true, + value: swContainer, + }); + }); + + it('stores pending notification and restores incoming call state from call click payload', async () => { + const store = createStore(); + store.set(mDirectAtom, { type: 'INITIALIZE', rooms: new Set(['!dm:example.org']) }); + + render( + + + + + + ); + + const now = Date.now(); + swContainer.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'notificationClick', + userId: '@me:example.org', + roomId: '!dm:example.org', + eventId: '$notif', + isCall: true, + callNotificationType: 'ring', + callIntentKind: 'video', + callIntentRaw: 'start_call_dm', + callRefEventId: '$ref', + callSenderId: '@alice:example.org', + callSenderTs: now, + callExpiresAt: now + 60_000, + }, + }) + ); + + await waitFor(() => { + expect(store.get(activeSessionIdAtom)).toBe('@me:example.org'); + expect(store.get(pendingNotificationAtom)).toEqual({ + roomId: '!dm:example.org', + eventId: '$notif', + targetSessionId: '@me:example.org', + }); + expect(store.get(incomingCallAtom)).toEqual( + expect.objectContaining({ + roomId: '!dm:example.org', + notificationEventId: '$notif', + refEventId: '$ref', + senderId: '@alice:example.org', + notificationType: 'ring', + intentKind: 'video', + isDirect: true, + }) + ); + }); + }); + + it('ignores expired call payloads while still navigating to the notification target', async () => { + const store = createStore(); + store.set(mDirectAtom, { type: 'INITIALIZE', rooms: new Set(['!dm:example.org']) }); + + render( + + + + + + ); + + const now = Date.now(); + swContainer.dispatchEvent( + new MessageEvent('message', { + data: { + type: 'notificationClick', + roomId: '!dm:example.org', + eventId: '$notif', + isCall: true, + callNotificationType: 'ring', + callSenderTs: now - 120_000, + callExpiresAt: now - 1, + }, + }) + ); + + await waitFor(() => { + expect(store.get(pendingNotificationAtom)).toEqual({ + roomId: '!dm:example.org', + eventId: '$notif', + targetSessionId: undefined, + }); + }); + expect(store.get(incomingCallAtom)).toBeNull(); + }); +}); + diff --git a/src/app/state/callEmbed.test.ts b/src/app/state/callEmbed.test.ts new file mode 100644 index 000000000..16024dd7e --- /dev/null +++ b/src/app/state/callEmbed.test.ts @@ -0,0 +1,46 @@ +import { createStore } from 'jotai'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { callEmbedAtom, callEmbedStartErrorAtom } from './callEmbed'; + +const distributionMock = vi.fn(); + +vi.mock('@sentry/react', () => ({ + metrics: { + distribution: (...args: unknown[]) => distributionMock(...args), + }, +})); + +describe('callEmbedAtom', () => { + beforeEach(() => { + distributionMock.mockReset(); + }); + + it('disposes previous embed when replaced', () => { + const store = createStore(); + const disposeA = vi.fn(); + const disposeB = vi.fn(); + const embedA = { dispose: disposeA } as unknown; + const embedB = { dispose: disposeB } as unknown; + + store.set(callEmbedAtom, embedA as never); + store.set(callEmbedAtom, embedB as never); + + expect(disposeA).toHaveBeenCalledTimes(1); + expect(disposeB).not.toHaveBeenCalled(); + expect(distributionMock).toHaveBeenCalledTimes(1); + }); + + it('clears start error when embed is removed', () => { + const store = createStore(); + const dispose = vi.fn(); + const embed = { dispose } as unknown; + + store.set(callEmbedStartErrorAtom, { code: 'prepare_failed', message: 'boom' } as never); + store.set(callEmbedAtom, embed as never); + store.set(callEmbedAtom, undefined); + + expect(dispose).toHaveBeenCalledTimes(1); + expect(store.get(callEmbedStartErrorAtom)).toBeNull(); + }); +}); + diff --git a/src/app/state/settings.defaults.test.ts b/src/app/state/settings.defaults.test.ts index d2b51cd56..3a39d9e17 100644 --- a/src/app/state/settings.defaults.test.ts +++ b/src/app/state/settings.defaults.test.ts @@ -31,6 +31,36 @@ describe('mergePersistedSettings', () => { const merged = mergePersistedSettings(localStorage.getItem('settings'), {}); expect(merged.saturationLevel).toBe(0); }); + + it('migrates persisted ringtone preferences to valid values', () => { + localStorage.setItem( + 'settings', + JSON.stringify({ + callRingtoneVolume: 140.2, + callRingtoneId: 'invalid-tone', + callRingbackTone: 'nope', + }) + ); + const merged = mergePersistedSettings(localStorage.getItem('settings'), {}); + expect(merged.callRingtoneVolume).toBe(100); + expect(merged.callRingtoneId).toBe(defaultSettings.callRingtoneId); + expect(merged.callRingbackTone).toBe(defaultSettings.callRingbackTone); + }); + + it('drops invalid custom ringtone metadata during migration', () => { + localStorage.setItem( + 'settings', + JSON.stringify({ + callCustomRingtoneName: 'tone.ogg', + callCustomRingtoneSizeBytes: -5, + callCustomRingtoneDurationMs: Number.NaN, + }) + ); + const merged = mergePersistedSettings(localStorage.getItem('settings'), {}); + expect(merged.callCustomRingtoneName).toBe('tone.ogg'); + expect(merged.callCustomRingtoneSizeBytes).toBeUndefined(); + expect(merged.callCustomRingtoneDurationMs).toBeNull(); + }); }); describe('sanitizeSettingsDefaults', () => { @@ -64,4 +94,25 @@ describe('sanitizeSettingsDefaults', () => { }); expect(sanitizeSettingsDefaults({ rightSwipeAction: 'nope' })).toEqual({}); }); + + it('sanitizes ringtone settings defaults', () => { + expect( + sanitizeSettingsDefaults({ + callRingtoneId: 'classic-soft', + callRingbackTone: 'default-ringback', + callRingtoneVolume: 73.7, + }) + ).toEqual({ + callRingtoneId: 'classic-soft', + callRingbackTone: 'default-ringback', + callRingtoneVolume: 74, + }); + expect( + sanitizeSettingsDefaults({ + callRingtoneId: 'bad', + callRingbackTone: 'bad', + callRingtoneVolume: Number.NaN, + }) + ).toEqual({}); + }); }); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index c89808700..d222a796e 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -385,6 +385,42 @@ function migrateParsedLocalStorage(parsed: Record): void { } delete parsed.themeChatPreviewAnyUrl; delete parsed.themeChatPreviewApprovedCatalogOnly; + + if (typeof parsed.callRingtoneVolume === 'number' && Number.isFinite(parsed.callRingtoneVolume)) { + parsed.callRingtoneVolume = Math.max(0, Math.min(100, Math.round(parsed.callRingtoneVolume))); + } + + if ( + parsed.callRingtoneId !== 'sable-default' && + parsed.callRingtoneId !== 'classic-soft' && + parsed.callRingtoneId !== 'minimal-ping' && + parsed.callRingtoneId !== 'silent' && + parsed.callRingtoneId !== 'custom' + ) { + delete parsed.callRingtoneId; + } + + if ( + parsed.callRingbackTone !== 'same-as-ringtone' && + parsed.callRingbackTone !== 'default-ringback' && + parsed.callRingbackTone !== 'silent' + ) { + delete parsed.callRingbackTone; + } + + if ( + typeof parsed.callCustomRingtoneSizeBytes === 'number' && + (!Number.isFinite(parsed.callCustomRingtoneSizeBytes) || parsed.callCustomRingtoneSizeBytes < 0) + ) { + delete parsed.callCustomRingtoneSizeBytes; + } + + if ( + typeof parsed.callCustomRingtoneDurationMs === 'number' && + (!Number.isFinite(parsed.callCustomRingtoneDurationMs) || parsed.callCustomRingtoneDurationMs < 0) + ) { + delete parsed.callCustomRingtoneDurationMs; + } } export function mergePersistedSettings( From 415ff779f0b20b19fc0d30a074de6433e29c6a75 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 17:01:10 -0500 Subject: [PATCH 10/27] changesets, lint, formatting --- .../call-signaling-notification-hardening.md | 5 ++++ .changeset/call-start-experience.md | 5 ++++ .changeset/custom-call-ringtones.md | 5 ++++ .changeset/incoming-call-modal-upgrade.md | 5 ++++ src/app/components/IncomingCallModal.test.tsx | 23 ++++++++------- src/app/components/IncomingCallModal.tsx | 2 +- .../message/content/ImageContent.tsx | 29 +++++++++---------- src/app/features/call/CallView.tsx | 4 ++- .../features/call/callNotificationBridge.ts | 15 ++++++++-- src/app/features/call/callRingtone.ts | 6 +--- .../features/call/callStartCapabilities.ts | 6 +--- .../call/rtcNotificationParser.test.ts | 11 ++++--- .../features/call/rtcNotificationParser.ts | 6 ++-- src/app/features/room/RoomCallButton.test.tsx | 10 +++---- src/app/features/room/RoomViewHeader.tsx | 16 +++++----- .../general/CallSoundSettings.test.tsx | 9 +++--- .../settings/general/CallSoundSettings.tsx | 7 +++-- src/app/hooks/useCallSignaling.ts | 7 ++++- .../client/HandleNotificationClick.test.tsx | 3 +- src/app/pages/client/ToRoomEvent.tsx | 11 ++++++- src/app/plugins/call/CallEmbed.intent.test.ts | 23 ++++++++------- src/app/plugins/call/CallEmbed.ts | 12 +++++--- src/app/plugins/call/CallWidgetDriver.ts | 4 +-- src/app/plugins/call/callEmbedError.ts | 9 +++++- .../call/elementCallDomAdapter.test.ts | 10 +++---- src/app/plugins/call/utils.test.ts | 24 ++++++++++----- src/app/plugins/markdown/markdownToHtml.ts | 2 +- src/app/state/callEmbed.test.ts | 9 +++--- src/app/state/settings.ts | 7 +++-- 29 files changed, 175 insertions(+), 110 deletions(-) create mode 100644 .changeset/call-signaling-notification-hardening.md create mode 100644 .changeset/call-start-experience.md create mode 100644 .changeset/custom-call-ringtones.md create mode 100644 .changeset/incoming-call-modal-upgrade.md diff --git a/.changeset/call-signaling-notification-hardening.md b/.changeset/call-signaling-notification-hardening.md new file mode 100644 index 000000000..d63a261e8 --- /dev/null +++ b/.changeset/call-signaling-notification-hardening.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Improved call signaling and notification handling to restore call state more reliably, reduce duplicate ringing, and handle expiry more safely. diff --git a/.changeset/call-start-experience.md b/.changeset/call-start-experience.md new file mode 100644 index 000000000..ea7888a34 --- /dev/null +++ b/.changeset/call-start-experience.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Added explicit voice/video call start options in room headers with more predictable DM/group join and start behavior. diff --git a/.changeset/custom-call-ringtones.md b/.changeset/custom-call-ringtones.md new file mode 100644 index 000000000..f5843c745 --- /dev/null +++ b/.changeset/custom-call-ringtones.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Added customizable call sounds in Settings with built-in ringtone choices, ringback behavior, volume control, and persistent custom ringtone import/reset. diff --git a/.changeset/incoming-call-modal-upgrade.md b/.changeset/incoming-call-modal-upgrade.md new file mode 100644 index 000000000..3dd61f735 --- /dev/null +++ b/.changeset/incoming-call-modal-upgrade.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Improved the incoming call modal with clearer caller/room context, better voice/video labeling, stronger keyboard support, and clearer decline vs ignore handling. diff --git a/src/app/components/IncomingCallModal.test.tsx b/src/app/components/IncomingCallModal.test.tsx index f9e74059c..608902ab3 100644 --- a/src/app/components/IncomingCallModal.test.tsx +++ b/src/app/components/IncomingCallModal.test.tsx @@ -3,12 +3,13 @@ import type { Room } from '$types/matrix-sdk'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { IncomingCallInternal } from './IncomingCallModal'; -const { navigateRoomMock, sendRtcDeclineMock, webRtcSupportedMock, livekitSupportedMock } = vi.hoisted(() => ({ - navigateRoomMock: vi.fn<(roomId: string) => void>(), - sendRtcDeclineMock: vi.fn<(roomId: string, eventId: string) => Promise>(), - webRtcSupportedMock: vi.fn<() => boolean>(), - livekitSupportedMock: vi.fn<() => boolean>(), -})); +const { navigateRoomMock, sendRtcDeclineMock, webRtcSupportedMock, livekitSupportedMock } = + vi.hoisted(() => ({ + navigateRoomMock: vi.fn<(roomId: string) => void>(), + sendRtcDeclineMock: vi.fn<(roomId: string, eventId: string) => Promise>(), + webRtcSupportedMock: vi.fn<() => boolean>(), + livekitSupportedMock: vi.fn<() => boolean>(), + })); vi.mock('$hooks/useMatrixClient', () => ({ useMatrixClient: () => ({ @@ -59,17 +60,17 @@ vi.mock('./user-avatar', () => ({ })); vi.mock('@sentry/react', () => ({ - addBreadcrumb: vi.fn(), + addBreadcrumb: vi.fn<(...args: unknown[]) => void>(), metrics: { - count: vi.fn(), + count: vi.fn<(...args: unknown[]) => void>(), }, })); vi.mock('$utils/debugLogger', () => ({ createDebugLogger: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), + info: vi.fn<(...args: unknown[]) => void>(), + warn: vi.fn<(...args: unknown[]) => void>(), + error: vi.fn<(...args: unknown[]) => void>(), }), })); diff --git a/src/app/components/IncomingCallModal.tsx b/src/app/components/IncomingCallModal.tsx index acb84ccaa..c29659fbd 100644 --- a/src/app/components/IncomingCallModal.tsx +++ b/src/app/components/IncomingCallModal.tsx @@ -107,7 +107,7 @@ export function IncomingCallInternal({ room, incomingCall, onClose }: IncomingCa issues.push({ id: 'permission', message: "You don't have permission to join this room's call.", - shortReason: "Missing permission to join this call.", + shortReason: 'Missing permission to join this call.', }); } if (inAnotherCall) { diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 13868c4bf..c6d43e672 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -142,24 +142,21 @@ export const ImageContent = as<'div', ImageContentProps>( ); useEffect(() => { + let cancelled = false; if (!viewer) { setViewerFullSrc(null); - return; - } - if ( - typeof matrixThumbnailMaxEdge !== 'number' || - matrixThumbnailMaxEdge <= 0 || - encInfo || - url.startsWith('http') + } else if ( + typeof matrixThumbnailMaxEdge === 'number' && + matrixThumbnailMaxEdge > 0 && + !encInfo && + !url.startsWith('http') ) { - return; + void (async () => { + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl || cancelled) return; + setViewerFullSrc(mediaUrl); + })(); } - let cancelled = false; - void (async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); - if (!mediaUrl || cancelled) return; - setViewerFullSrc(mediaUrl); - })(); return () => { cancelled = true; }; @@ -195,12 +192,14 @@ export const ImageContent = as<'div', ImageContentProps>( const rootClass = isContained ? css.ContainedMediaRoot : css.RelativeBase; const stripMin = containedStripMinPx ?? 56; + const imageWidth = info?.w; + const imageHeight = info?.h; const intrinsicSizingStyle = fillsSlot ? {} : isContained ? { minHeight: containedReserveStrip ? toRem(stripMin) : undefined } : hasDimensions - ? { aspectRatio: `${info!.w} / ${info!.h}` } + ? { aspectRatio: `${imageWidth} / ${imageHeight}` } : { minHeight: '150px' }; const fillPreviewSlotStyle = fillsSlot diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 43f976906..6109aeea1 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -130,7 +130,9 @@ function CallPrescreen() { ))} {callStartCapabilities.inAnotherCall && } {showEmbedError && ( - + )} diff --git a/src/app/features/call/callNotificationBridge.ts b/src/app/features/call/callNotificationBridge.ts index d8ba080a4..402f91706 100644 --- a/src/app/features/call/callNotificationBridge.ts +++ b/src/app/features/call/callNotificationBridge.ts @@ -1,4 +1,8 @@ -import type { IncomingCall, IncomingCallIntentKind, IncomingCallNotificationType } from '$state/callEmbed'; +import type { + IncomingCall, + IncomingCallIntentKind, + IncomingCallNotificationType, +} from '$state/callEmbed'; const MAX_CALL_NOTIFICATION_LIFETIME_MS = 120_000; @@ -68,7 +72,8 @@ export const resolveIncomingCallFromNotificationData = ( ): IncomingCall | undefined => { const roomId = typeof data.roomId === 'string' ? data.roomId : undefined; const eventId = typeof data.eventId === 'string' ? data.eventId : undefined; - const callType = typeof data.callNotificationType === 'string' ? data.callNotificationType : undefined; + const callType = + typeof data.callNotificationType === 'string' ? data.callNotificationType : undefined; if (!roomId || !eventId) return undefined; if (data.isCall !== true && !callType) return undefined; @@ -128,7 +133,11 @@ export const dismissSystemCallNotifications = async (roomId?: string): Promise { - if (!roomId || notification?.data?.room_id === roomId || notification?.data?.roomId === roomId) { + if ( + !roomId || + notification?.data?.room_id === roomId || + notification?.data?.roomId === roomId + ) { notification.close(); } }); diff --git a/src/app/features/call/callRingtone.ts b/src/app/features/call/callRingtone.ts index d550789fa..9023a453d 100644 --- a/src/app/features/call/callRingtone.ts +++ b/src/app/features/call/callRingtone.ts @@ -27,11 +27,7 @@ export const CALL_RINGBACK_OPTIONS: CallToneOption[] = [ { value: 'silent', label: 'Silent' }, ]; -type ToneSettings = Pick< - Settings, - | 'isNotificationSounds' - | 'callSoundOverrideGlobalNotifications' ->; +type ToneSettings = Pick; export const clampCallRingtoneVolume = (volume: number): number => Math.max(0, Math.min(100, Math.round(volume))); diff --git a/src/app/features/call/callStartCapabilities.ts b/src/app/features/call/callStartCapabilities.ts index 0c7c45913..c2f72a7a1 100644 --- a/src/app/features/call/callStartCapabilities.ts +++ b/src/app/features/call/callStartCapabilities.ts @@ -44,11 +44,7 @@ export const evaluateCallStartCapabilities = ({ if (inAnotherCall) blockers.push('already_in_another_call'); const canRenderCallButton = !blockers.some((blocker) => - [ - 'missing_webrtc', - 'missing_livekit', - 'missing_call_member_permission', - ].includes(blocker) + ['missing_webrtc', 'missing_livekit', 'missing_call_member_permission'].includes(blocker) ); return { diff --git a/src/app/features/call/rtcNotificationParser.test.ts b/src/app/features/call/rtcNotificationParser.test.ts index f1f9f94b4..de38f84c5 100644 --- a/src/app/features/call/rtcNotificationParser.test.ts +++ b/src/app/features/call/rtcNotificationParser.test.ts @@ -104,10 +104,13 @@ describe('parseIncomingRtcNotification', () => { }); it('ignores self-sent notifications', async () => { - const parsed = await parseIncomingRtcNotification(createEvent({ sender: '@self:example.org' }), { - myUserId: '@self:example.org', - now: NOW, - }); + const parsed = await parseIncomingRtcNotification( + createEvent({ sender: '@self:example.org' }), + { + myUserId: '@self:example.org', + now: NOW, + } + ); expect(parsed).toBeUndefined(); }); diff --git a/src/app/features/call/rtcNotificationParser.ts b/src/app/features/call/rtcNotificationParser.ts index 3165c201e..959ce52c5 100644 --- a/src/app/features/call/rtcNotificationParser.ts +++ b/src/app/features/call/rtcNotificationParser.ts @@ -89,7 +89,8 @@ export const parseIncomingRtcNotification = async ( const notificationType = toNotificationType(content.notification_type); if (typeof senderTsCandidate !== 'number') return undefined; - if (typeof lifetimeCandidate !== 'number' || !Number.isFinite(lifetimeCandidate)) return undefined; + if (typeof lifetimeCandidate !== 'number' || !Number.isFinite(lifetimeCandidate)) + return undefined; if (!notificationType) return undefined; const senderTs = getSenderTimestamp(senderTsCandidate, event.originServerTs); @@ -97,7 +98,8 @@ export const parseIncomingRtcNotification = async ( const expiresAt = senderTs + lifetime; if (options.now >= expiresAt) return undefined; - const intentRaw = typeof content['m.call.intent'] === 'string' ? content['m.call.intent'] : undefined; + const intentRaw = + typeof content['m.call.intent'] === 'string' ? content['m.call.intent'] : undefined; return { roomId: event.roomId, diff --git a/src/app/features/room/RoomCallButton.test.tsx b/src/app/features/room/RoomCallButton.test.tsx index a4fc446e9..3be930919 100644 --- a/src/app/features/room/RoomCallButton.test.tsx +++ b/src/app/features/room/RoomCallButton.test.tsx @@ -1,11 +1,12 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type * as JotaiModule from 'jotai'; import type { Room } from '$types/matrix-sdk'; import { RoomCallButton } from './RoomCallButton'; const { startCallMock, useCallJoinedMock } = vi.hoisted(() => ({ - startCallMock: vi.fn(), - useCallJoinedMock: vi.fn(), + startCallMock: vi.fn<(...args: unknown[]) => void>(), + useCallJoinedMock: vi.fn<() => boolean>(), })); vi.mock('$hooks/useCallEmbed', () => ({ @@ -13,8 +14,8 @@ vi.mock('$hooks/useCallEmbed', () => ({ useCallJoined: () => useCallJoinedMock(), })); -vi.mock('jotai', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('jotai', async (importOriginal: () => Promise) => { + const actual = await importOriginal(); return { ...actual, useAtomValue: () => undefined, @@ -82,4 +83,3 @@ describe('RoomCallButton', () => { }); }); }); - diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 889feab3f..9f2d660d6 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -28,7 +28,7 @@ import { useNavigate } from 'react-router-dom'; import type { Room, MatrixEvent } from '$types/matrix-sdk'; import { Direction, - EventTimeline, + type EventTimeline, NotificationCountType, ThreadEvent, RoomEvent, @@ -728,13 +728,13 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { {!room.isCallRoom() && callStartCapabilities.canRenderCallButton && shouldShowCallButton && ( - - )} + + )} ({ callCustomRingtoneSizeBytes: undefined, callCustomRingtoneDurationMs: undefined, }; - return [values[key], vi.fn()] as const; + return [values[key], vi.fn<(value: unknown) => void>()] as const; }, })); vi.mock('$features/call/callRingtoneStorage', () => ({ - getCustomCallRingtone: vi.fn(async () => undefined), - putCustomCallRingtone: vi.fn(), - clearCustomCallRingtone: vi.fn(), + getCustomCallRingtone: vi.fn<() => Promise>(async () => undefined), + putCustomCallRingtone: vi.fn<() => Promise>(), + clearCustomCallRingtone: vi.fn<() => Promise>(), })); describe('CallSoundSettings', () => { @@ -46,4 +46,3 @@ describe('CallSoundSettings', () => { }); }); }); - diff --git a/src/app/features/settings/general/CallSoundSettings.tsx b/src/app/features/settings/general/CallSoundSettings.tsx index de62ac087..4fe644130 100644 --- a/src/app/features/settings/general/CallSoundSettings.tsx +++ b/src/app/features/settings/general/CallSoundSettings.tsx @@ -62,7 +62,10 @@ export function CallSoundSettings() { ); const [callRingtoneId, setCallRingtoneId] = useSetting(settingsAtom, 'callRingtoneId'); const [callRingbackTone, setCallRingbackTone] = useSetting(settingsAtom, 'callRingbackTone'); - const [callRingtoneVolume, setCallRingtoneVolume] = useSetting(settingsAtom, 'callRingtoneVolume'); + const [callRingtoneVolume, setCallRingtoneVolume] = useSetting( + settingsAtom, + 'callRingtoneVolume' + ); const [callSoundOverrideGlobalNotifications, setCallSoundOverrideGlobalNotifications] = useSetting(settingsAtom, 'callSoundOverrideGlobalNotifications'); const [callCustomRingtoneName, setCallCustomRingtoneName] = useSetting( @@ -116,7 +119,7 @@ export function CallSoundSettings() { ? { ...option, label: callCustomRingtoneName ? 'Custom File (Imported)' : 'Custom File', - disabled: loadingCustomState ? true : false, + disabled: loadingCustomState, } : option ), diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index 7dcd9b2d8..baf2549c3 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -2,7 +2,12 @@ import { useCallback, useEffect, useRef } from 'react'; import * as Sentry from '@sentry/react'; import { useAtomValue, useSetAtom } from 'jotai'; import type { RoomEventHandlerMap, MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; -import { CryptoBackend, MatrixRTCSession, MatrixRTCSessionManagerEvents, RoomEvent } from '$types/matrix-sdk'; +import { + type CryptoBackend, + MatrixRTCSession, + MatrixRTCSessionManagerEvents, + RoomEvent, +} from '$types/matrix-sdk'; import { mDirectAtom } from '$state/mDirectList'; import { callSoundBlockedAtom, diff --git a/src/app/pages/client/HandleNotificationClick.test.tsx b/src/app/pages/client/HandleNotificationClick.test.tsx index 0aecdd45f..85eb4f641 100644 --- a/src/app/pages/client/HandleNotificationClick.test.tsx +++ b/src/app/pages/client/HandleNotificationClick.test.tsx @@ -1,7 +1,7 @@ import { render, waitFor } from '@testing-library/react'; import { Provider, createStore } from 'jotai'; import { MemoryRouter } from 'react-router-dom'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; import { HandleNotificationClick } from './ClientNonUIFeatures'; import { pendingNotificationAtom, activeSessionIdAtom } from '$state/sessions'; import { incomingCallAtom } from '$state/callEmbed'; @@ -110,4 +110,3 @@ describe('HandleNotificationClick', () => { expect(store.get(incomingCallAtom)).toBeNull(); }); }); - diff --git a/src/app/pages/client/ToRoomEvent.tsx b/src/app/pages/client/ToRoomEvent.tsx index a7c9b2141..e08f0de6c 100644 --- a/src/app/pages/client/ToRoomEvent.tsx +++ b/src/app/pages/client/ToRoomEvent.tsx @@ -45,7 +45,16 @@ export function ToRoomEvent() { // Replace /to/… in history so the back button doesn't return to this route. window.history.replaceState({}, '', '/'); - }, [eventId, mDirects, roomId, searchParams, setActiveSessionId, setIncomingCall, setPending, userId]); + }, [ + eventId, + mDirects, + roomId, + searchParams, + setActiveSessionId, + setIncomingCall, + setPending, + userId, + ]); return null; } diff --git a/src/app/plugins/call/CallEmbed.intent.test.ts b/src/app/plugins/call/CallEmbed.intent.test.ts index 983b8e78f..637760942 100644 --- a/src/app/plugins/call/CallEmbed.intent.test.ts +++ b/src/app/plugins/call/CallEmbed.intent.test.ts @@ -3,10 +3,10 @@ import { vi } from 'vitest'; vi.mock('../../utils/debugLogger', () => ({ createDebugLogger: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), + info: vi.fn<(...args: unknown[]) => void>(), + warn: vi.fn<(...args: unknown[]) => void>(), + error: vi.fn<(...args: unknown[]) => void>(), + debug: vi.fn<(...args: unknown[]) => void>(), }), })); @@ -20,6 +20,14 @@ type IntentCase = { expected: string; }; +function createRoom(isCallRoom: boolean) { + return { + roomId: '!room:example.com', + hasEncryptionStateEvent: () => false, + isCallRoom: () => isCallRoom, + } as never; +} + const intentCases: IntentCase[] = [ { dm: true, ongoing: false, video: true, expected: ElementCallIntent.StartCallDM }, { dm: true, ongoing: false, video: false, expected: ElementCallIntent.StartCallDMVoice }, @@ -89,13 +97,6 @@ describe('CallEmbed.getWidget', () => { getDeviceId: () => 'ALICEDEVICE', } as never; - const createRoom = (isCallRoom: boolean) => - ({ - roomId: '!room:example.com', - hasEncryptionStateEvent: () => false, - isCallRoom: () => isCallRoom, - }) as never; - it('adds ring notification delegation for starting DM calls in non-call rooms', () => { const room = createRoom(false); const widget = CallEmbed.getWidget(mx, room, ElementCallIntent.StartCallDMVoice, 'dark'); diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 02c31fbe0..752f465e5 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -112,10 +112,14 @@ export class CallEmbed { try { widgetUrl = new URL(elementCallUrl, window.location.origin); } catch (error) { - debugLog.warn('call', 'Invalid elementCallUrl in client config, falling back to bundled call app', { - elementCallUrl, - error: error instanceof Error ? error.message : String(error), - }); + debugLog.warn( + 'call', + 'Invalid elementCallUrl in client config, falling back to bundled call app', + { + elementCallUrl, + error: error instanceof Error ? error.message : String(error), + } + ); widgetUrl = new URL( `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`, window.location.origin diff --git a/src/app/plugins/call/CallWidgetDriver.ts b/src/app/plugins/call/CallWidgetDriver.ts index ad0a0a2ba..308e698f0 100644 --- a/src/app/plugins/call/CallWidgetDriver.ts +++ b/src/app/plugins/call/CallWidgetDriver.ts @@ -63,8 +63,8 @@ export class CallWidgetDriver extends WidgetDriver { }); } - const requestedSableCapabilities = ['moe.sable.thumbnails', 'moe.sable.media_proxy'].filter((cap) => - requested.has(cap) + const requestedSableCapabilities = ['moe.sable.thumbnails', 'moe.sable.media_proxy'].filter( + (cap) => requested.has(cap) ); debugLog.info('call', 'Sable-only capability request status', { roomId: this.inRoomId, diff --git a/src/app/plugins/call/callEmbedError.ts b/src/app/plugins/call/callEmbedError.ts index 4283bb18d..8c6f6ceca 100644 --- a/src/app/plugins/call/callEmbedError.ts +++ b/src/app/plugins/call/callEmbedError.ts @@ -9,7 +9,14 @@ const defaultPreparingMessage = 'Could not prepare the call embed.'; const capabilityMessage = 'Call start was blocked by capability negotiation.'; export const toCallEmbedStartError = (error: unknown): CallEmbedStartError => { - const rawMessage = error instanceof Error ? error.message : String(error ?? ''); + const rawMessage = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : error == null + ? '' + : JSON.stringify(error); const normalized = rawMessage.toLowerCase(); const looksLikeCapabilityError = normalized.includes('capabilit') || normalized.includes('org.matrix.msc'); diff --git a/src/app/plugins/call/elementCallDomAdapter.test.ts b/src/app/plugins/call/elementCallDomAdapter.test.ts index 01f322f42..5cba1ccfd 100644 --- a/src/app/plugins/call/elementCallDomAdapter.test.ts +++ b/src/app/plugins/call/elementCallDomAdapter.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it, vi } from 'vitest'; vi.mock('../../utils/debugLogger', () => ({ createDebugLogger: () => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), + info: vi.fn<(...args: unknown[]) => void>(), + warn: vi.fn<(...args: unknown[]) => void>(), + error: vi.fn<(...args: unknown[]) => void>(), + debug: vi.fn<(...args: unknown[]) => void>(), }), })); import { @@ -33,7 +33,7 @@ const createFakeElement = ( describe('elementCallDomAdapter', () => { it('finds call controls container from leave button ancestry', () => { const container = createFakeElement(); - const row = createFakeElement({ }, { parentElement: container }); + const row = createFakeElement({}, { parentElement: container }); const leave = createFakeElement({}, { parentElement: row }); const doc = { querySelector: (selector: string) => diff --git a/src/app/plugins/call/utils.test.ts b/src/app/plugins/call/utils.test.ts index c79665937..116466b71 100644 --- a/src/app/plugins/call/utils.test.ts +++ b/src/app/plugins/call/utils.test.ts @@ -35,8 +35,10 @@ describe('getCallCapabilities', () => { ).toBe(true); expect( capabilities.has( - WidgetEventCapability.forStateEvent(EventDirection.Receive, 'org.matrix.msc3401.call.member') - .raw + WidgetEventCapability.forStateEvent( + EventDirection.Receive, + 'org.matrix.msc3401.call.member' + ).raw ) ).toBe(true); }); @@ -46,24 +48,30 @@ describe('getCallCapabilities', () => { expect( capabilities.has( - WidgetEventCapability.forRoomEvent(EventDirection.Send, 'org.matrix.msc4075.rtc.notification') - .raw + WidgetEventCapability.forRoomEvent( + EventDirection.Send, + 'org.matrix.msc4075.rtc.notification' + ).raw ) ).toBe(true); expect( capabilities.has( - WidgetEventCapability.forRoomEvent(EventDirection.Receive, 'org.matrix.msc4075.rtc.notification') - .raw + WidgetEventCapability.forRoomEvent( + EventDirection.Receive, + 'org.matrix.msc4075.rtc.notification' + ).raw ) ).toBe(true); expect( capabilities.has( - WidgetEventCapability.forRoomEvent(EventDirection.Send, 'org.matrix.msc4310.rtc.decline').raw + WidgetEventCapability.forRoomEvent(EventDirection.Send, 'org.matrix.msc4310.rtc.decline') + .raw ) ).toBe(true); expect( capabilities.has( - WidgetEventCapability.forRoomEvent(EventDirection.Receive, 'org.matrix.msc4310.rtc.decline').raw + WidgetEventCapability.forRoomEvent(EventDirection.Receive, 'org.matrix.msc4310.rtc.decline') + .raw ) ).toBe(true); }); diff --git a/src/app/plugins/markdown/markdownToHtml.ts b/src/app/plugins/markdown/markdownToHtml.ts index 8bdc41200..58e3439e9 100644 --- a/src/app/plugins/markdown/markdownToHtml.ts +++ b/src/app/plugins/markdown/markdownToHtml.ts @@ -77,7 +77,7 @@ const shieldBareMatrixToLinks = ( const unshieldBareMatrixToLinks = (html: string, placeholders: Map): string => { let result = html; - const keys = [...placeholders.keys()].sort((a, b) => b.length - a.length); + const keys = [...placeholders.keys()].toSorted((a, b) => b.length - a.length); for (const key of keys) { const url = placeholders.get(key); if (url) result = result.split(key).join(escapeHtml(url)); diff --git a/src/app/state/callEmbed.test.ts b/src/app/state/callEmbed.test.ts index 16024dd7e..35e37d76a 100644 --- a/src/app/state/callEmbed.test.ts +++ b/src/app/state/callEmbed.test.ts @@ -2,7 +2,7 @@ import { createStore } from 'jotai'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { callEmbedAtom, callEmbedStartErrorAtom } from './callEmbed'; -const distributionMock = vi.fn(); +const distributionMock = vi.fn<(...args: unknown[]) => void>(); vi.mock('@sentry/react', () => ({ metrics: { @@ -17,8 +17,8 @@ describe('callEmbedAtom', () => { it('disposes previous embed when replaced', () => { const store = createStore(); - const disposeA = vi.fn(); - const disposeB = vi.fn(); + const disposeA = vi.fn<() => void>(); + const disposeB = vi.fn<() => void>(); const embedA = { dispose: disposeA } as unknown; const embedB = { dispose: disposeB } as unknown; @@ -32,7 +32,7 @@ describe('callEmbedAtom', () => { it('clears start error when embed is removed', () => { const store = createStore(); - const dispose = vi.fn(); + const dispose = vi.fn<() => void>(); const embed = { dispose } as unknown; store.set(callEmbedStartErrorAtom, { code: 'prepare_failed', message: 'boom' } as never); @@ -43,4 +43,3 @@ describe('callEmbedAtom', () => { expect(store.get(callEmbedStartErrorAtom)).toBeNull(); }); }); - diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index d222a796e..ae38d69a7 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -417,7 +417,8 @@ function migrateParsedLocalStorage(parsed: Record): void { if ( typeof parsed.callCustomRingtoneDurationMs === 'number' && - (!Number.isFinite(parsed.callCustomRingtoneDurationMs) || parsed.callCustomRingtoneDurationMs < 0) + (!Number.isFinite(parsed.callCustomRingtoneDurationMs) || + parsed.callCustomRingtoneDurationMs < 0) ) { delete parsed.callCustomRingtoneDurationMs; } @@ -549,7 +550,9 @@ function sanitizeSettingsKey(key: keyof Settings, val: unknown): unknown { return Math.max(0, Math.min(100, Math.round(val))); case 'callCustomRingtoneSizeBytes': case 'callCustomRingtoneDurationMs': - return typeof val === 'number' && Number.isFinite(val) && val >= 0 ? Math.round(val) : undefined; + return typeof val === 'number' && Number.isFinite(val) && val >= 0 + ? Math.round(val) + : undefined; case 'renderUserCards': return val === 'both' || val === 'light' || val === 'dark' || val === 'none' ? val From 9afae5434838cfe25054c70245a6c9ed9fd023f4 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 17:10:17 -0500 Subject: [PATCH 11/27] separate room call buttons and fix element call not clickable --- src/app/features/call/CallView.tsx | 1 + src/app/features/room/RoomCallButton.test.tsx | 41 ++-- src/app/features/room/RoomCallButton.tsx | 178 ++++-------------- src/app/features/room/RoomViewHeader.tsx | 21 ++- 4 files changed, 75 insertions(+), 166 deletions(-) diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 6109aeea1..49d727700 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -265,6 +265,7 @@ export function CallView({ resizable }: CallViewProps) { borderBottom: `1px solid var(--sable-surface-container-line)`, zIndex: 20, backgroundColor: currentJoined ? 'transparent' : undefined, + pointerEvents: currentJoined ? 'none' : 'all', }} > {isDragging && ( diff --git a/src/app/features/room/RoomCallButton.test.tsx b/src/app/features/room/RoomCallButton.test.tsx index 3be930919..7f4ce6ae6 100644 --- a/src/app/features/room/RoomCallButton.test.tsx +++ b/src/app/features/room/RoomCallButton.test.tsx @@ -30,56 +30,59 @@ describe('RoomCallButton', () => { useCallJoinedMock.mockReset().mockReturnValue(false); }); - it('opens a voice/video start menu', async () => { + it('starts a voice call from the voice button', async () => { render( ); - fireEvent.click(screen.getByRole('button', { name: /start call/i })); + fireEvent.click(screen.getByRole('button', { name: /start voice call/i })); await waitFor(() => { - expect(screen.getByText('Voice Call')).toBeInTheDocument(); + expect(startCallMock).toHaveBeenCalledWith(room, { + microphone: true, + video: false, + sound: true, + }); }); - expect(screen.getByText('Video Call')).toBeInTheDocument(); }); - it('hides video start when video start is disabled', async () => { + it('starts a video call from the video button', async () => { render( ); - fireEvent.click(screen.getByRole('button', { name: /start call/i })); + fireEvent.click(screen.getByRole('button', { name: /start video call/i })); await waitFor(() => { - expect(screen.getByText('Voice Call')).toBeInTheDocument(); + expect(startCallMock).toHaveBeenCalledWith(room, { + microphone: true, + video: true, + sound: true, + }); }); - expect(screen.queryByText('Video Call')).toBeNull(); }); - it('starts the default mode on context-click', () => { + it('hides video button when video start is disabled', () => { render( ); - fireEvent.contextMenu(screen.getByRole('button', { name: /start call/i })); - - expect(startCallMock).toHaveBeenCalledWith(room, { - microphone: true, - video: false, - sound: true, - }); + expect(screen.queryByRole('button', { name: /start video call/i })).toBeNull(); }); }); diff --git a/src/app/features/room/RoomCallButton.tsx b/src/app/features/room/RoomCallButton.tsx index d17b75997..43840cf01 100644 --- a/src/app/features/room/RoomCallButton.tsx +++ b/src/app/features/room/RoomCallButton.tsx @@ -1,181 +1,77 @@ -import type { MouseEventHandler } from 'react'; -import { useState } from 'react'; -import FocusTrap from 'focus-trap-react'; -import type { RectCords } from 'folds'; -import { - Box, - IconButton, - Icon, - Icons, - TooltipProvider, - Tooltip, - Text, - PopOut, - Menu, - MenuItem, - config, -} from 'folds'; +import { IconButton, Icon, Icons, TooltipProvider, Tooltip, Text } from 'folds'; import { useAtomValue } from 'jotai'; import type { Room } from '$types/matrix-sdk'; import { useCallStart, useCallJoined } from '$hooks/useCallEmbed'; import type { CallPreferences } from '$state/callPreferences'; import { callEmbedAtom } from '$state/callEmbed'; -import { stopPropagation } from '$utils/keyboard'; interface RoomCallButtonProps { room: Room; direct: boolean; defaultPreferences: CallPreferences; + kind: 'voice' | 'video'; allowVideoStart?: boolean; } -type CallStartMenuProps = { - onVoiceCall: () => void; - onVideoCall: () => void; - requestClose: () => void; - allowVideoStart: boolean; -}; - -function CallStartMenu({ - onVoiceCall, - onVideoCall, - requestClose, - allowVideoStart, -}: CallStartMenuProps) { - return ( - - - - - Voice Call - - - {allowVideoStart && ( - - - Video Call - - - )} - - - Cancel - - - - - ); -} - export function RoomCallButton({ room, direct, defaultPreferences, + kind, allowVideoStart = true, }: RoomCallButtonProps) { const startCall = useCallStart(direct); const callEmbed = useAtomValue(callEmbedAtom); const joined = useCallJoined(callEmbed); - const [menuAnchor, setMenuAnchor] = useState(); const isJoinedInThisRoom = joined && callEmbed?.roomId === room.roomId; const callStartingInThisRoom = !!callEmbed && callEmbed.roomId === room.roomId && !joined; const inAnotherCall = !!callEmbed && callEmbed.roomId !== room.roomId; const startDisabled = inAnotherCall || callStartingInThisRoom; + const startingVideoCall = kind === 'video'; + if (kind === 'video' && !allowVideoStart) return null; if (isJoinedInThisRoom) return null; - const startVoiceCall = () => { - startCall(room, { - microphone: defaultPreferences.microphone, - video: false, - sound: defaultPreferences.sound, - }); - setMenuAnchor(undefined); - }; - - const startVideoCall = () => { + const startSelectedCall = () => { startCall(room, { microphone: defaultPreferences.microphone, - video: true, + video: startingVideoCall, sound: defaultPreferences.sound, }); - setMenuAnchor(undefined); }; - const startDefaultCall = () => { - const resolvedVideo = allowVideoStart ? defaultPreferences.video : false; - startCall(room, { - microphone: defaultPreferences.microphone, - video: resolvedVideo, - sound: defaultPreferences.sound, - }); - }; - - const handleOpenMenu: MouseEventHandler = (evt) => { - setMenuAnchor(evt.currentTarget.getBoundingClientRect()); - }; + const readyCopy = startingVideoCall ? 'Start Video Call' : 'Start Voice Call'; + const ariaLabel = startingVideoCall ? 'Start Video Call' : 'Start Voice Call'; + const icon = startingVideoCall ? Icons.VideoCamera : Icons.Phone; return ( - <> - - {inAnotherCall ? ( - Already in another call - ) : callStartingInThisRoom ? ( - Call is starting - ) : ( - Start Call - )} - - } - > - {(triggerRef) => ( - { - evt.preventDefault(); - if (startDisabled) return; - startDefaultCall(); - }} - disabled={startDisabled} - aria-label="Start Call" - aria-pressed={!!menuAnchor} - > - - - )} - - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} - allowVideoStart={allowVideoStart} - /> - - } - /> - + + {inAnotherCall ? ( + Already in another call + ) : callStartingInThisRoom ? ( + Call is starting + ) : ( + {readyCopy} + )} + + } + > + {(triggerRef) => ( + + + + )} + ); } diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 9f2d660d6..850a3dd9d 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -728,12 +728,21 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { {!room.isCallRoom() && callStartCapabilities.canRenderCallButton && shouldShowCallButton && ( - + <> + + + )} Date: Thu, 14 May 2026 17:38:45 -0500 Subject: [PATCH 12/27] fix some ringtone things --- src/app/hooks/useCallEmbed.ts | 12 ++- src/app/hooks/useCallSignaling.ts | 147 ++++++++++++++-------------- src/app/plugins/call/CallControl.ts | 141 +++++++++++++++++++++++++- src/app/plugins/call/CallEmbed.ts | 6 +- 4 files changed, 227 insertions(+), 79 deletions(-) diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts index edcc55738..e8248372f 100644 --- a/src/app/hooks/useCallEmbed.ts +++ b/src/app/hooks/useCallEmbed.ts @@ -55,8 +55,16 @@ export const createCallEmbed = ( const intent = CallEmbed.getIntent(dm, ongoing, pref?.video); const widget = CallEmbed.getWidget(mx, room, intent, themeKind, elementCallUrl); const controlState = pref && new CallControlState(pref.microphone, pref.video, pref.sound); - - const embed = new CallEmbed(mx, room, widget, container, controlState); + const suppressOutgoingPickupSound = CallEmbed.startingCall(intent); + + const embed = new CallEmbed( + mx, + room, + widget, + container, + controlState, + suppressOutgoingPickupSound + ); return embed; }; diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index baf2549c3..90cbe3d90 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -10,6 +10,7 @@ import { } from '$types/matrix-sdk'; import { mDirectAtom } from '$state/mDirectList'; import { + callEmbedAtom, callSoundBlockedAtom, incomingCallAtom, mutedCallRoomIdAtom, @@ -76,6 +77,22 @@ const isCallActive = ( return selfMember && remoteMembers.length > 0; }; +const isOutgoingCallPending = ( + mxUserId: string, + room: Room, + sessionDescription: SessionDescription +): boolean => { + const memberships = getRoomMemberships(room, sessionDescription); + const remoteMembers = memberships.filter( + (m: { userId?: string; sender?: string }) => (m.userId || m.sender) !== mxUserId + ); + const selfMember = memberships.some( + (m: { userId?: string; sender?: string }) => (m.userId || m.sender) === mxUserId + ); + + return selfMember && remoteMembers.length === 0; +}; + const decryptWithTimeout = async ( event: MatrixEvent, mx: MatrixClient @@ -118,6 +135,7 @@ const canSenderStartCalls = (room: Room, senderId: string): boolean => export function useIncomingCallSignaling() { const mx = useMatrixClient(); + const callEmbed = useAtomValue(callEmbedAtom); const mDirects = useAtomValue(mDirectAtom); const settings = useAtomValue(settingsAtom); const incomingCall = useAtomValue(incomingCallAtom); @@ -224,9 +242,12 @@ export function useIncomingCallSignaling() { const stopOutgoingRing = useCallback(() => { outgoingAudioRef.current?.pause(); if (outgoingAudioRef.current) outgoingAudioRef.current.currentTime = 0; + if (callEmbed) { + callEmbed.control.setOutputOverrideMuted(false); + } outgoingRingRoomIdRef.current = null; outgoingStartRef.current = null; - }, []); + }, [callEmbed]); const clearIncomingCall = useCallback(() => { const activeIncomingCall = incomingCallRef.current; @@ -282,31 +303,20 @@ export function useIncomingCallSignaling() { }, }); - if (nextIncomingCall.notificationType === 'ring') { - const appVisible = document.visibilityState === 'visible'; - if (!appVisible) { - stopIncomingRing(); - setCallSoundBlocked(false); - return; - } - - if (!incomingRingtoneAllowed) { - stopIncomingRing(); - return; - } - - incomingAudioRef.current - ?.play() - .then(() => { - setCallSoundBlocked(false); - }) - .catch(() => { - setCallSoundBlocked(true); - Sentry.metrics.count('sable.call.ringtone.blocked', 1); - }); - } else { + if (!incomingRingtoneAllowed) { stopIncomingRing(); + return; } + + incomingAudioRef.current + ?.play() + .then(() => { + setCallSoundBlocked(false); + }) + .catch(() => { + setCallSoundBlocked(true); + Sentry.metrics.count('sable.call.ringtone.blocked', 1); + }); }, [incomingRingtoneAllowed, setCallSoundBlocked, setIncomingCall, stopIncomingRing] ); @@ -398,6 +408,8 @@ export function useIncomingCallSignaling() { }; const evaluateFallbackState = () => { + const now = Date.now(); + const currentIncoming = incomingCallRef.current; if (currentIncoming) { if (Date.now() >= currentIncoming.expiresAt) { @@ -418,6 +430,11 @@ export function useIncomingCallSignaling() { const session = mx.matrixRTC.getRoomSession(incomingRoom); if (!isIncomingCallActive(myUserId, incomingRoom, session.sessionDescription)) { + // Session membership can lag behind live RTC notification delivery. + // Keep ringing for a short grace window before treating the call as ended. + if (now - currentIncoming.senderTs < 15_000) { + return; + } debugLog.info('call', 'Incoming call cleared after membership drop', { roomId: currentIncoming.roomId, }); @@ -426,61 +443,42 @@ export function useIncomingCallSignaling() { } } - const outgoingRoomId = outgoingRingRoomIdRef.current; - if (outgoingRoomId) { - const outgoingRoom = mx.getRoom(outgoingRoomId); - if (!outgoingRoom) { - stopOutgoingRing(); - return; - } - const session = mx.matrixRTC.getRoomSession(outgoingRoom); - if (isCallActive(myUserId, outgoingRoom, session.sessionDescription)) { - stopOutgoingRing(); - return; - } + const activeCallRoomId = callEmbed?.roomId; + if (!activeCallRoomId || !outgoingRingbackAllowed) { + stopOutgoingRing(); + return; } - if (outgoingRingRoomIdRef.current) return; + const outgoingRoom = mx.getRoom(activeCallRoomId); + if (!outgoingRoom) { + stopOutgoingRing(); + return; + } - const now = Date.now(); - const localUserId = mx.getUserId(); - if (!localUserId) return; - - for (const roomId of mDirects) { - if (mutedRoomIdRef.current === roomId) continue; - - const room = mx.getRoom(roomId); - if (!room) continue; - - const session = mx.matrixRTC.getRoomSession(room); - const memberships = getRoomMemberships(room, session.sessionDescription); - const remoteMembers = memberships.filter( - (m: { userId?: string; sender?: string }) => (m.userId || m.sender) !== localUserId - ); - const selfMember = memberships.some( - (m: { userId?: string; sender?: string }) => (m.userId || m.sender) === localUserId - ); - - if (selfMember && remoteMembers.length === 0) { - if (!outgoingStartRef.current) outgoingStartRef.current = now; - if (now - outgoingStartRef.current < OUTGOING_RING_TIMEOUT_MS) { - if (outgoingRingRoomIdRef.current !== roomId) { - if (outgoingRingbackAllowed) { - outgoingAudioRef.current?.play().catch(() => { - Sentry.metrics.count('sable.call.ringback.blocked', 1); - }); - } - outgoingRingRoomIdRef.current = roomId; - debugLog.info('call', 'Outgoing ringing fallback started', { roomId }); - } - } else { - stopOutgoingRing(); - } - return; - } + const session = mx.matrixRTC.getRoomSession(outgoingRoom); + const pendingOutgoing = isOutgoingCallPending(myUserId, outgoingRoom, session.sessionDescription); + const activeCall = isCallActive(myUserId, outgoingRoom, session.sessionDescription); + + if (!pendingOutgoing || activeCall) { + stopOutgoingRing(); + return; } - stopOutgoingRing(); + if (outgoingRingRoomIdRef.current !== activeCallRoomId) { + outgoingRingRoomIdRef.current = activeCallRoomId; + outgoingStartRef.current = now; + debugLog.info('call', 'Outgoing ringing fallback started', { roomId: activeCallRoomId }); + } + + if (outgoingStartRef.current && now - outgoingStartRef.current >= OUTGOING_RING_TIMEOUT_MS) { + stopOutgoingRing(); + return; + } + + callEmbed.control.setOutputOverrideMuted(true); + outgoingAudioRef.current?.play().catch(() => { + Sentry.metrics.count('sable.call.ringback.blocked', 1); + }); }; const handleSessionEnded = (roomId: string) => { @@ -504,6 +502,7 @@ export function useIncomingCallSignaling() { stopOutgoingRing(); }; }, [ + callEmbed, mx, mDirects, outgoingRingbackAllowed, diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index f8c4e9816..43071bf0b 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -24,6 +24,12 @@ export class CallControl extends EventEmitter implements CallControlState { private iframe: HTMLIFrameElement; private controlMutationObserver: MutationObserver; + private audioMutationObserver: MutationObserver; + private outputOverrideMuted = false; + private patchedWindow: Window | undefined; + private readonly trackedAudioContexts = new Set(); + private readonly runningContextsBeforeOverride = new WeakMap(); + private readonly audioPatchRestores: Array<() => void> = []; private get document(): Document | undefined { return this.iframe.contentDocument ?? this.iframe.contentWindow?.document; @@ -57,6 +63,9 @@ export class CallControl extends EventEmitter implements CallControlState { this.iframe = iframe; this.controlMutationObserver = new MutationObserver(this.onControlMutation.bind(this)); + this.audioMutationObserver = new MutationObserver(() => { + this.applyOutputMute(); + }); } public getState(): CallControlState { @@ -117,19 +126,144 @@ export class CallControl extends EventEmitter implements CallControlState { this.setSound(this.sound); } + public setOutputOverrideMuted(muted: boolean) { + const win = this.iframe.contentWindow; + const callDocument = this.iframe.contentDocument ?? this.iframe.contentWindow?.document; + const windowChanged = !!win && this.patchedWindow !== win; + if (this.outputOverrideMuted === muted && !windowChanged) return; + this.outputOverrideMuted = muted; + + if (muted) { + const target = callDocument?.body; + if (target) { + this.audioMutationObserver.observe(target, { + childList: true, + subtree: true, + }); + } + if (win) { + this.ensureAudioPatches(win); + this.collectExistingAudioContexts(win); + this.suspendTrackedAudioContexts(); + } + } else { + this.audioMutationObserver.disconnect(); + this.resumeTrackedAudioContexts(); + this.teardownAudioPatches(); + } + this.applyOutputMute(); + } + private setMediaState(state: ElementMediaStatePayload) { return this.call.transport.send(ElementWidgetActions.DeviceMute, state); } private setSound(sound: boolean): void { + this.applyOutputMute(sound); + } + + private applyOutputMute(sound = this.sound): void { const callDocument = this.iframe.contentDocument ?? this.iframe.contentWindow?.document; + const shouldMute = this.outputOverrideMuted || !sound; if (callDocument) { - callDocument.querySelectorAll('audio').forEach((el) => { - el.muted = !sound; + callDocument.querySelectorAll('audio, video').forEach((el) => { + el.muted = shouldMute; + if (shouldMute) el.volume = 0; }); } } + private ensureAudioPatches(win: Window): void { + if (this.patchedWindow === win) return; + this.teardownAudioPatches(); + this.patchedWindow = win; + + this.patchAudioContextConstructor(win, 'AudioContext'); + this.patchAudioContextConstructor(win, 'webkitAudioContext'); + } + + private patchAudioContextConstructor(win: Window, key: 'AudioContext' | 'webkitAudioContext') { + const scopedWindow = win as Window & + Partial>; + const originalCtor = scopedWindow[key]; + if (typeof originalCtor !== 'function') return; + + const originalResume = originalCtor.prototype.resume; + const trackedContexts = this.trackedAudioContexts; + const isOverrideMuted = () => this.outputOverrideMuted; + originalCtor.prototype.resume = function patchedResume(this: AudioContext) { + trackedContexts.add(this); + if (isOverrideMuted()) { + return Promise.resolve(); + } + return originalResume.call(this); + }; + this.audioPatchRestores.push(() => { + originalCtor.prototype.resume = originalResume; + }); + + const wrappedCtor = function patchedAudioContext( + this: unknown, + ...args: ConstructorParameters + ) { + const context = Reflect.construct( + originalCtor, + args, + new.target ?? originalCtor + ) as AudioContext; + trackedContexts.add(context); + if (isOverrideMuted()) { + void context.suspend().catch(() => {}); + } + return context; + } as unknown as typeof AudioContext; + wrappedCtor.prototype = originalCtor.prototype; + Object.setPrototypeOf(wrappedCtor, originalCtor); + + scopedWindow[key] = wrappedCtor; + this.audioPatchRestores.push(() => { + scopedWindow[key] = originalCtor; + }); + } + + private teardownAudioPatches(): void { + this.audioPatchRestores.splice(0).forEach((restore) => restore()); + this.patchedWindow = undefined; + } + + private collectExistingAudioContexts(win: Window): void { + const scopedWindow = win as Window & + Partial>; + const audioCtor = scopedWindow.AudioContext; + if (!audioCtor) return; + + Object.values(win as unknown as Record).forEach((value) => { + if (value instanceof audioCtor) { + this.trackedAudioContexts.add(value); + } + }); + } + + private suspendTrackedAudioContexts(): void { + this.trackedAudioContexts.forEach((context) => { + const wasRunning = context.state === 'running'; + this.runningContextsBeforeOverride.set(context, wasRunning); + if (wasRunning) { + void context.suspend().catch(() => {}); + } + }); + } + + private resumeTrackedAudioContexts(): void { + this.trackedAudioContexts.forEach((context) => { + const wasRunning = this.runningContextsBeforeOverride.get(context) ?? false; + if (wasRunning) { + void context.resume().catch(() => {}); + } + this.runningContextsBeforeOverride.delete(context); + }); + } + public onMediaState(evt: CustomEvent) { const { data } = evt.detail; if (!data) return; @@ -222,6 +356,9 @@ export class CallControl extends EventEmitter implements CallControlState { public dispose() { this.controlMutationObserver.disconnect(); + this.audioMutationObserver.disconnect(); + this.resumeTrackedAudioContexts(); + this.teardownAudioPatches(); } private emitStateUpdate() { diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 752f465e5..96cb85167 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -169,7 +169,8 @@ export class CallEmbed { room: Room, widget: Widget, container: HTMLElement, - initialControlState?: CallControlState + initialControlState?: CallControlState, + suppressOutgoingPickupSound = false ) { debugLog.info('call', 'Initializing call embed', { roomId: room.roomId }); @@ -189,6 +190,9 @@ export class CallEmbed { const controlState = initialControlState ?? new CallControlState(true, false, true); this.control = new CallControl(controlState, call, iframe); + if (suppressOutgoingPickupSound) { + this.control.setOutputOverrideMuted(true); + } this.disposables.push( this.listenAction(WidgetApiFromWidgetAction.UpdateAlwaysOnScreen, (evt) => { From a553d4eac64ed26abe1a1f065e8b2f955cc3038f Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 17:47:34 -0500 Subject: [PATCH 13/27] copy ringtone options for ringback --- src/app/features/call/callRingtone.test.ts | 11 +- src/app/features/call/callRingtone.ts | 22 +- src/app/features/call/callRingtoneStorage.ts | 32 ++- .../general/CallSoundSettings.test.tsx | 10 +- .../settings/general/CallSoundSettings.tsx | 246 ++++++++++++++++-- src/app/hooks/useCallSignaling.ts | 33 ++- src/app/state/settings.defaults.test.ts | 26 +- src/app/state/settings.ts | 47 +++- src/app/utils/settingsSync.test.ts | 3 + src/app/utils/settingsSync.ts | 3 + 10 files changed, 375 insertions(+), 58 deletions(-) diff --git a/src/app/features/call/callRingtone.test.ts b/src/app/features/call/callRingtone.test.ts index 98173eb0e..5225cfff8 100644 --- a/src/app/features/call/callRingtone.test.ts +++ b/src/app/features/call/callRingtone.test.ts @@ -36,13 +36,20 @@ describe('callRingtone', () => { it('resolves outgoing ringback modes', () => { expect( resolveOutgoingRingbackToneUrl( - { callRingbackTone: 'same-as-ringtone', callRingtoneId: 'minimal-ping' }, + { callRingbackTone: 'minimal-ping', callRingtoneId: 'minimal-ping' }, 'blob:https://example.test/custom' ) ).toContain('/public/sound/notification.ogg'); expect( resolveOutgoingRingbackToneUrl( - { callRingbackTone: 'same-as-ringtone', callRingtoneId: 'custom' }, + { callRingbackTone: 'custom', callRingtoneId: 'custom' }, + 'blob:https://example.test/custom', + 'blob:https://example.test/ringback-custom' + ) + ).toBe('blob:https://example.test/ringback-custom'); + expect( + resolveOutgoingRingbackToneUrl( + { callRingbackTone: 'custom', callRingtoneId: 'custom' }, 'blob:https://example.test/custom' ) ).toBe('blob:https://example.test/custom'); diff --git a/src/app/features/call/callRingtone.ts b/src/app/features/call/callRingtone.ts index 9023a453d..b35dd378b 100644 --- a/src/app/features/call/callRingtone.ts +++ b/src/app/features/call/callRingtone.ts @@ -22,9 +22,11 @@ export const CALL_RINGTONE_OPTIONS: CallToneOption[] = [ ]; export const CALL_RINGBACK_OPTIONS: CallToneOption[] = [ - { value: 'same-as-ringtone', label: 'Same As Ringtone' }, - { value: 'default-ringback', label: 'Default Ringback' }, - { value: 'silent', label: 'Silent' }, + { value: 'sable-default', label: 'Sable Default' }, + { value: 'classic-soft', label: 'Classic Soft Ring' }, + { value: 'minimal-ping', label: 'Minimal Ping Loop' }, + { value: 'silent', label: 'Silent (Visual Only)' }, + { value: 'custom', label: 'Custom File' }, ]; type ToneSettings = Pick; @@ -66,15 +68,17 @@ export const resolveIncomingCallToneUrl = ( export const resolveOutgoingRingbackToneUrl = ( settings: Pick, - customRingtoneUrl?: string + customRingtoneUrl?: string, + customRingbackUrl?: string ): string | null => { + if (settings.callRingbackTone === 'custom') { + return customRingbackUrl ?? resolveIncomingCallToneUrl(settings, customRingtoneUrl); + } if (settings.callRingbackTone === 'silent') return null; - - if (settings.callRingbackTone === 'default-ringback') { - return InviteSound; + if (settings.callRingbackTone === settings.callRingtoneId) { + return resolveIncomingCallToneUrl(settings, customRingtoneUrl); } - - return resolveIncomingCallToneUrl(settings, customRingtoneUrl); + return resolveBuiltInTone(settings.callRingbackTone); }; export const readAudioDurationMs = async (file: Blob): Promise => diff --git a/src/app/features/call/callRingtoneStorage.ts b/src/app/features/call/callRingtoneStorage.ts index e5ddac8e4..b0a9605fd 100644 --- a/src/app/features/call/callRingtoneStorage.ts +++ b/src/app/features/call/callRingtoneStorage.ts @@ -2,6 +2,7 @@ const DB_NAME = 'sable-call-audio'; const DB_VERSION = 1; const STORE = 'ringtones'; const CUSTOM_RINGTONE_KEY = 'custom-ringtone'; +const CUSTOM_RINGBACK_KEY = 'custom-ringback'; export type StoredCallRingtone = { id: string; @@ -24,13 +25,14 @@ function openDb(): Promise { }); } -export async function putCustomCallRingtone( +async function putCustomCallAudio( + key: string, file: File, durationMs: number ): Promise { const db = await openDb(); const entry: StoredCallRingtone = { - id: CUSTOM_RINGTONE_KEY, + id: key, fileName: file.name, mimeType: file.type, sizeBytes: file.size, @@ -49,11 +51,11 @@ export async function putCustomCallRingtone( return entry; } -export async function getCustomCallRingtone(): Promise { +async function getCustomCallAudio(key: string): Promise { const db = await openDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readonly'); - const req = tx.objectStore(STORE).get(CUSTOM_RINGTONE_KEY); + const req = tx.objectStore(STORE).get(key); req.addEventListener('error', () => reject(req.error)); req.addEventListener('success', () => { resolve(req.result as StoredCallRingtone | undefined); @@ -61,12 +63,30 @@ export async function getCustomCallRingtone(): Promise { +async function clearCustomCallAudio(key: string): Promise { const db = await openDb(); await new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite'); - tx.objectStore(STORE).delete(CUSTOM_RINGTONE_KEY); + tx.objectStore(STORE).delete(key); tx.addEventListener('complete', () => resolve()); tx.addEventListener('error', () => reject(tx.error)); }); } + +export const putCustomCallRingtone = (file: File, durationMs: number): Promise => + putCustomCallAudio(CUSTOM_RINGTONE_KEY, file, durationMs); + +export const getCustomCallRingtone = (): Promise => + getCustomCallAudio(CUSTOM_RINGTONE_KEY); + +export const clearCustomCallRingtone = (): Promise => + clearCustomCallAudio(CUSTOM_RINGTONE_KEY); + +export const putCustomCallRingback = (file: File, durationMs: number): Promise => + putCustomCallAudio(CUSTOM_RINGBACK_KEY, file, durationMs); + +export const getCustomCallRingback = (): Promise => + getCustomCallAudio(CUSTOM_RINGBACK_KEY); + +export const clearCustomCallRingback = (): Promise => + clearCustomCallAudio(CUSTOM_RINGBACK_KEY); diff --git a/src/app/features/settings/general/CallSoundSettings.test.tsx b/src/app/features/settings/general/CallSoundSettings.test.tsx index 8bd6731a9..b3aee782e 100644 --- a/src/app/features/settings/general/CallSoundSettings.test.tsx +++ b/src/app/features/settings/general/CallSoundSettings.test.tsx @@ -12,12 +12,15 @@ vi.mock('$state/hooks/settings', () => ({ incomingCallSoundEnabled: true, outgoingRingbackEnabled: true, callRingtoneId: 'sable-default', - callRingbackTone: 'same-as-ringtone', + callRingbackTone: 'sable-default', callRingtoneVolume: 80, callSoundOverrideGlobalNotifications: false, callCustomRingtoneName: undefined, callCustomRingtoneSizeBytes: undefined, callCustomRingtoneDurationMs: undefined, + callCustomRingbackName: undefined, + callCustomRingbackSizeBytes: undefined, + callCustomRingbackDurationMs: undefined, }; return [values[key], vi.fn<(value: unknown) => void>()] as const; }, @@ -25,8 +28,11 @@ vi.mock('$state/hooks/settings', () => ({ vi.mock('$features/call/callRingtoneStorage', () => ({ getCustomCallRingtone: vi.fn<() => Promise>(async () => undefined), + getCustomCallRingback: vi.fn<() => Promise>(async () => undefined), putCustomCallRingtone: vi.fn<() => Promise>(), + putCustomCallRingback: vi.fn<() => Promise>(), clearCustomCallRingtone: vi.fn<() => Promise>(), + clearCustomCallRingback: vi.fn<() => Promise>(), })); describe('CallSoundSettings', () => { @@ -40,9 +46,11 @@ describe('CallSoundSettings', () => { expect(screen.getByText('Ringtone Volume')).toBeInTheDocument(); expect(screen.getByText('Always Play Call Sound')).toBeInTheDocument(); expect(screen.getByText('Custom Ringtone')).toBeInTheDocument(); + expect(screen.getByText('Custom Ringback')).toBeInTheDocument(); await waitFor(() => { expect(screen.getByText('No custom ringtone imported.')).toBeInTheDocument(); + expect(screen.getByText('No custom ringback imported.')).toBeInTheDocument(); }); }); }); diff --git a/src/app/features/settings/general/CallSoundSettings.tsx b/src/app/features/settings/general/CallSoundSettings.tsx index 4fe644130..47d1c5217 100644 --- a/src/app/features/settings/general/CallSoundSettings.tsx +++ b/src/app/features/settings/general/CallSoundSettings.tsx @@ -4,7 +4,7 @@ import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; import { SettingMenuSelector } from '$components/setting-menu-selector'; import { useSetting } from '$state/hooks/settings'; -import { settingsAtom, type CallRingtoneId } from '$state/settings'; +import { settingsAtom, type CallRingbackTone, type CallRingtoneId } from '$state/settings'; import { CALL_RINGBACK_OPTIONS, CALL_RINGTONE_OPTIONS, @@ -18,26 +18,31 @@ import { validateCustomCallRingtone, } from '$features/call/callRingtone'; import { + clearCustomCallRingback, clearCustomCallRingtone, + getCustomCallRingback, getCustomCallRingtone, + putCustomCallRingback, putCustomCallRingtone, } from '$features/call/callRingtoneStorage'; import { SequenceCardStyle } from '$features/settings/styles.css'; import { bytesToSize, millisecondsToMinutesAndSeconds } from '$utils/common'; -function CustomRingtoneMeta({ +function CustomToneMeta({ fileName, sizeBytes, durationMs, + emptyLabel, }: { fileName?: string; sizeBytes?: number; durationMs?: number; + emptyLabel: string; }) { if (!fileName) { return ( - No custom ringtone imported. + {emptyLabel} ); } @@ -80,19 +85,33 @@ export function CallSoundSettings() { settingsAtom, 'callCustomRingtoneDurationMs' ); + const [callCustomRingbackName, setCallCustomRingbackName] = useSetting( + settingsAtom, + 'callCustomRingbackName' + ); + const [callCustomRingbackSizeBytes, setCallCustomRingbackSizeBytes] = useSetting( + settingsAtom, + 'callCustomRingbackSizeBytes' + ); + const [callCustomRingbackDurationMs, setCallCustomRingbackDurationMs] = useSetting( + settingsAtom, + 'callCustomRingbackDurationMs' + ); const [previewing, setPreviewing] = useState(false); const [loadingCustomState, setLoadingCustomState] = useState(true); const [hasCustomRingtone, setHasCustomRingtone] = useState(false); + const [hasCustomRingback, setHasCustomRingback] = useState(false); const [customError, setCustomError] = useState(null); const previewAudioRef = useRef(null); useEffect(() => { let mounted = true; - getCustomCallRingtone() - .then((entry) => { + Promise.all([getCustomCallRingtone(), getCustomCallRingback()]) + .then(([ringtone, ringback]) => { if (!mounted) return; - setHasCustomRingtone(Boolean(entry)); + setHasCustomRingtone(Boolean(ringtone)); + setHasCustomRingback(Boolean(ringback)); }) .finally(() => { if (!mounted) return; @@ -108,9 +127,22 @@ export function CallSoundSettings() { useEffect(() => { if (!loadingCustomState && !hasCustomRingtone && callRingtoneId === 'custom') { + setCallRingtoneId('sable-default'); setCustomError('Custom ringtone is not available on this device. Falling back to default.'); } - }, [callRingtoneId, hasCustomRingtone, loadingCustomState]); + if (!loadingCustomState && !hasCustomRingback && callRingbackTone === 'custom') { + setCallRingbackTone('sable-default'); + setCustomError('Custom ringback is not available on this device. Falling back to default.'); + } + }, [ + callRingtoneId, + callRingbackTone, + hasCustomRingtone, + hasCustomRingback, + loadingCustomState, + setCallRingtoneId, + setCallRingbackTone, + ]); const ringtoneOptions = useMemo( () => @@ -125,25 +157,48 @@ export function CallSoundSettings() { ), [callCustomRingtoneName, loadingCustomState] ); + const ringbackOptions = useMemo( + () => + CALL_RINGBACK_OPTIONS.map((option) => + option.value === 'custom' + ? { + ...option, + label: callCustomRingbackName ? 'Custom File (Imported)' : 'Custom File', + disabled: loadingCustomState, + } + : option + ), + [callCustomRingbackName, loadingCustomState] + ); const resolveToneForPreview = useCallback( async (tone: 'incoming' | 'outgoing'): Promise => { - let customUrl: string | undefined; - if (callRingtoneId === 'custom' || callRingbackTone === 'same-as-ringtone') { - const custom = await getCustomCallRingtone(); - if (custom?.blob) { - customUrl = URL.createObjectURL(custom.blob); + let customRingtoneUrl: string | undefined; + let customRingbackUrl: string | undefined; + if (callRingtoneId === 'custom') { + const customRingtone = await getCustomCallRingtone(); + if (customRingtone?.blob) { + customRingtoneUrl = URL.createObjectURL(customRingtone.blob); + } + } + if (callRingbackTone === 'custom') { + const customRingback = await getCustomCallRingback(); + if (customRingback?.blob) { + customRingbackUrl = URL.createObjectURL(customRingback.blob); } } const source = tone === 'incoming' - ? resolveIncomingCallToneUrl({ callRingtoneId }, customUrl) - : resolveOutgoingRingbackToneUrl({ callRingbackTone, callRingtoneId }, customUrl); + ? resolveIncomingCallToneUrl({ callRingtoneId }, customRingtoneUrl) + : resolveOutgoingRingbackToneUrl( + { callRingbackTone, callRingtoneId }, + customRingtoneUrl, + customRingbackUrl + ); - if (customUrl && source !== customUrl) { - URL.revokeObjectURL(customUrl); - } + if (customRingtoneUrl && source !== customRingtoneUrl) URL.revokeObjectURL(customRingtoneUrl); + if (customRingbackUrl && source !== customRingbackUrl) URL.revokeObjectURL(customRingbackUrl); return source; }, @@ -254,6 +309,79 @@ export function CallSoundSettings() { setCallRingtoneId, ]); + const handleImportCustomRingback = useCallback(() => { + setCustomError(null); + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'audio/*'; + input.addEventListener('change', async () => { + const file = input.files?.[0]; + if (!file) return; + + try { + const durationMs = await readAudioDurationMs(file); + const validation = validateCustomCallRingtone({ + fileName: file.name, + mimeType: file.type, + sizeBytes: file.size, + durationMs, + }); + if (!validation.valid) { + if (validation.reason === 'type') { + setCustomError('Only audio files are supported.'); + return; + } + if (validation.reason === 'size') { + setCustomError( + `File is too large. Max ${bytesToSize(CUSTOM_CALL_RINGTONE_MAX_BYTES)} allowed.` + ); + return; + } + setCustomError( + `Ringback must be between 1s and ${millisecondsToMinutesAndSeconds( + CUSTOM_CALL_RINGTONE_MAX_DURATION_MS + )}.` + ); + return; + } + + await putCustomCallRingback(file, durationMs); + setHasCustomRingback(true); + setCallRingbackTone('custom'); + setCallCustomRingbackName(file.name); + setCallCustomRingbackSizeBytes(file.size); + setCallCustomRingbackDurationMs(durationMs); + } catch { + setCustomError('Could not import this file. Try a different audio format.'); + } + }); + + input.click(); + }, [ + setCallCustomRingbackDurationMs, + setCallCustomRingbackName, + setCallCustomRingbackSizeBytes, + setCallRingbackTone, + ]); + + const handleResetCustomRingback = useCallback(async () => { + setCustomError(null); + await clearCustomCallRingback(); + setHasCustomRingback(false); + setCallCustomRingbackName(undefined); + setCallCustomRingbackSizeBytes(undefined); + setCallCustomRingbackDurationMs(undefined); + if (callRingbackTone === 'custom') { + setCallRingbackTone('sable-default'); + } + }, [ + callRingbackTone, + setCallCustomRingbackDurationMs, + setCallCustomRingbackName, + setCallCustomRingbackSizeBytes, + setCallRingbackTone, + ]); + const handleRingtoneSelection = (next: CallRingtoneId) => { if (next === 'custom' && !hasCustomRingtone) { setCustomError('Import a custom ringtone file first.'); @@ -263,6 +391,15 @@ export function CallSoundSettings() { setCallRingtoneId(next); }; + const handleRingbackSelection = (next: CallRingbackTone) => { + if (next === 'custom' && !hasCustomRingback) { + setCustomError('Import a custom ringback file first.'); + return; + } + setCustomError(null); + setCallRingbackTone(next); + }; + const handleVolumeChange = (value: string) => { const parsed = Number.parseInt(value, 10); if (Number.isNaN(parsed)) return; @@ -322,8 +459,9 @@ export function CallSoundSettings() { after={ } /> @@ -375,10 +513,11 @@ export function CallSoundSettings() { description="Import an audio file for your ringtone." > - + + + + + Max file size: {bytesToSize(CUSTOM_CALL_RINGTONE_MAX_BYTES)}. Max duration:{' '} + {millisecondsToMinutesAndSeconds(CUSTOM_CALL_RINGTONE_MAX_DURATION_MS)}. + + + + ); } diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index 90cbe3d90..54d253a27 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -29,7 +29,10 @@ import { resolveOutgoingRingbackToneUrl, } from '$features/call/callRingtone'; import { dismissSystemCallNotifications } from '$features/call/callNotificationBridge'; -import { getCustomCallRingtone } from '$features/call/callRingtoneStorage'; +import { + getCustomCallRingback, + getCustomCallRingtone, +} from '$features/call/callRingtoneStorage'; import { useMatrixClient } from './useMatrixClient'; import { createDebugLogger } from '../utils/debugLogger'; @@ -172,19 +175,25 @@ export function useIncomingCallSignaling() { useEffect(() => { let canceled = false; - let customToneUrl: string | undefined; + let customRingtoneUrl: string | undefined; + let customRingbackUrl: string | undefined; const incoming = incomingAudioRef.current; const outgoing = outgoingAudioRef.current; if (!incoming || !outgoing) return undefined; const syncSources = async () => { - const needsCustomTone = - settings.callRingtoneId === 'custom' || settings.callRingbackTone === 'same-as-ringtone'; - if (needsCustomTone) { - const custom = await getCustomCallRingtone().catch(() => undefined); - if (custom?.blob) { - customToneUrl = URL.createObjectURL(custom.blob); + if (settings.callRingtoneId === 'custom') { + const customRingtone = await getCustomCallRingtone().catch(() => undefined); + if (customRingtone?.blob) { + customRingtoneUrl = URL.createObjectURL(customRingtone.blob); + } + } + + if (settings.callRingbackTone === 'custom') { + const customRingback = await getCustomCallRingback().catch(() => undefined); + if (customRingback?.blob) { + customRingbackUrl = URL.createObjectURL(customRingback.blob); } } @@ -199,14 +208,15 @@ export function useIncomingCallSignaling() { { callRingtoneId: settings.callRingtoneId, }, - customToneUrl + customRingtoneUrl ); const outgoingTone = resolveOutgoingRingbackToneUrl( { callRingtoneId: settings.callRingtoneId, callRingbackTone: settings.callRingbackTone, }, - customToneUrl + customRingtoneUrl, + customRingbackUrl ); const gain = callRingtoneVolumeToGain(settings.callRingtoneVolume); @@ -229,7 +239,8 @@ export function useIncomingCallSignaling() { return () => { canceled = true; - if (customToneUrl) URL.revokeObjectURL(customToneUrl); + if (customRingtoneUrl) URL.revokeObjectURL(customRingtoneUrl); + if (customRingbackUrl) URL.revokeObjectURL(customRingbackUrl); }; }, [settings.callRingtoneId, settings.callRingbackTone, settings.callRingtoneVolume]); diff --git a/src/app/state/settings.defaults.test.ts b/src/app/state/settings.defaults.test.ts index 3a39d9e17..4b242f076 100644 --- a/src/app/state/settings.defaults.test.ts +++ b/src/app/state/settings.defaults.test.ts @@ -47,6 +47,22 @@ describe('mergePersistedSettings', () => { expect(merged.callRingbackTone).toBe(defaultSettings.callRingbackTone); }); + it('migrates legacy ringback presets to new ringback ids', () => { + localStorage.setItem( + 'settings', + JSON.stringify({ + callRingtoneId: 'minimal-ping', + callRingbackTone: 'same-as-ringtone', + }) + ); + const mergedSame = mergePersistedSettings(localStorage.getItem('settings'), {}); + expect(mergedSame.callRingbackTone).toBe('minimal-ping'); + + localStorage.setItem('settings', JSON.stringify({ callRingbackTone: 'default-ringback' })); + const mergedDefault = mergePersistedSettings(localStorage.getItem('settings'), {}); + expect(mergedDefault.callRingbackTone).toBe('classic-soft'); + }); + it('drops invalid custom ringtone metadata during migration', () => { localStorage.setItem( 'settings', @@ -54,12 +70,18 @@ describe('mergePersistedSettings', () => { callCustomRingtoneName: 'tone.ogg', callCustomRingtoneSizeBytes: -5, callCustomRingtoneDurationMs: Number.NaN, + callCustomRingbackName: 'ringback.ogg', + callCustomRingbackSizeBytes: -7, + callCustomRingbackDurationMs: Number.NaN, }) ); const merged = mergePersistedSettings(localStorage.getItem('settings'), {}); expect(merged.callCustomRingtoneName).toBe('tone.ogg'); expect(merged.callCustomRingtoneSizeBytes).toBeUndefined(); expect(merged.callCustomRingtoneDurationMs).toBeNull(); + expect(merged.callCustomRingbackName).toBe('ringback.ogg'); + expect(merged.callCustomRingbackSizeBytes).toBeUndefined(); + expect(merged.callCustomRingbackDurationMs).toBeNull(); }); }); @@ -99,12 +121,12 @@ describe('sanitizeSettingsDefaults', () => { expect( sanitizeSettingsDefaults({ callRingtoneId: 'classic-soft', - callRingbackTone: 'default-ringback', + callRingbackTone: 'minimal-ping', callRingtoneVolume: 73.7, }) ).toEqual({ callRingtoneId: 'classic-soft', - callRingbackTone: 'default-ringback', + callRingbackTone: 'minimal-ping', callRingtoneVolume: 74, }); expect( diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index ae38d69a7..832fe9834 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -35,7 +35,7 @@ export type CallRingtoneId = | 'minimal-ping' | 'silent' | 'custom'; -export type CallRingbackTone = 'same-as-ringtone' | 'default-ringback' | 'silent'; +export type CallRingbackTone = CallRingtoneId; export type ThemeRemoteFavorite = { fullUrl: string; @@ -169,6 +169,9 @@ export interface Settings { callCustomRingtoneName?: string; callCustomRingtoneSizeBytes?: number; callCustomRingtoneDurationMs?: number; + callCustomRingbackName?: string; + callCustomRingbackSizeBytes?: number; + callCustomRingbackDurationMs?: number; faviconForMentionsOnly: boolean; highlightMentions: boolean; pkCompat: boolean; @@ -305,11 +308,14 @@ export const defaultSettings: Settings = { outgoingRingbackEnabled: true, callRingtoneVolume: 80, callRingtoneId: 'sable-default', - callRingbackTone: 'same-as-ringtone', + callRingbackTone: 'sable-default', callSoundOverrideGlobalNotifications: false, callCustomRingtoneName: undefined, callCustomRingtoneSizeBytes: undefined, callCustomRingtoneDurationMs: undefined, + callCustomRingbackName: undefined, + callCustomRingbackSizeBytes: undefined, + callCustomRingbackDurationMs: undefined, faviconForMentionsOnly: false, highlightMentions: true, pkCompat: false, @@ -400,10 +406,18 @@ function migrateParsedLocalStorage(parsed: Record): void { delete parsed.callRingtoneId; } + if (parsed.callRingbackTone === 'same-as-ringtone') { + parsed.callRingbackTone = parsed.callRingtoneId ?? defaultSettings.callRingtoneId; + } else if (parsed.callRingbackTone === 'default-ringback') { + parsed.callRingbackTone = 'classic-soft'; + } + if ( - parsed.callRingbackTone !== 'same-as-ringtone' && - parsed.callRingbackTone !== 'default-ringback' && - parsed.callRingbackTone !== 'silent' + parsed.callRingbackTone !== 'sable-default' && + parsed.callRingbackTone !== 'classic-soft' && + parsed.callRingbackTone !== 'minimal-ping' && + parsed.callRingbackTone !== 'silent' && + parsed.callRingbackTone !== 'custom' ) { delete parsed.callRingbackTone; } @@ -422,6 +436,21 @@ function migrateParsedLocalStorage(parsed: Record): void { ) { delete parsed.callCustomRingtoneDurationMs; } + + if ( + typeof parsed.callCustomRingbackSizeBytes === 'number' && + (!Number.isFinite(parsed.callCustomRingbackSizeBytes) || parsed.callCustomRingbackSizeBytes < 0) + ) { + delete parsed.callCustomRingbackSizeBytes; + } + + if ( + typeof parsed.callCustomRingbackDurationMs === 'number' && + (!Number.isFinite(parsed.callCustomRingbackDurationMs) || + parsed.callCustomRingbackDurationMs < 0) + ) { + delete parsed.callCustomRingbackDurationMs; + } } export function mergePersistedSettings( @@ -542,7 +571,11 @@ function sanitizeSettingsKey(key: keyof Settings, val: unknown): unknown { ? val : undefined; case 'callRingbackTone': - return val === 'same-as-ringtone' || val === 'default-ringback' || val === 'silent' + return val === 'sable-default' || + val === 'classic-soft' || + val === 'minimal-ping' || + val === 'silent' || + val === 'custom' ? val : undefined; case 'callRingtoneVolume': @@ -550,6 +583,8 @@ function sanitizeSettingsKey(key: keyof Settings, val: unknown): unknown { return Math.max(0, Math.min(100, Math.round(val))); case 'callCustomRingtoneSizeBytes': case 'callCustomRingtoneDurationMs': + case 'callCustomRingbackSizeBytes': + case 'callCustomRingbackDurationMs': return typeof val === 'number' && Number.isFinite(val) && val >= 0 ? Math.round(val) : undefined; diff --git a/src/app/utils/settingsSync.test.ts b/src/app/utils/settingsSync.test.ts index 6edd6bd3b..c8622b936 100644 --- a/src/app/utils/settingsSync.test.ts +++ b/src/app/utils/settingsSync.test.ts @@ -40,6 +40,9 @@ describe('NON_SYNCABLE_KEYS', () => { 'callCustomRingtoneName', 'callCustomRingtoneSizeBytes', 'callCustomRingtoneDurationMs', + 'callCustomRingbackName', + 'callCustomRingbackSizeBytes', + 'callCustomRingbackDurationMs', 'developerTools', 'settingsSyncEnabled', ] as const; diff --git a/src/app/utils/settingsSync.ts b/src/app/utils/settingsSync.ts index 65f1d6cfe..b3f3f7020 100644 --- a/src/app/utils/settingsSync.ts +++ b/src/app/utils/settingsSync.ts @@ -24,6 +24,9 @@ export const NON_SYNCABLE_KEYS = new Set([ 'callCustomRingtoneName', 'callCustomRingtoneSizeBytes', 'callCustomRingtoneDurationMs', + 'callCustomRingbackName', + 'callCustomRingbackSizeBytes', + 'callCustomRingbackDurationMs', // Developer / diagnostic 'developerTools', // Sync toggle itself must never be uploaded (it's device-local) From 59cfcd0d271b1903ed1a9265485161f0359fc786 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 17:50:11 -0500 Subject: [PATCH 14/27] fix ringtone not stopping --- src/app/components/IncomingCallModal.tsx | 16 +++++++--------- src/app/hooks/useCallSignaling.ts | 6 ++++++ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/app/components/IncomingCallModal.tsx b/src/app/components/IncomingCallModal.tsx index c29659fbd..b1d1d3705 100644 --- a/src/app/components/IncomingCallModal.tsx +++ b/src/app/components/IncomingCallModal.tsx @@ -168,7 +168,7 @@ export function IncomingCallInternal({ room, incomingCall, onClose }: IncomingCa navigateRoom(room.roomId); }; - const handleDeclineOrIgnore = async () => { + const handleDeclineOrIgnore = () => { setCallSoundBlocked(false); const action = isDirectRing ? 'decline' : 'ignore'; debugLog.info('call', 'Incoming call dismissed', { @@ -192,22 +192,20 @@ export function IncomingCallInternal({ room, incomingCall, onClose }: IncomingCa }, }); + setMutedRoomId(room.roomId); + void dismissSystemCallNotifications(room.roomId); + onClose(); + if (isDirectRing) { - try { - await mx.sendRtcDecline(room.roomId, incomingCall.notificationEventId); - } catch (error) { + void mx.sendRtcDecline(room.roomId, incomingCall.notificationEventId).catch((error) => { debugLog.warn('call', 'Failed to send RTC decline event', { roomId: room.roomId, notificationEventId: incomingCall.notificationEventId, error: error instanceof Error ? error.message : String(error), }); Sentry.metrics.count('sable.call.decline.error', 1); - } + }); } - - setMutedRoomId(room.roomId); - void dismissSystemCallNotifications(room.roomId); - onClose(); }; const handleModalKeyDown = (evt: ReactKeyboardEvent) => { diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index 54d253a27..0d5f14ba1 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -285,6 +285,12 @@ export function useIncomingCallSignaling() { } }, [incomingRingtoneAllowed, outgoingRingbackAllowed, stopIncomingRing, stopOutgoingRing]); + useEffect(() => { + if (!incomingCall) { + stopIncomingRing(); + } + }, [incomingCall, stopIncomingRing]); + const handleIncomingCall = useCallback( (nextIncomingCall: IncomingCall) => { if (mutedRoomIdRef.current === nextIncomingCall.roomId) return; From 08272faa1bb415480a4d84642aa11f06a972cd01 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 18:06:04 -0500 Subject: [PATCH 15/27] override element call ringback sound --- src/sw.ts | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/src/sw.ts b/src/sw.ts index f51e5001e..2e0f30f67 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -624,6 +624,57 @@ const MEDIA_PATHS = [ '/_matrix/media/r0/thumbnail', ]; +const ELEMENT_CALL_RINGTONE_PATH = '/public/element-call/assets/ringtone-'; +let silentWavBytesCache: Uint8Array | undefined; + +function createSilentWavBytes(durationMs = 250): Uint8Array { + if (silentWavBytesCache) return silentWavBytesCache; + + const sampleRate = 8000; + const channels = 1; + const bitsPerSample = 16; + const bytesPerSample = bitsPerSample / 8; + const frameCount = Math.max(1, Math.floor((sampleRate * durationMs) / 1000)); + const dataSize = frameCount * channels * bytesPerSample; + const buffer = new ArrayBuffer(44 + dataSize); + const view = new DataView(buffer); + + // RIFF header + view.setUint32(0, 0x52494646, false); // "RIFF" + view.setUint32(4, 36 + dataSize, true); + view.setUint32(8, 0x57415645, false); // "WAVE" + + // fmt chunk + view.setUint32(12, 0x666d7420, false); // "fmt " + view.setUint32(16, 16, true); // PCM chunk size + view.setUint16(20, 1, true); // PCM format + view.setUint16(22, channels, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * channels * bytesPerSample, true); + view.setUint16(32, channels * bytesPerSample, true); + view.setUint16(34, bitsPerSample, true); + + // data chunk + view.setUint32(36, 0x64617461, false); // "data" + view.setUint32(40, dataSize, true); + + // PCM data is already zeroed => silence. + silentWavBytesCache = new Uint8Array(buffer); + return silentWavBytesCache; +} + +function isElementCallRingtoneRequest(url: string): boolean { + try { + const { pathname } = new URL(url); + return ( + pathname.startsWith(ELEMENT_CALL_RINGTONE_PATH) && + (pathname.endsWith('.mp3') || pathname.endsWith('.ogg') || pathname.endsWith('.wav')) + ); + } catch { + return false; + } +} + function mediaPath(url: string): boolean { try { const { pathname } = new URL(url); @@ -666,7 +717,24 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { self.addEventListener('fetch', (event: FetchEvent) => { const { url, method } = event.request; - if (method !== 'GET' || !mediaPath(url)) return; + if (method !== 'GET') return; + + if (isElementCallRingtoneRequest(url)) { + event.respondWith( + Promise.resolve( + new Response(createSilentWavBytes(), { + status: 200, + headers: { + 'Content-Type': 'audio/wav', + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }) + ) + ); + return; + } + + if (!mediaPath(url)) return; const { clientId } = event; From f35cfad3f007257864b5f64f562050ddff76a849 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 18:15:25 -0500 Subject: [PATCH 16/27] remove old ringback suppression --- src/app/hooks/useCallEmbed.ts | 12 ++---------- src/app/plugins/call/CallEmbed.ts | 6 +----- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts index e8248372f..edcc55738 100644 --- a/src/app/hooks/useCallEmbed.ts +++ b/src/app/hooks/useCallEmbed.ts @@ -55,16 +55,8 @@ export const createCallEmbed = ( const intent = CallEmbed.getIntent(dm, ongoing, pref?.video); const widget = CallEmbed.getWidget(mx, room, intent, themeKind, elementCallUrl); const controlState = pref && new CallControlState(pref.microphone, pref.video, pref.sound); - const suppressOutgoingPickupSound = CallEmbed.startingCall(intent); - - const embed = new CallEmbed( - mx, - room, - widget, - container, - controlState, - suppressOutgoingPickupSound - ); + + const embed = new CallEmbed(mx, room, widget, container, controlState); return embed; }; diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 96cb85167..752f465e5 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -169,8 +169,7 @@ export class CallEmbed { room: Room, widget: Widget, container: HTMLElement, - initialControlState?: CallControlState, - suppressOutgoingPickupSound = false + initialControlState?: CallControlState ) { debugLog.info('call', 'Initializing call embed', { roomId: room.roomId }); @@ -190,9 +189,6 @@ export class CallEmbed { const controlState = initialControlState ?? new CallControlState(true, false, true); this.control = new CallControl(controlState, call, iframe); - if (suppressOutgoingPickupSound) { - this.control.setOutputOverrideMuted(true); - } this.disposables.push( this.listenAction(WidgetApiFromWidgetAction.UpdateAlwaysOnScreen, (evt) => { From 329520a80e9ab30af27cddd4e81ee0bef6d3e22e Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 19:30:49 -0500 Subject: [PATCH 17/27] lint --- src/app/components/IncomingCallModal.tsx | 2 +- src/app/features/call/CallView.tsx | 14 +++++++++++--- src/app/features/call/callRingtoneStorage.ts | 12 ++++++++---- src/app/hooks/useCallSignaling.ts | 11 ++++++----- src/app/plugins/call/CallControl.ts | 13 +++++++++---- src/sw.ts | 4 +++- 6 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/app/components/IncomingCallModal.tsx b/src/app/components/IncomingCallModal.tsx index b1d1d3705..a7ab56ff4 100644 --- a/src/app/components/IncomingCallModal.tsx +++ b/src/app/components/IncomingCallModal.tsx @@ -212,7 +212,7 @@ export function IncomingCallInternal({ room, incomingCall, onClose }: IncomingCa if (evt.key === 'Escape') { evt.preventDefault(); evt.stopPropagation(); - void handleDeclineOrIgnore(); + handleDeclineOrIgnore(); return; } if (evt.key === 'Enter' && canAnswer) { diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 49d727700..5298a89b3 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -183,6 +183,12 @@ export function CallView({ resizable }: CallViewProps) { const room = useRoom(); const screenSize = useScreenSizeContext(); const isMobile = screenSize === ScreenSize.Mobile; + const desktopMinCallHeight = 620; + const desktopMaxCallHeight = Math.round(window.innerHeight * 0.8); + const desktopDefaultCallHeight = Math.min( + Math.max(desktopMinCallHeight, Math.round(window.innerHeight * 0.72)), + desktopMaxCallHeight + ); const callViewRef = useRef(null); const callContainerRef = useRef(null); @@ -193,7 +199,7 @@ export function CallView({ resizable }: CallViewProps) { const currentJoined = callEmbed?.roomId === room.roomId && callJoined; - const [height, setHeight] = useState(isMobile ? 240 : 380); + const [height, setHeight] = useState(isMobile ? 240 : desktopDefaultCallHeight); const [isDragging, setIsDragging] = useState(false); const isResizing = useRef(false); const previousBodyUserSelect = useRef(null); @@ -202,9 +208,11 @@ export function CallView({ resizable }: CallViewProps) { (clientY: number) => { if (!isResizing.current || !callViewRef.current) return; const { top } = callViewRef.current.getBoundingClientRect(); - setHeight(Math.max(isMobile ? 120 : 150, Math.min(clientY - top, window.innerHeight * 0.8))); + const maxHeight = window.innerHeight * 0.8; + const minHeight = isMobile ? 120 : Math.min(desktopMinCallHeight, maxHeight); + setHeight(Math.max(minHeight, Math.min(clientY - top, maxHeight))); }, - [isMobile] + [isMobile, desktopMinCallHeight] ); const handleMouseMove = useCallback((e: MouseEvent) => handleMove(e.clientY), [handleMove]); diff --git a/src/app/features/call/callRingtoneStorage.ts b/src/app/features/call/callRingtoneStorage.ts index b0a9605fd..f4c0bafe6 100644 --- a/src/app/features/call/callRingtoneStorage.ts +++ b/src/app/features/call/callRingtoneStorage.ts @@ -73,8 +73,10 @@ async function clearCustomCallAudio(key: string): Promise { }); } -export const putCustomCallRingtone = (file: File, durationMs: number): Promise => - putCustomCallAudio(CUSTOM_RINGTONE_KEY, file, durationMs); +export const putCustomCallRingtone = ( + file: File, + durationMs: number +): Promise => putCustomCallAudio(CUSTOM_RINGTONE_KEY, file, durationMs); export const getCustomCallRingtone = (): Promise => getCustomCallAudio(CUSTOM_RINGTONE_KEY); @@ -82,8 +84,10 @@ export const getCustomCallRingtone = (): Promise export const clearCustomCallRingtone = (): Promise => clearCustomCallAudio(CUSTOM_RINGTONE_KEY); -export const putCustomCallRingback = (file: File, durationMs: number): Promise => - putCustomCallAudio(CUSTOM_RINGBACK_KEY, file, durationMs); +export const putCustomCallRingback = ( + file: File, + durationMs: number +): Promise => putCustomCallAudio(CUSTOM_RINGBACK_KEY, file, durationMs); export const getCustomCallRingback = (): Promise => getCustomCallAudio(CUSTOM_RINGBACK_KEY); diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index 0d5f14ba1..a3d69c9a1 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -29,10 +29,7 @@ import { resolveOutgoingRingbackToneUrl, } from '$features/call/callRingtone'; import { dismissSystemCallNotifications } from '$features/call/callNotificationBridge'; -import { - getCustomCallRingback, - getCustomCallRingtone, -} from '$features/call/callRingtoneStorage'; +import { getCustomCallRingback, getCustomCallRingtone } from '$features/call/callRingtoneStorage'; import { useMatrixClient } from './useMatrixClient'; import { createDebugLogger } from '../utils/debugLogger'; @@ -473,7 +470,11 @@ export function useIncomingCallSignaling() { } const session = mx.matrixRTC.getRoomSession(outgoingRoom); - const pendingOutgoing = isOutgoingCallPending(myUserId, outgoingRoom, session.sessionDescription); + const pendingOutgoing = isOutgoingCallPending( + myUserId, + outgoingRoom, + session.sessionDescription + ); const activeCall = isCallActive(myUserId, outgoingRoom, session.sessionDescription); if (!pendingOutgoing || activeCall) { diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index 43071bf0b..834d18674 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -166,7 +166,7 @@ export class CallControl extends EventEmitter implements CallControlState { const callDocument = this.iframe.contentDocument ?? this.iframe.contentWindow?.document; const shouldMute = this.outputOverrideMuted || !sound; if (callDocument) { - callDocument.querySelectorAll('audio, video').forEach((el) => { + callDocument.querySelectorAll('audio, video').forEach((el) => { el.muted = shouldMute; if (shouldMute) el.volume = 0; }); @@ -188,7 +188,12 @@ export class CallControl extends EventEmitter implements CallControlState { const originalCtor = scopedWindow[key]; if (typeof originalCtor !== 'function') return; - const originalResume = originalCtor.prototype.resume; + const resumeDescriptor = Object.getOwnPropertyDescriptor(originalCtor.prototype, 'resume'); + const originalResumeImpl = resumeDescriptor?.value as + | ((this: AudioContext) => Promise) + | undefined; + if (typeof originalResumeImpl !== 'function') return; + const originalResume = (context: AudioContext): Promise => originalResumeImpl.call(context); const trackedContexts = this.trackedAudioContexts; const isOverrideMuted = () => this.outputOverrideMuted; originalCtor.prototype.resume = function patchedResume(this: AudioContext) { @@ -196,10 +201,10 @@ export class CallControl extends EventEmitter implements CallControlState { if (isOverrideMuted()) { return Promise.resolve(); } - return originalResume.call(this); + return originalResume(this); }; this.audioPatchRestores.push(() => { - originalCtor.prototype.resume = originalResume; + originalCtor.prototype.resume = originalResumeImpl; }); const wrappedCtor = function patchedAudioContext( diff --git a/src/sw.ts b/src/sw.ts index 2e0f30f67..ac3b9720b 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -720,9 +720,11 @@ self.addEventListener('fetch', (event: FetchEvent) => { if (method !== 'GET') return; if (isElementCallRingtoneRequest(url)) { + const silentWavBytes = createSilentWavBytes(); + const silentWavBuffer = new Uint8Array(silentWavBytes).buffer; event.respondWith( Promise.resolve( - new Response(createSilentWavBytes(), { + new Response(silentWavBuffer, { status: 200, headers: { 'Content-Type': 'audio/wav', From d49dcb023082de09024a872cd299d248350991a4 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 20:44:14 -0500 Subject: [PATCH 18/27] remove some redundancies --- .../message/content/ImageContent.tsx | 29 +- src/app/features/call/callRingtone.ts | 38 +- .../settings/general/CallSoundSettings.tsx | 452 +++++++++--------- src/app/hooks/useCallSignaling.ts | 53 +- src/app/plugins/markdown/markdownToHtml.ts | 2 +- src/app/state/settings.ts | 94 ++-- 6 files changed, 313 insertions(+), 355 deletions(-) diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index c6d43e672..13868c4bf 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -142,21 +142,24 @@ export const ImageContent = as<'div', ImageContentProps>( ); useEffect(() => { - let cancelled = false; if (!viewer) { setViewerFullSrc(null); - } else if ( - typeof matrixThumbnailMaxEdge === 'number' && - matrixThumbnailMaxEdge > 0 && - !encInfo && - !url.startsWith('http') + return; + } + if ( + typeof matrixThumbnailMaxEdge !== 'number' || + matrixThumbnailMaxEdge <= 0 || + encInfo || + url.startsWith('http') ) { - void (async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); - if (!mediaUrl || cancelled) return; - setViewerFullSrc(mediaUrl); - })(); + return; } + let cancelled = false; + void (async () => { + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl || cancelled) return; + setViewerFullSrc(mediaUrl); + })(); return () => { cancelled = true; }; @@ -192,14 +195,12 @@ export const ImageContent = as<'div', ImageContentProps>( const rootClass = isContained ? css.ContainedMediaRoot : css.RelativeBase; const stripMin = containedStripMinPx ?? 56; - const imageWidth = info?.w; - const imageHeight = info?.h; const intrinsicSizingStyle = fillsSlot ? {} : isContained ? { minHeight: containedReserveStrip ? toRem(stripMin) : undefined } : hasDimensions - ? { aspectRatio: `${imageWidth} / ${imageHeight}` } + ? { aspectRatio: `${info!.w} / ${info!.h}` } : { minHeight: '150px' }; const fillPreviewSlotStyle = fillsSlot diff --git a/src/app/features/call/callRingtone.ts b/src/app/features/call/callRingtone.ts index b35dd378b..206d5f79f 100644 --- a/src/app/features/call/callRingtone.ts +++ b/src/app/features/call/callRingtone.ts @@ -1,7 +1,12 @@ import InviteSound from '$public/sound/invite.ogg'; import NotificationSound from '$public/sound/notification.ogg'; import RingtoneSound from '$public/sound/ringtone.webm'; -import type { CallRingbackTone, CallRingtoneId, Settings } from '$state/settings'; +import { + CALL_TONE_IDS, + type CallRingbackTone, + type CallRingtoneId, + type Settings, +} from '$state/settings'; export type CallToneOption = { value: T; @@ -13,21 +18,22 @@ export type CallToneOption = { export const CUSTOM_CALL_RINGTONE_MAX_BYTES = 3_000_000; export const CUSTOM_CALL_RINGTONE_MAX_DURATION_MS = 45_000; -export const CALL_RINGTONE_OPTIONS: CallToneOption[] = [ - { value: 'sable-default', label: 'Sable Default' }, - { value: 'classic-soft', label: 'Classic Soft Ring' }, - { value: 'minimal-ping', label: 'Minimal Ping Loop' }, - { value: 'silent', label: 'Silent (Visual Only)' }, - { value: 'custom', label: 'Custom File' }, -]; - -export const CALL_RINGBACK_OPTIONS: CallToneOption[] = [ - { value: 'sable-default', label: 'Sable Default' }, - { value: 'classic-soft', label: 'Classic Soft Ring' }, - { value: 'minimal-ping', label: 'Minimal Ping Loop' }, - { value: 'silent', label: 'Silent (Visual Only)' }, - { value: 'custom', label: 'Custom File' }, -]; +const CALL_TONE_LABELS: Record = { + 'sable-default': 'Sable Default', + 'classic-soft': 'Classic Soft Ring', + 'minimal-ping': 'Minimal Ping Loop', + silent: 'Silent (Visual Only)', + custom: 'Custom File', +}; + +export const CALL_RINGTONE_OPTIONS: CallToneOption[] = CALL_TONE_IDS.map( + (value) => ({ + value, + label: CALL_TONE_LABELS[value], + }) +); + +export const CALL_RINGBACK_OPTIONS: CallToneOption[] = CALL_RINGTONE_OPTIONS; type ToneSettings = Pick; diff --git a/src/app/features/settings/general/CallSoundSettings.tsx b/src/app/features/settings/general/CallSoundSettings.tsx index 47d1c5217..9193c6daf 100644 --- a/src/app/features/settings/general/CallSoundSettings.tsx +++ b/src/app/features/settings/general/CallSoundSettings.tsx @@ -28,6 +28,8 @@ import { import { SequenceCardStyle } from '$features/settings/styles.css'; import { bytesToSize, millisecondsToMinutesAndSeconds } from '$utils/common'; +type PreviewTone = 'incoming' | 'outgoing'; + function CustomToneMeta({ fileName, sizeBytes, @@ -49,13 +51,132 @@ function CustomToneMeta({ return ( - {fileName} - {typeof sizeBytes === 'number' && ` • ${bytesToSize(sizeBytes)}`} - {typeof durationMs === 'number' && ` • ${millisecondsToMinutesAndSeconds(durationMs)}`} + {[ + fileName, + typeof sizeBytes === 'number' ? bytesToSize(sizeBytes) : undefined, + typeof durationMs === 'number' ? millisecondsToMinutesAndSeconds(durationMs) : undefined, + ] + .filter(Boolean) + .join(' - ')} ); } +function CustomToneSettingsCard({ + title, + focusId, + description, + fileName, + sizeBytes, + durationMs, + emptyLabel, + hasCustomTone, + previewing, + previewActions, + onImport, + onPreview, + onReset, +}: { + title: string; + focusId: string; + description: string; + fileName?: string; + sizeBytes?: number; + durationMs?: number; + emptyLabel: string; + hasCustomTone: boolean; + previewing: boolean; + previewActions: { + label: string; + tone: PreviewTone; + icon: (typeof Icons)[keyof typeof Icons]; + }[]; + onImport: () => void; + onPreview: (tone: PreviewTone) => void; + onReset: () => void; +}) { + return ( + + + + + + + {previewActions.map(({ label, tone, icon }) => ( + + ))} + + + + Max file size: {bytesToSize(CUSTOM_CALL_RINGTONE_MAX_BYTES)}. Max duration:{' '} + {millisecondsToMinutesAndSeconds(CUSTOM_CALL_RINGTONE_MAX_DURATION_MS)}. + + + + + ); +} + +const customToneValidationError = ( + reason: 'type' | 'size' | 'duration', + label: 'Ringtone' | 'Ringback' +): string => { + if (reason === 'type') return 'Only audio files are supported.'; + if (reason === 'size') { + return `File is too large. Max ${bytesToSize(CUSTOM_CALL_RINGTONE_MAX_BYTES)} allowed.`; + } + + return `${label} must be between 1s and ${millisecondsToMinutesAndSeconds( + CUSTOM_CALL_RINGTONE_MAX_DURATION_MS + )}.`; +}; + export function CallSoundSettings() { const [incomingCallSoundEnabled, setIncomingCallSoundEnabled] = useSetting( settingsAtom, @@ -172,7 +293,7 @@ export function CallSoundSettings() { ); const resolveToneForPreview = useCallback( - async (tone: 'incoming' | 'outgoing'): Promise => { + async (tone: PreviewTone): Promise => { let customRingtoneUrl: string | undefined; let customRingbackUrl: string | undefined; if (callRingtoneId === 'custom') { @@ -206,7 +327,7 @@ export function CallSoundSettings() { ); const playPreviewTone = useCallback( - async (tone: 'incoming' | 'outgoing') => { + async (tone: PreviewTone) => { setCustomError(null); setPreviewing(true); try { @@ -236,55 +357,55 @@ export function CallSoundSettings() { [callRingtoneVolume, resolveToneForPreview] ); - const handleImportCustomRingtone = useCallback(() => { - setCustomError(null); - const input = document.createElement('input'); - input.type = 'file'; - input.accept = 'audio/*'; - input.addEventListener('change', async () => { - const file = input.files?.[0]; - if (!file) return; + const importCustomTone = useCallback( + ( + label: 'Ringtone' | 'Ringback', + putTone: (file: File, durationMs: number) => Promise, + onImported: (file: File, durationMs: number) => void + ) => { + setCustomError(null); + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'audio/*'; + input.addEventListener('change', async () => { + const file = input.files?.[0]; + if (!file) return; - try { - const durationMs = await readAudioDurationMs(file); - const validation = validateCustomCallRingtone({ - fileName: file.name, - mimeType: file.type, - sizeBytes: file.size, - durationMs, - }); - if (!validation.valid) { - if (validation.reason === 'type') { - setCustomError('Only audio files are supported.'); + try { + const durationMs = await readAudioDurationMs(file); + const validation = validateCustomCallRingtone({ + fileName: file.name, + mimeType: file.type, + sizeBytes: file.size, + durationMs, + }); + if (!validation.valid) { + setCustomError(customToneValidationError(validation.reason, label)); return; } - if (validation.reason === 'size') { - setCustomError( - `File is too large. Max ${bytesToSize(CUSTOM_CALL_RINGTONE_MAX_BYTES)} allowed.` - ); - return; - } - setCustomError( - `Ringtone must be between 1s and ${millisecondsToMinutesAndSeconds( - CUSTOM_CALL_RINGTONE_MAX_DURATION_MS - )}.` - ); - return; + + await putTone(file, durationMs); + onImported(file, durationMs); + } catch { + setCustomError('Could not import this file. Try a different audio format.'); } + }); - await putCustomCallRingtone(file, durationMs); - setHasCustomRingtone(true); - setCallRingtoneId('custom'); - setCallCustomRingtoneName(file.name); - setCallCustomRingtoneSizeBytes(file.size); - setCallCustomRingtoneDurationMs(durationMs); - } catch { - setCustomError('Could not import this file. Try a different audio format.'); - } - }); + input.click(); + }, + [] + ); - input.click(); + const handleImportCustomRingtone = useCallback(() => { + importCustomTone('Ringtone', putCustomCallRingtone, (file, durationMs) => { + setHasCustomRingtone(true); + setCallRingtoneId('custom'); + setCallCustomRingtoneName(file.name); + setCallCustomRingtoneSizeBytes(file.size); + setCallCustomRingtoneDurationMs(durationMs); + }); }, [ + importCustomTone, setCallCustomRingtoneDurationMs, setCallCustomRingtoneName, setCallCustomRingtoneSizeBytes, @@ -310,54 +431,15 @@ export function CallSoundSettings() { ]); const handleImportCustomRingback = useCallback(() => { - setCustomError(null); - const input = document.createElement('input'); - input.type = 'file'; - input.accept = 'audio/*'; - input.addEventListener('change', async () => { - const file = input.files?.[0]; - if (!file) return; - - try { - const durationMs = await readAudioDurationMs(file); - const validation = validateCustomCallRingtone({ - fileName: file.name, - mimeType: file.type, - sizeBytes: file.size, - durationMs, - }); - if (!validation.valid) { - if (validation.reason === 'type') { - setCustomError('Only audio files are supported.'); - return; - } - if (validation.reason === 'size') { - setCustomError( - `File is too large. Max ${bytesToSize(CUSTOM_CALL_RINGTONE_MAX_BYTES)} allowed.` - ); - return; - } - setCustomError( - `Ringback must be between 1s and ${millisecondsToMinutesAndSeconds( - CUSTOM_CALL_RINGTONE_MAX_DURATION_MS - )}.` - ); - return; - } - - await putCustomCallRingback(file, durationMs); - setHasCustomRingback(true); - setCallRingbackTone('custom'); - setCallCustomRingbackName(file.name); - setCallCustomRingbackSizeBytes(file.size); - setCallCustomRingbackDurationMs(durationMs); - } catch { - setCustomError('Could not import this file. Try a different audio format.'); - } + importCustomTone('Ringback', putCustomCallRingback, (file, durationMs) => { + setHasCustomRingback(true); + setCallRingbackTone('custom'); + setCallCustomRingbackName(file.name); + setCallCustomRingbackSizeBytes(file.size); + setCallCustomRingbackDurationMs(durationMs); }); - - input.click(); }, [ + importCustomTone, setCallCustomRingbackDurationMs, setCallCustomRingbackName, setCallCustomRingbackSizeBytes, @@ -501,152 +583,44 @@ export function CallSoundSettings() { } /> - - - - - - - - - - - - Max file size: {bytesToSize(CUSTOM_CALL_RINGTONE_MAX_BYTES)}. Max duration:{' '} - {millisecondsToMinutesAndSeconds(CUSTOM_CALL_RINGTONE_MAX_DURATION_MS)}. - - {customError && ( - - {customError} - - )} - - - - - - - - - - - - - - Max file size: {bytesToSize(CUSTOM_CALL_RINGTONE_MAX_BYTES)}. Max duration:{' '} - {millisecondsToMinutesAndSeconds(CUSTOM_CALL_RINGTONE_MAX_DURATION_MS)}. - - - - + + + {customError && ( + + {customError} + + )} ); } diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index a3d69c9a1..d53f867e9 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -41,24 +41,39 @@ const FALLBACK_INTERVAL_MS = 5_000; const OUTGOING_RING_TIMEOUT_MS = 30_000; type SessionDescription = Parameters[1]; +type RtcMembership = { userId?: string; sender?: string }; const getRoomMemberships = (room: Room, sessionDescription: SessionDescription) => MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription); +const getCallMembershipPresence = ( + mxUserId: string, + room: Room, + sessionDescription: SessionDescription +) => { + const memberships = getRoomMemberships(room, sessionDescription) as RtcMembership[]; + const remoteMemberCount = memberships.filter( + (membership) => (membership.userId || membership.sender) !== mxUserId + ).length; + const hasSelfMember = memberships.some( + (membership) => (membership.userId || membership.sender) === mxUserId + ); + + return { hasSelfMember, remoteMemberCount }; +}; + const isIncomingCallActive = ( mxUserId: string, room: Room, sessionDescription: SessionDescription ): boolean => { - const memberships = getRoomMemberships(room, sessionDescription); - const remoteMembers = memberships.filter( - (m: { userId?: string; sender?: string }) => (m.userId || m.sender) !== mxUserId - ); - const selfMember = memberships.some( - (m: { userId?: string; sender?: string }) => (m.userId || m.sender) === mxUserId + const { hasSelfMember, remoteMemberCount } = getCallMembershipPresence( + mxUserId, + room, + sessionDescription ); - return remoteMembers.length > 0 && !selfMember; + return remoteMemberCount > 0 && !hasSelfMember; }; const isCallActive = ( @@ -66,15 +81,13 @@ const isCallActive = ( room: Room, sessionDescription: SessionDescription ): boolean => { - const memberships = getRoomMemberships(room, sessionDescription); - const remoteMembers = memberships.filter( - (m: { userId?: string; sender?: string }) => (m.userId || m.sender) !== mxUserId - ); - const selfMember = memberships.some( - (m: { userId?: string; sender?: string }) => (m.userId || m.sender) === mxUserId + const { hasSelfMember, remoteMemberCount } = getCallMembershipPresence( + mxUserId, + room, + sessionDescription ); - return selfMember && remoteMembers.length > 0; + return hasSelfMember && remoteMemberCount > 0; }; const isOutgoingCallPending = ( @@ -82,15 +95,13 @@ const isOutgoingCallPending = ( room: Room, sessionDescription: SessionDescription ): boolean => { - const memberships = getRoomMemberships(room, sessionDescription); - const remoteMembers = memberships.filter( - (m: { userId?: string; sender?: string }) => (m.userId || m.sender) !== mxUserId - ); - const selfMember = memberships.some( - (m: { userId?: string; sender?: string }) => (m.userId || m.sender) === mxUserId + const { hasSelfMember, remoteMemberCount } = getCallMembershipPresence( + mxUserId, + room, + sessionDescription ); - return selfMember && remoteMembers.length === 0; + return hasSelfMember && remoteMemberCount === 0; }; const decryptWithTimeout = async ( diff --git a/src/app/plugins/markdown/markdownToHtml.ts b/src/app/plugins/markdown/markdownToHtml.ts index 58e3439e9..8bdc41200 100644 --- a/src/app/plugins/markdown/markdownToHtml.ts +++ b/src/app/plugins/markdown/markdownToHtml.ts @@ -77,7 +77,7 @@ const shieldBareMatrixToLinks = ( const unshieldBareMatrixToLinks = (html: string, placeholders: Map): string => { let result = html; - const keys = [...placeholders.keys()].toSorted((a, b) => b.length - a.length); + const keys = [...placeholders.keys()].sort((a, b) => b.length - a.length); for (const key of keys) { const url = placeholders.get(key); if (url) result = result.split(key).join(escapeHtml(url)); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 832fe9834..9be3792b0 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -29,12 +29,14 @@ export enum ShowRoomIcon { Never = 'never', } export type JumboEmojiSize = 'none' | 'extraSmall' | 'small' | 'normal' | 'large' | 'extraLarge'; -export type CallRingtoneId = - | 'sable-default' - | 'classic-soft' - | 'minimal-ping' - | 'silent' - | 'custom'; +export const CALL_TONE_IDS = [ + 'sable-default', + 'classic-soft', + 'minimal-ping', + 'silent', + 'custom', +] as const; +export type CallRingtoneId = (typeof CALL_TONE_IDS)[number]; export type CallRingbackTone = CallRingtoneId; export type ThemeRemoteFavorite = { @@ -364,6 +366,18 @@ function cloneDefaultSettings(): Settings { }; } +const CALL_TONE_ID_SET = new Set(CALL_TONE_IDS); +const CALL_AUDIO_METADATA_NUMBER_KEYS = [ + 'callCustomRingtoneSizeBytes', + 'callCustomRingtoneDurationMs', + 'callCustomRingbackSizeBytes', + 'callCustomRingbackDurationMs', +] as const; + +const isCallToneId = (value: unknown): value is CallRingtoneId => CALL_TONE_ID_SET.has(value); + +const clampPercent = (value: number): number => Math.max(0, Math.min(100, Math.round(value))); + function migrateParsedLocalStorage(parsed: Record): void { if (parsed.monochromeMode === true && parsed.saturationLevel === undefined) { parsed.saturationLevel = 0; @@ -393,16 +407,10 @@ function migrateParsedLocalStorage(parsed: Record): void { delete parsed.themeChatPreviewApprovedCatalogOnly; if (typeof parsed.callRingtoneVolume === 'number' && Number.isFinite(parsed.callRingtoneVolume)) { - parsed.callRingtoneVolume = Math.max(0, Math.min(100, Math.round(parsed.callRingtoneVolume))); + parsed.callRingtoneVolume = clampPercent(parsed.callRingtoneVolume); } - if ( - parsed.callRingtoneId !== 'sable-default' && - parsed.callRingtoneId !== 'classic-soft' && - parsed.callRingtoneId !== 'minimal-ping' && - parsed.callRingtoneId !== 'silent' && - parsed.callRingtoneId !== 'custom' - ) { + if (!isCallToneId(parsed.callRingtoneId)) { delete parsed.callRingtoneId; } @@ -412,44 +420,15 @@ function migrateParsedLocalStorage(parsed: Record): void { parsed.callRingbackTone = 'classic-soft'; } - if ( - parsed.callRingbackTone !== 'sable-default' && - parsed.callRingbackTone !== 'classic-soft' && - parsed.callRingbackTone !== 'minimal-ping' && - parsed.callRingbackTone !== 'silent' && - parsed.callRingbackTone !== 'custom' - ) { + if (!isCallToneId(parsed.callRingbackTone)) { delete parsed.callRingbackTone; } - if ( - typeof parsed.callCustomRingtoneSizeBytes === 'number' && - (!Number.isFinite(parsed.callCustomRingtoneSizeBytes) || parsed.callCustomRingtoneSizeBytes < 0) - ) { - delete parsed.callCustomRingtoneSizeBytes; - } - - if ( - typeof parsed.callCustomRingtoneDurationMs === 'number' && - (!Number.isFinite(parsed.callCustomRingtoneDurationMs) || - parsed.callCustomRingtoneDurationMs < 0) - ) { - delete parsed.callCustomRingtoneDurationMs; - } - - if ( - typeof parsed.callCustomRingbackSizeBytes === 'number' && - (!Number.isFinite(parsed.callCustomRingbackSizeBytes) || parsed.callCustomRingbackSizeBytes < 0) - ) { - delete parsed.callCustomRingbackSizeBytes; - } - - if ( - typeof parsed.callCustomRingbackDurationMs === 'number' && - (!Number.isFinite(parsed.callCustomRingbackDurationMs) || - parsed.callCustomRingbackDurationMs < 0) - ) { - delete parsed.callCustomRingbackDurationMs; + for (const key of CALL_AUDIO_METADATA_NUMBER_KEYS) { + const value = parsed[key]; + if (typeof value === 'number' && (!Number.isFinite(value) || value < 0)) { + delete parsed[key]; + } } } @@ -563,24 +542,11 @@ function sanitizeSettingsKey(key: keyof Settings, val: unknown): unknown { case 'rightSwipeAction': return val === RightSwipeAction.Members || val === RightSwipeAction.Reply ? val : undefined; case 'callRingtoneId': - return val === 'sable-default' || - val === 'classic-soft' || - val === 'minimal-ping' || - val === 'silent' || - val === 'custom' - ? val - : undefined; case 'callRingbackTone': - return val === 'sable-default' || - val === 'classic-soft' || - val === 'minimal-ping' || - val === 'silent' || - val === 'custom' - ? val - : undefined; + return isCallToneId(val) ? val : undefined; case 'callRingtoneVolume': if (typeof val !== 'number' || !Number.isFinite(val)) return undefined; - return Math.max(0, Math.min(100, Math.round(val))); + return clampPercent(val); case 'callCustomRingtoneSizeBytes': case 'callCustomRingtoneDurationMs': case 'callCustomRingbackSizeBytes': From e58fa0fe926bab46a78a01da43c0a4af8206fef2 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 21:29:43 -0500 Subject: [PATCH 19/27] receive call declines and cleanup --- .../message/content/ImageContent.tsx | 29 ++-- .../call/rtcNotificationParser.test.ts | 51 ++++++ .../features/call/rtcNotificationParser.ts | 27 +++ .../general/CallSoundSettings.test.tsx | 1 + src/app/hooks/useCallSignaling.ts | 155 +++++++++++++++++- src/app/plugins/markdown/markdownToHtml.ts | 2 +- 6 files changed, 247 insertions(+), 18 deletions(-) diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 13868c4bf..c6d43e672 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -142,24 +142,21 @@ export const ImageContent = as<'div', ImageContentProps>( ); useEffect(() => { + let cancelled = false; if (!viewer) { setViewerFullSrc(null); - return; - } - if ( - typeof matrixThumbnailMaxEdge !== 'number' || - matrixThumbnailMaxEdge <= 0 || - encInfo || - url.startsWith('http') + } else if ( + typeof matrixThumbnailMaxEdge === 'number' && + matrixThumbnailMaxEdge > 0 && + !encInfo && + !url.startsWith('http') ) { - return; + void (async () => { + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl || cancelled) return; + setViewerFullSrc(mediaUrl); + })(); } - let cancelled = false; - void (async () => { - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); - if (!mediaUrl || cancelled) return; - setViewerFullSrc(mediaUrl); - })(); return () => { cancelled = true; }; @@ -195,12 +192,14 @@ export const ImageContent = as<'div', ImageContentProps>( const rootClass = isContained ? css.ContainedMediaRoot : css.RelativeBase; const stripMin = containedStripMinPx ?? 56; + const imageWidth = info?.w; + const imageHeight = info?.h; const intrinsicSizingStyle = fillsSlot ? {} : isContained ? { minHeight: containedReserveStrip ? toRem(stripMin) : undefined } : hasDimensions - ? { aspectRatio: `${info!.w} / ${info!.h}` } + ? { aspectRatio: `${imageWidth} / ${imageHeight}` } : { minHeight: '150px' }; const fillPreviewSlotStyle = fillsSlot diff --git a/src/app/features/call/rtcNotificationParser.test.ts b/src/app/features/call/rtcNotificationParser.test.ts index de38f84c5..2871341f8 100644 --- a/src/app/features/call/rtcNotificationParser.test.ts +++ b/src/app/features/call/rtcNotificationParser.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { + parseRtcDecline, parseIncomingRtcNotification, + RTC_DECLINE_EVENT_TYPE, REFERENCE_REL_TYPE, RTC_NOTIFICATION_EVENT_TYPE, type RtcNotificationEventLike, @@ -205,3 +207,52 @@ describe('parseIncomingRtcNotification', () => { expect(video?.intentKind).toBe('video'); }); }); + +describe('parseRtcDecline', () => { + it('parses a live remote decline referencing a notification event', () => { + const parsed = parseRtcDecline( + createEvent({ + type: RTC_DECLINE_EVENT_TYPE, + eventId: '$decline', + content: {}, + relation: { + rel_type: REFERENCE_REL_TYPE, + event_id: '$notif', + }, + }), + { myUserId: '@self:example.org' } + ); + + expect(parsed).toEqual({ + roomId: '!room:example.org', + declineEventId: '$decline', + notificationEventId: '$notif', + senderId: '@caller:example.org', + }); + }); + + it('ignores self-sent declines and declines without reference relations', () => { + expect( + parseRtcDecline( + createEvent({ + type: RTC_DECLINE_EVENT_TYPE, + sender: '@self:example.org', + }), + { myUserId: '@self:example.org' } + ) + ).toBeUndefined(); + + expect( + parseRtcDecline( + createEvent({ + type: RTC_DECLINE_EVENT_TYPE, + relation: { + rel_type: 'm.thread', + event_id: '$notif', + }, + }), + { myUserId: '@self:example.org' } + ) + ).toBeUndefined(); + }); +}); diff --git a/src/app/features/call/rtcNotificationParser.ts b/src/app/features/call/rtcNotificationParser.ts index 959ce52c5..b86cc4627 100644 --- a/src/app/features/call/rtcNotificationParser.ts +++ b/src/app/features/call/rtcNotificationParser.ts @@ -1,4 +1,5 @@ export const RTC_NOTIFICATION_EVENT_TYPE = 'org.matrix.msc4075.rtc.notification'; +export const RTC_DECLINE_EVENT_TYPE = 'org.matrix.msc4310.rtc.decline'; export const REFERENCE_REL_TYPE = 'm.reference'; export type NotificationType = 'ring' | 'notification'; @@ -51,6 +52,13 @@ export type ParsedIncomingRtcNotification = { intentRaw?: string; }; +export type ParsedRtcDecline = { + roomId: string; + declineEventId: string; + notificationEventId: string; + senderId: string; +}; + const isObject = (value: unknown): value is Record => typeof value === 'object' && value !== null; @@ -113,3 +121,22 @@ export const parseIncomingRtcNotification = async ( intentRaw, }; }; + +export const parseRtcDecline = ( + event: RtcNotificationEventLike, + options: Pick +): ParsedRtcDecline | undefined => { + if (!event.isLiveEvent) return undefined; + if (event.type !== RTC_DECLINE_EVENT_TYPE) return undefined; + if (event.sender === options.myUserId) return undefined; + if (event.relation?.rel_type !== REFERENCE_REL_TYPE || !event.relation.event_id) { + return undefined; + } + + return { + roomId: event.roomId, + declineEventId: event.eventId, + notificationEventId: event.relation.event_id, + senderId: event.sender, + }; +}; diff --git a/src/app/features/settings/general/CallSoundSettings.test.tsx b/src/app/features/settings/general/CallSoundSettings.test.tsx index b3aee782e..3e9815251 100644 --- a/src/app/features/settings/general/CallSoundSettings.test.tsx +++ b/src/app/features/settings/general/CallSoundSettings.test.tsx @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { CallSoundSettings } from './CallSoundSettings'; vi.mock('$state/settings', () => ({ + CALL_TONE_IDS: ['sable-default', 'classic-soft', 'minimal-ping', 'silent', 'custom'], settingsAtom: {}, })); diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index d53f867e9..16d1c43d1 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef } from 'react'; import * as Sentry from '@sentry/react'; -import { useAtomValue, useSetAtom } from 'jotai'; +import { useAtomValue, useSetAtom, useStore } from 'jotai'; import type { RoomEventHandlerMap, MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; import { type CryptoBackend, @@ -18,7 +18,9 @@ import { } from '$state/callEmbed'; import { settingsAtom } from '$state/settings'; import { + parseRtcDecline, parseIncomingRtcNotification, + RTC_DECLINE_EVENT_TYPE, REFERENCE_REL_TYPE, RTC_NOTIFICATION_EVENT_TYPE, } from '$features/call/rtcNotificationParser'; @@ -146,6 +148,7 @@ const canSenderStartCalls = (room: Room, senderId: string): boolean => export function useIncomingCallSignaling() { const mx = useMatrixClient(); + const store = useStore(); const callEmbed = useAtomValue(callEmbedAtom); const mDirects = useAtomValue(mDirectAtom); const settings = useAtomValue(settingsAtom); @@ -154,6 +157,7 @@ export function useIncomingCallSignaling() { const setIncomingCall = useSetAtom(incomingCallAtom); const setMutedRoomId = useSetAtom(mutedCallRoomIdAtom); const setCallSoundBlocked = useSetAtom(callSoundBlockedAtom); + const setCallEmbed = useSetAtom(callEmbedAtom); const incomingAudioRef = useRef(null); const outgoingAudioRef = useRef(null); @@ -161,11 +165,20 @@ export function useIncomingCallSignaling() { const mutedRoomIdRef = useRef(mutedRoomId); const seenNotificationIdsRef = useRef>(new Set()); const outgoingRingRoomIdRef = useRef(null); + const declinedOutgoingRoomIdRef = useRef(null); + const outgoingDeclinesRef = useRef< + Map }> + >(new Map()); const outgoingStartRef = useRef(null); incomingCallRef.current = incomingCall; mutedRoomIdRef.current = mutedRoomId; + useEffect(() => { + declinedOutgoingRoomIdRef.current = null; + outgoingDeclinesRef.current.clear(); + }, [callEmbed]); + useEffect(() => { const incoming = new Audio(); incoming.loop = true; @@ -277,6 +290,87 @@ export function useIncomingCallSignaling() { } }, [setIncomingCall, stopIncomingRing]); + const handleOutgoingDecline = useCallback( + (decline: { + roomId: string; + declineEventId: string; + notificationEventId: string; + senderId: string; + }) => { + if (!callEmbed || callEmbed.roomId !== decline.roomId) return; + + const outgoingRoom = mx.getRoom(decline.roomId); + if (!outgoingRoom) return; + + const isDirectRoom = mDirects.has(decline.roomId); + const remoteJoinedIds = new Set( + outgoingRoom + .getJoinedMembers() + .map((member) => member.userId) + .filter((userId) => userId !== mx.getSafeUserId()) + ); + + const trackedDecline = outgoingDeclinesRef.current.get(decline.roomId); + const declineState = + trackedDecline && trackedDecline.notificationEventId === decline.notificationEventId + ? trackedDecline + : { + notificationEventId: decline.notificationEventId, + declinerIds: new Set(), + }; + declineState.declinerIds.add(decline.senderId); + outgoingDeclinesRef.current.set(decline.roomId, declineState); + + const allRemoteDeclined = + remoteJoinedIds.size > 0 && + [...remoteJoinedIds].every((userId) => declineState.declinerIds.has(userId)); + + if (!isDirectRoom && remoteJoinedIds.size > 0 && !allRemoteDeclined) { + debugLog.info('call', 'Ignoring partial outgoing decline for group call', { + roomId: decline.roomId, + declineEventId: decline.declineEventId, + notificationEventId: decline.notificationEventId, + declinedCount: declineState.declinerIds.size, + targetCount: remoteJoinedIds.size, + }); + Sentry.metrics.count('sable.call.outgoing.declined.partial', 1); + return; + } + + declinedOutgoingRoomIdRef.current = decline.roomId; + debugLog.info('call', 'Outgoing call declined and ending call', { + roomId: decline.roomId, + declineEventId: decline.declineEventId, + notificationEventId: decline.notificationEventId, + declinedCount: declineState.declinerIds.size, + targetCount: remoteJoinedIds.size, + }); + Sentry.metrics.count('sable.call.outgoing.declined', 1); + stopOutgoingRing(); + + // Run the same hangup path as the "End call" controls so MatrixRTC membership + // is properly cleared before we dispose the embed. + void callEmbed + .hangup() + .catch((error) => { + debugLog.warn('call', 'Failed to hang up after outgoing decline', { + roomId: decline.roomId, + error: error instanceof Error ? error.message : String(error), + }); + Sentry.metrics.count('sable.call.outgoing.decline_hangup_error', 1); + }) + .finally(() => { + // If the widget doesn't close itself after hangup, force cleanup as a fallback. + window.setTimeout(() => { + const activeEmbed = store.get(callEmbedAtom); + if (activeEmbed !== callEmbed) return; + setCallEmbed(undefined); + }, 2_000); + }); + }, + [callEmbed, mDirects, mx, setCallEmbed, stopOutgoingRing, store] + ); + const callAudioAllowed = canPlayCallAudio({ isNotificationSounds: settings.isNotificationSounds, callSoundOverrideGlobalNotifications: settings.callSoundOverrideGlobalNotifications, @@ -409,6 +503,46 @@ export function useIncomingCallSignaling() { }; }; + const parseDeclineEvent = async ( + event: MatrixEvent, + room: Room, + liveEvent: boolean + ): Promise> => { + const relation = event.getRelation(); + if (relation?.rel_type !== REFERENCE_REL_TYPE || !relation.event_id) return undefined; + + let eventType = event.getType(); + let content = event.getContent(); + + if (event.isEncrypted()) { + const decrypted = await decryptWithTimeout(event, mx); + if (!decrypted?.content || !decrypted.type) { + Sentry.metrics.count('sable.call.signal.decrypt_timeout', 1); + return undefined; + } + eventType = decrypted.type; + content = decrypted.content; + } + + return parseRtcDecline( + { + type: eventType, + sender: event.getSender() ?? '', + roomId: room.roomId, + eventId: event.getId() ?? '', + originServerTs: event.getTs(), + content, + relation: { + rel_type: relation.rel_type, + event_id: relation.event_id, + }, + isLiveEvent: liveEvent, + isEncrypted: false, + }, + { myUserId } + ); + }; + const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = async ( event, room, @@ -422,10 +556,22 @@ export function useIncomingCallSignaling() { if (relation?.rel_type !== REFERENCE_REL_TYPE) return; const type = event.getType(); - if (type !== RTC_NOTIFICATION_EVENT_TYPE && !event.isEncrypted()) return; + if ( + type !== RTC_NOTIFICATION_EVENT_TYPE && + type !== RTC_DECLINE_EVENT_TYPE && + !event.isEncrypted() + ) { + return; + } if (event.getSender() === myUserId) return; if (!event.getId()) return; + const decline = await parseDeclineEvent(event, room, data.liveEvent); + if (decline) { + handleOutgoingDecline(decline); + return; + } + const incoming = await parseEvent(event, room, data.liveEvent); if (!incoming) return; @@ -473,6 +619,10 @@ export function useIncomingCallSignaling() { stopOutgoingRing(); return; } + if (declinedOutgoingRoomIdRef.current === activeCallRoomId) { + stopOutgoingRing(); + return; + } const outgoingRoom = mx.getRoom(activeCallRoomId); if (!outgoingRoom) { @@ -536,6 +686,7 @@ export function useIncomingCallSignaling() { mDirects, outgoingRingbackAllowed, handleIncomingCall, + handleOutgoingDecline, clearIncomingCall, stopIncomingRing, stopOutgoingRing, diff --git a/src/app/plugins/markdown/markdownToHtml.ts b/src/app/plugins/markdown/markdownToHtml.ts index 8bdc41200..58e3439e9 100644 --- a/src/app/plugins/markdown/markdownToHtml.ts +++ b/src/app/plugins/markdown/markdownToHtml.ts @@ -77,7 +77,7 @@ const shieldBareMatrixToLinks = ( const unshieldBareMatrixToLinks = (html: string, placeholders: Map): string => { let result = html; - const keys = [...placeholders.keys()].sort((a, b) => b.length - a.length); + const keys = [...placeholders.keys()].toSorted((a, b) => b.length - a.length); for (const key of keys) { const url = placeholders.get(key); if (url) result = result.split(key).join(escapeHtml(url)); From e27ccae261b72317c12c651d22a37238e1a283c3 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 21:31:49 -0500 Subject: [PATCH 20/27] formatting --- src/app/plugins/call/CallControl.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index 834d18674..f639e3434 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -193,7 +193,8 @@ export class CallControl extends EventEmitter implements CallControlState { | ((this: AudioContext) => Promise) | undefined; if (typeof originalResumeImpl !== 'function') return; - const originalResume = (context: AudioContext): Promise => originalResumeImpl.call(context); + const originalResume = (context: AudioContext): Promise => + originalResumeImpl.call(context); const trackedContexts = this.trackedAudioContexts; const isOverrideMuted = () => this.outputOverrideMuted; originalCtor.prototype.resume = function patchedResume(this: AudioContext) { From 3c1f92587d3faeb31a5699d9cc596ee1a3c9f0e7 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 22:29:18 -0500 Subject: [PATCH 21/27] make ringtone work again had to ai this one ngl --- .../call/rtcNotificationParser.test.ts | 2 +- .../features/call/rtcNotificationParser.ts | 8 +- src/app/hooks/useCallSignaling.ts | 192 +++++++++++++++--- 3 files changed, 174 insertions(+), 28 deletions(-) diff --git a/src/app/features/call/rtcNotificationParser.test.ts b/src/app/features/call/rtcNotificationParser.test.ts index 2871341f8..95709239f 100644 --- a/src/app/features/call/rtcNotificationParser.test.ts +++ b/src/app/features/call/rtcNotificationParser.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; import { parseRtcDecline, - parseIncomingRtcNotification, RTC_DECLINE_EVENT_TYPE, + parseIncomingRtcNotification, REFERENCE_REL_TYPE, RTC_NOTIFICATION_EVENT_TYPE, type RtcNotificationEventLike, diff --git a/src/app/features/call/rtcNotificationParser.ts b/src/app/features/call/rtcNotificationParser.ts index b86cc4627..fe0cdd310 100644 --- a/src/app/features/call/rtcNotificationParser.ts +++ b/src/app/features/call/rtcNotificationParser.ts @@ -129,14 +129,14 @@ export const parseRtcDecline = ( if (!event.isLiveEvent) return undefined; if (event.type !== RTC_DECLINE_EVENT_TYPE) return undefined; if (event.sender === options.myUserId) return undefined; - if (event.relation?.rel_type !== REFERENCE_REL_TYPE || !event.relation.event_id) { - return undefined; - } return { roomId: event.roomId, declineEventId: event.eventId, - notificationEventId: event.relation.event_id, + notificationEventId: + event.relation?.rel_type === REFERENCE_REL_TYPE && event.relation.event_id + ? event.relation.event_id + : event.eventId, senderId: event.sender, }; }; diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index 16d1c43d1..eb99d63d5 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -41,6 +41,27 @@ const MAX_NOTIFICATION_LIFETIME_MS = 120_000; const DECRYPT_TIMEOUT_MS = 8_000; const FALLBACK_INTERVAL_MS = 5_000; const OUTGOING_RING_TIMEOUT_MS = 30_000; +const DECLINE_DEBUG_MAX = 200; + +type DeclineDebugEntry = { + ts: number; + phase: string; + roomId?: string; + eventId?: string; + eventType?: string; + encrypted?: boolean; + detail?: string; +}; + +const pushDeclineDebug = (entry: DeclineDebugEntry): void => { + const scope = globalThis as typeof globalThis & { + __sableDeclineDebug?: DeclineDebugEntry[]; + }; + const queue = scope.__sableDeclineDebug ?? []; + queue.push(entry); + if (queue.length > DECLINE_DEBUG_MAX) queue.shift(); + scope.__sableDeclineDebug = queue; +}; type SessionDescription = Parameters[1]; type RtcMembership = { userId?: string; sender?: string }; @@ -297,10 +318,30 @@ export function useIncomingCallSignaling() { notificationEventId: string; senderId: string; }) => { - if (!callEmbed || callEmbed.roomId !== decline.roomId) return; + if (!callEmbed || callEmbed.roomId !== decline.roomId) { + pushDeclineDebug({ + ts: Date.now(), + phase: 'handle_skip', + roomId: decline.roomId, + eventId: decline.declineEventId, + eventType: RTC_DECLINE_EVENT_TYPE, + detail: 'no_active_embed_for_room', + }); + return; + } const outgoingRoom = mx.getRoom(decline.roomId); - if (!outgoingRoom) return; + if (!outgoingRoom) { + pushDeclineDebug({ + ts: Date.now(), + phase: 'handle_skip', + roomId: decline.roomId, + eventId: decline.declineEventId, + eventType: RTC_DECLINE_EVENT_TYPE, + detail: 'missing_room', + }); + return; + } const isDirectRoom = mDirects.has(decline.roomId); const remoteJoinedIds = new Set( @@ -324,8 +365,17 @@ export function useIncomingCallSignaling() { const allRemoteDeclined = remoteJoinedIds.size > 0 && [...remoteJoinedIds].every((userId) => declineState.declinerIds.has(userId)); + const treatAsOneToOne = isDirectRoom || remoteJoinedIds.size <= 1; - if (!isDirectRoom && remoteJoinedIds.size > 0 && !allRemoteDeclined) { + if (!treatAsOneToOne && remoteJoinedIds.size > 0 && !allRemoteDeclined) { + pushDeclineDebug({ + ts: Date.now(), + phase: 'handle_partial', + roomId: decline.roomId, + eventId: decline.declineEventId, + eventType: RTC_DECLINE_EVENT_TYPE, + detail: `${declineState.declinerIds.size}/${remoteJoinedIds.size}`, + }); debugLog.info('call', 'Ignoring partial outgoing decline for group call', { roomId: decline.roomId, declineEventId: decline.declineEventId, @@ -338,6 +388,14 @@ export function useIncomingCallSignaling() { } declinedOutgoingRoomIdRef.current = decline.roomId; + pushDeclineDebug({ + ts: Date.now(), + phase: 'handle_end', + roomId: decline.roomId, + eventId: decline.declineEventId, + eventType: RTC_DECLINE_EVENT_TYPE, + detail: `${declineState.declinerIds.size}/${remoteJoinedIds.size}`, + }); debugLog.info('call', 'Outgoing call declined and ending call', { roomId: decline.roomId, declineEventId: decline.declineEventId, @@ -348,11 +406,17 @@ export function useIncomingCallSignaling() { Sentry.metrics.count('sable.call.outgoing.declined', 1); stopOutgoingRing(); - // Run the same hangup path as the "End call" controls so MatrixRTC membership - // is properly cleared before we dispose the embed. void callEmbed .hangup() .catch((error) => { + pushDeclineDebug({ + ts: Date.now(), + phase: 'hangup_error', + roomId: decline.roomId, + eventId: decline.declineEventId, + eventType: RTC_DECLINE_EVENT_TYPE, + detail: error instanceof Error ? error.message : String(error), + }); debugLog.warn('call', 'Failed to hang up after outgoing decline', { roomId: decline.roomId, error: error instanceof Error ? error.message : String(error), @@ -360,7 +424,13 @@ export function useIncomingCallSignaling() { Sentry.metrics.count('sable.call.outgoing.decline_hangup_error', 1); }) .finally(() => { - // If the widget doesn't close itself after hangup, force cleanup as a fallback. + pushDeclineDebug({ + ts: Date.now(), + phase: 'hangup_finally', + roomId: decline.roomId, + eventId: decline.declineEventId, + eventType: RTC_DECLINE_EVENT_TYPE, + }); window.setTimeout(() => { const activeEmbed = store.get(callEmbedAtom); if (activeEmbed !== callEmbed) return; @@ -508,23 +578,57 @@ export function useIncomingCallSignaling() { room: Room, liveEvent: boolean ): Promise> => { - const relation = event.getRelation(); - if (relation?.rel_type !== REFERENCE_REL_TYPE || !relation.event_id) return undefined; - + pushDeclineDebug({ + ts: Date.now(), + phase: 'parse_start', + roomId: room.roomId, + eventId: event.getId() ?? undefined, + eventType: event.getType(), + encrypted: event.isEncrypted(), + }); let eventType = event.getType(); let content = event.getContent(); if (event.isEncrypted()) { const decrypted = await decryptWithTimeout(event, mx); if (!decrypted?.content || !decrypted.type) { + pushDeclineDebug({ + ts: Date.now(), + phase: 'parse_decrypt_fallback', + roomId: room.roomId, + eventId: event.getId() ?? undefined, + eventType: event.getType(), + encrypted: event.isEncrypted(), + detail: 'decrypt_timeout_or_missing_content', + }); Sentry.metrics.count('sable.call.signal.decrypt_timeout', 1); - return undefined; + } else { + eventType = decrypted.type; + content = decrypted.content; + pushDeclineDebug({ + ts: Date.now(), + phase: 'parse_decrypted', + roomId: room.roomId, + eventId: event.getId() ?? undefined, + eventType, + encrypted: event.isEncrypted(), + }); } - eventType = decrypted.type; - content = decrypted.content; } - return parseRtcDecline( + const relationFromContent = (() => { + if (!content || typeof content !== 'object') return undefined; + const maybeRelates = (content as { 'm.relates_to'?: unknown })['m.relates_to']; + if (!maybeRelates || typeof maybeRelates !== 'object') return undefined; + const relation = maybeRelates as { rel_type?: unknown; event_id?: unknown }; + return { + rel_type: typeof relation.rel_type === 'string' ? relation.rel_type : undefined, + event_id: typeof relation.event_id === 'string' ? relation.event_id : undefined, + }; + })(); + const relation = event.getRelation() ?? relationFromContent; + + const parsed = parseRtcDecline( { type: eventType, sender: event.getSender() ?? '', @@ -532,15 +636,27 @@ export function useIncomingCallSignaling() { eventId: event.getId() ?? '', originServerTs: event.getTs(), content, - relation: { - rel_type: relation.rel_type, - event_id: relation.event_id, - }, + relation: relation + ? { + rel_type: relation.rel_type, + event_id: relation.event_id, + } + : undefined, isLiveEvent: liveEvent, isEncrypted: false, }, { myUserId } ); + pushDeclineDebug({ + ts: Date.now(), + phase: parsed ? 'parse_match' : 'parse_skip', + roomId: room.roomId, + eventId: event.getId() ?? undefined, + eventType, + encrypted: event.isEncrypted(), + detail: parsed ? 'decline_parsed' : 'type_or_sender_mismatch', + }); + return parsed; }; const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = async ( @@ -553,7 +669,7 @@ export function useIncomingCallSignaling() { if (!room || !data.liveEvent) return; const relation = event.getRelation(); - if (relation?.rel_type !== REFERENCE_REL_TYPE) return; + if (relation?.rel_type !== REFERENCE_REL_TYPE && !event.isEncrypted()) return; const type = event.getType(); if ( @@ -566,16 +682,46 @@ export function useIncomingCallSignaling() { if (event.getSender() === myUserId) return; if (!event.getId()) return; + const incoming = await parseEvent(event, room, data.liveEvent); + if (incoming) { + handleIncomingCall(incoming); + return; + } + + // Avoid decrypting unrelated encrypted timeline traffic; only inspect declines + // for the currently active outgoing call room. + const shouldCheckDecline = + !!callEmbed && + callEmbed.roomId === room.roomId && + (event.isEncrypted() || type === RTC_DECLINE_EVENT_TYPE); + if (!shouldCheckDecline) { + if (event.isEncrypted() || type === RTC_DECLINE_EVENT_TYPE) { + pushDeclineDebug({ + ts: Date.now(), + phase: 'timeline_skip', + roomId: room.roomId, + eventId: event.getId() ?? undefined, + eventType: type, + encrypted: event.isEncrypted(), + detail: `activeRoom=${callEmbed?.roomId ?? 'none'}`, + }); + } + return; + } + const decline = await parseDeclineEvent(event, room, data.liveEvent); if (decline) { + pushDeclineDebug({ + ts: Date.now(), + phase: 'timeline_match', + roomId: room.roomId, + eventId: event.getId() ?? undefined, + eventType: type, + encrypted: event.isEncrypted(), + }); handleOutgoingDecline(decline); return; } - - const incoming = await parseEvent(event, room, data.liveEvent); - if (!incoming) return; - - handleIncomingCall(incoming); }; const evaluateFallbackState = () => { From db808355e73254396d37662c562d1ba533e99a5a Mon Sep 17 00:00:00 2001 From: 7w1 Date: Mon, 18 May 2026 18:35:09 -0500 Subject: [PATCH 22/27] organize permission for calls --- .../permissions/PermissionGroups.tsx | 23 +++++++++++-------- .../common-settings/permissions/Powers.tsx | 2 +- .../permissions/callPermissions.ts | 17 ++++++++++++++ .../common-settings/permissions/index.ts | 1 + .../room-settings/permissions/Permissions.tsx | 2 +- .../permissions/usePermissionItems.ts | 20 ++++------------ .../permissions/usePermissionItems.ts | 2 ++ 7 files changed, 39 insertions(+), 28 deletions(-) create mode 100644 src/app/features/common-settings/permissions/callPermissions.ts diff --git a/src/app/features/common-settings/permissions/PermissionGroups.tsx b/src/app/features/common-settings/permissions/PermissionGroups.tsx index 9508741cc..86ff78ef4 100644 --- a/src/app/features/common-settings/permissions/PermissionGroups.tsx +++ b/src/app/features/common-settings/permissions/PermissionGroups.tsx @@ -41,9 +41,7 @@ export function PermissionGroups({ const powerLevelTags = usePowerLevelTags(room, powerLevels); const maxPower = useMemo(() => Math.max(...getPowers(powerLevelTags)), [powerLevelTags]); - const [permissionUpdate, setPermissionUpdate] = useState>( - new Map() - ); + const [permissionUpdate, setPermissionUpdate] = useState>(new Map()); useEffect(() => { // reset permission update if component rerender @@ -56,15 +54,16 @@ export function PermissionGroups({ newPower: number, currentPower: number ) => { + const locationKey = getPermissionLocationKey(location); setPermissionUpdate((p) => { const up: typeof p = new Map(); p.forEach((value, key) => { up.set(key, value); }); if (newPower === currentPower) { - up.delete(location); + up.delete(locationKey); } else { - up.set(location, newPower); + up.set(locationKey, newPower); } return up; }); @@ -79,8 +78,12 @@ export function PermissionGroups({ applyPermissionPower(draftPowerLevels, item.location, power); }) ); - permissionUpdate.forEach((power, location) => - applyPermissionPower(draftPowerLevels, location, power) + permissionUpdate.forEach((power, locationKey) => + applyPermissionPower( + draftPowerLevels, + JSON.parse(locationKey) as PermissionLocation, + power + ) ); return draftPowerLevels; @@ -110,7 +113,7 @@ export function PermissionGroups({ const renderUserGroup = () => { const power = getPermissionPower(powerLevels, USER_DEFAULT_LOCATION); - const powerUpdate = permissionUpdate.get(USER_DEFAULT_LOCATION); + const powerUpdate = permissionUpdate.get(getPermissionLocationKey(USER_DEFAULT_LOCATION)); const value = powerUpdate ?? power; const tag = getPowerLevelTag(powerLevelTags, value); @@ -175,7 +178,7 @@ export function PermissionGroups({ {group.name} {group.items.map((item) => { const power = getPermissionPower(powerLevels, item.location); - const powerUpdate = permissionUpdate.get(item.location); + const powerUpdate = permissionUpdate.get(getPermissionLocationKey(item.location)); const value = powerUpdate ?? power; const tag = getPowerLevelTag(powerLevelTags, value); @@ -183,7 +186,7 @@ export function PermissionGroups({ return ( { +export const usePermissionGroups = (): PermissionGroup[] => { const groups: PermissionGroup[] = useMemo(() => { const messagesGroup: PermissionGroup = { name: 'Messages', @@ -48,19 +49,6 @@ export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => { ], }; - const callSettingsGroup: PermissionGroup = { - name: 'Calls', - items: [ - { - location: { - state: true, - key: EventType.GroupCallMemberPrefix, - }, - name: 'Join Call', - }, - ], - }; - const moderationGroup: PermissionGroup = { name: 'Moderation', items: [ @@ -218,13 +206,13 @@ export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => { return [ messagesGroup, - ...(isCallRoom ? [callSettingsGroup] : []), + CALL_PERMISSIONS_GROUP, moderationGroup, roomOverviewGroup, roomSettingsGroup, otherSettingsGroup, ]; - }, [isCallRoom]); + }, []); return groups; }; diff --git a/src/app/features/space-settings/permissions/usePermissionItems.ts b/src/app/features/space-settings/permissions/usePermissionItems.ts index 697d98abe..958e64e3a 100644 --- a/src/app/features/space-settings/permissions/usePermissionItems.ts +++ b/src/app/features/space-settings/permissions/usePermissionItems.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import type { PermissionGroup } from '$features/common-settings/permissions'; +import { CALL_PERMISSIONS_GROUP } from '$features/common-settings/permissions/callPermissions'; import { EventType } from '$types/matrix-sdk'; import { CustomStateEvent } from '$types/matrix/room'; @@ -146,6 +147,7 @@ export const usePermissionGroups = (): PermissionGroup[] => { return [ messagesGroup, + CALL_PERMISSIONS_GROUP, moderationGroup, roomOverviewGroup, roomSettingsGroup, From 333fa55ec632f48384f9f2a08b5326eccde810d1 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Mon, 18 May 2026 18:50:33 -0500 Subject: [PATCH 23/27] remove outdated sound suppression --- src/app/hooks/useCallSignaling.ts | 6 +- src/app/plugins/call/CallControl.ts | 139 +--------------------------- 2 files changed, 2 insertions(+), 143 deletions(-) diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index eb99d63d5..854957923 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -295,12 +295,9 @@ export function useIncomingCallSignaling() { const stopOutgoingRing = useCallback(() => { outgoingAudioRef.current?.pause(); if (outgoingAudioRef.current) outgoingAudioRef.current.currentTime = 0; - if (callEmbed) { - callEmbed.control.setOutputOverrideMuted(false); - } outgoingRingRoomIdRef.current = null; outgoingStartRef.current = null; - }, [callEmbed]); + }, []); const clearIncomingCall = useCallback(() => { const activeIncomingCall = incomingCallRef.current; @@ -800,7 +797,6 @@ export function useIncomingCallSignaling() { return; } - callEmbed.control.setOutputOverrideMuted(true); outgoingAudioRef.current?.play().catch(() => { Sentry.metrics.count('sable.call.ringback.blocked', 1); }); diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index f639e3434..d25b21d6f 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -24,12 +24,6 @@ export class CallControl extends EventEmitter implements CallControlState { private iframe: HTMLIFrameElement; private controlMutationObserver: MutationObserver; - private audioMutationObserver: MutationObserver; - private outputOverrideMuted = false; - private patchedWindow: Window | undefined; - private readonly trackedAudioContexts = new Set(); - private readonly runningContextsBeforeOverride = new WeakMap(); - private readonly audioPatchRestores: Array<() => void> = []; private get document(): Document | undefined { return this.iframe.contentDocument ?? this.iframe.contentWindow?.document; @@ -63,9 +57,6 @@ export class CallControl extends EventEmitter implements CallControlState { this.iframe = iframe; this.controlMutationObserver = new MutationObserver(this.onControlMutation.bind(this)); - this.audioMutationObserver = new MutationObserver(() => { - this.applyOutputMute(); - }); } public getState(): CallControlState { @@ -126,34 +117,6 @@ export class CallControl extends EventEmitter implements CallControlState { this.setSound(this.sound); } - public setOutputOverrideMuted(muted: boolean) { - const win = this.iframe.contentWindow; - const callDocument = this.iframe.contentDocument ?? this.iframe.contentWindow?.document; - const windowChanged = !!win && this.patchedWindow !== win; - if (this.outputOverrideMuted === muted && !windowChanged) return; - this.outputOverrideMuted = muted; - - if (muted) { - const target = callDocument?.body; - if (target) { - this.audioMutationObserver.observe(target, { - childList: true, - subtree: true, - }); - } - if (win) { - this.ensureAudioPatches(win); - this.collectExistingAudioContexts(win); - this.suspendTrackedAudioContexts(); - } - } else { - this.audioMutationObserver.disconnect(); - this.resumeTrackedAudioContexts(); - this.teardownAudioPatches(); - } - this.applyOutputMute(); - } - private setMediaState(state: ElementMediaStatePayload) { return this.call.transport.send(ElementWidgetActions.DeviceMute, state); } @@ -164,7 +127,7 @@ export class CallControl extends EventEmitter implements CallControlState { private applyOutputMute(sound = this.sound): void { const callDocument = this.iframe.contentDocument ?? this.iframe.contentWindow?.document; - const shouldMute = this.outputOverrideMuted || !sound; + const shouldMute = !sound; if (callDocument) { callDocument.querySelectorAll('audio, video').forEach((el) => { el.muted = shouldMute; @@ -173,103 +136,6 @@ export class CallControl extends EventEmitter implements CallControlState { } } - private ensureAudioPatches(win: Window): void { - if (this.patchedWindow === win) return; - this.teardownAudioPatches(); - this.patchedWindow = win; - - this.patchAudioContextConstructor(win, 'AudioContext'); - this.patchAudioContextConstructor(win, 'webkitAudioContext'); - } - - private patchAudioContextConstructor(win: Window, key: 'AudioContext' | 'webkitAudioContext') { - const scopedWindow = win as Window & - Partial>; - const originalCtor = scopedWindow[key]; - if (typeof originalCtor !== 'function') return; - - const resumeDescriptor = Object.getOwnPropertyDescriptor(originalCtor.prototype, 'resume'); - const originalResumeImpl = resumeDescriptor?.value as - | ((this: AudioContext) => Promise) - | undefined; - if (typeof originalResumeImpl !== 'function') return; - const originalResume = (context: AudioContext): Promise => - originalResumeImpl.call(context); - const trackedContexts = this.trackedAudioContexts; - const isOverrideMuted = () => this.outputOverrideMuted; - originalCtor.prototype.resume = function patchedResume(this: AudioContext) { - trackedContexts.add(this); - if (isOverrideMuted()) { - return Promise.resolve(); - } - return originalResume(this); - }; - this.audioPatchRestores.push(() => { - originalCtor.prototype.resume = originalResumeImpl; - }); - - const wrappedCtor = function patchedAudioContext( - this: unknown, - ...args: ConstructorParameters - ) { - const context = Reflect.construct( - originalCtor, - args, - new.target ?? originalCtor - ) as AudioContext; - trackedContexts.add(context); - if (isOverrideMuted()) { - void context.suspend().catch(() => {}); - } - return context; - } as unknown as typeof AudioContext; - wrappedCtor.prototype = originalCtor.prototype; - Object.setPrototypeOf(wrappedCtor, originalCtor); - - scopedWindow[key] = wrappedCtor; - this.audioPatchRestores.push(() => { - scopedWindow[key] = originalCtor; - }); - } - - private teardownAudioPatches(): void { - this.audioPatchRestores.splice(0).forEach((restore) => restore()); - this.patchedWindow = undefined; - } - - private collectExistingAudioContexts(win: Window): void { - const scopedWindow = win as Window & - Partial>; - const audioCtor = scopedWindow.AudioContext; - if (!audioCtor) return; - - Object.values(win as unknown as Record).forEach((value) => { - if (value instanceof audioCtor) { - this.trackedAudioContexts.add(value); - } - }); - } - - private suspendTrackedAudioContexts(): void { - this.trackedAudioContexts.forEach((context) => { - const wasRunning = context.state === 'running'; - this.runningContextsBeforeOverride.set(context, wasRunning); - if (wasRunning) { - void context.suspend().catch(() => {}); - } - }); - } - - private resumeTrackedAudioContexts(): void { - this.trackedAudioContexts.forEach((context) => { - const wasRunning = this.runningContextsBeforeOverride.get(context) ?? false; - if (wasRunning) { - void context.resume().catch(() => {}); - } - this.runningContextsBeforeOverride.delete(context); - }); - } - public onMediaState(evt: CustomEvent) { const { data } = evt.detail; if (!data) return; @@ -362,9 +228,6 @@ export class CallControl extends EventEmitter implements CallControlState { public dispose() { this.controlMutationObserver.disconnect(); - this.audioMutationObserver.disconnect(); - this.resumeTrackedAudioContexts(); - this.teardownAudioPatches(); } private emitStateUpdate() { From 8cb0464760c1fb0715d76f05bbffc9bd9f1a29c8 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Mon, 18 May 2026 19:02:28 -0500 Subject: [PATCH 24/27] remove debug, reorganize notification things a little, add tests --- src/app/features/call/callIntent.test.ts | 37 +++ src/app/features/call/callIntent.ts | 22 ++ .../features/call/callIntentCrossPath.test.ts | 103 +++++++ .../features/call/callNotificationBridge.ts | 36 +-- src/app/features/call/callToneSources.test.ts | 62 +++++ src/app/features/call/callToneSources.ts | 70 +++++ .../call/rtcNotificationParser.test.ts | 4 +- .../features/call/rtcNotificationParser.ts | 32 ++- .../settings/general/CallSoundSettings.tsx | 33 +-- src/app/hooks/useCallSignaling.ts | 261 +++++------------- 10 files changed, 395 insertions(+), 265 deletions(-) create mode 100644 src/app/features/call/callIntent.test.ts create mode 100644 src/app/features/call/callIntent.ts create mode 100644 src/app/features/call/callIntentCrossPath.test.ts create mode 100644 src/app/features/call/callToneSources.test.ts create mode 100644 src/app/features/call/callToneSources.ts diff --git a/src/app/features/call/callIntent.test.ts b/src/app/features/call/callIntent.test.ts new file mode 100644 index 000000000..be790119e --- /dev/null +++ b/src/app/features/call/callIntent.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeCallIntent, + toCallNotificationType, + toCallNotificationTypeOrDefault, +} from './callIntent'; + +describe('callIntent', () => { + describe('normalizeCallIntent', () => { + it('prefers explicit intent kind', () => { + expect(normalizeCallIntent('video', 'start_call_dm_voice')).toBe('video'); + expect(normalizeCallIntent('audio', 'start_call_dm_video')).toBe('audio'); + }); + + it('infers voice and video from intent raw string', () => { + expect(normalizeCallIntent(undefined, 'start_call_dm_voice')).toBe('audio'); + expect(normalizeCallIntent(undefined, 'start_call_dm_video')).toBe('video'); + }); + + it('defaults DM start without voice/video markers to audio', () => { + expect(normalizeCallIntent(undefined, 'start_call_dm')).toBe('audio'); + }); + }); + + describe('toCallNotificationType', () => { + it('accepts ring and notification only', () => { + expect(toCallNotificationType('ring')).toBe('ring'); + expect(toCallNotificationType('notification')).toBe('notification'); + expect(toCallNotificationType('invalid')).toBeUndefined(); + }); + + it('defaults missing push types to ring', () => { + expect(toCallNotificationTypeOrDefault(undefined)).toBe('ring'); + expect(toCallNotificationTypeOrDefault('notification')).toBe('notification'); + }); + }); +}); diff --git a/src/app/features/call/callIntent.ts b/src/app/features/call/callIntent.ts new file mode 100644 index 000000000..d419aac90 --- /dev/null +++ b/src/app/features/call/callIntent.ts @@ -0,0 +1,22 @@ +export type CallIntentKind = 'audio' | 'video'; +export type CallNotificationType = 'ring' | 'notification'; + +export const MAX_CALL_NOTIFICATION_LIFETIME_MS = 120_000; + +export const normalizeCallIntent = (intentKindRaw?: string, intentRaw?: string): CallIntentKind => { + if (intentKindRaw === 'audio' || intentKindRaw === 'video') { + return intentKindRaw; + } + const normalized = intentRaw?.toLowerCase(); + if (normalized?.includes('voice')) return 'audio'; + if (normalized?.includes('video')) return 'video'; + return 'audio'; +}; + +export const toCallNotificationType = (value: unknown): CallNotificationType | undefined => + value === 'ring' || value === 'notification' ? value : undefined; + +export const toCallNotificationTypeOrDefault = ( + value: unknown, + defaultType: CallNotificationType = 'ring' +): CallNotificationType => toCallNotificationType(value) ?? defaultType; diff --git a/src/app/features/call/callIntentCrossPath.test.ts b/src/app/features/call/callIntentCrossPath.test.ts new file mode 100644 index 000000000..774fd189c --- /dev/null +++ b/src/app/features/call/callIntentCrossPath.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { resolveIncomingCallFromNotificationData } from './callNotificationBridge'; +import { + parseIncomingRtcNotification, + REFERENCE_REL_TYPE, + RTC_NOTIFICATION_EVENT_TYPE, + type RtcNotificationEventLike, +} from './rtcNotificationParser'; + +const NOW = 1_700_000_000_000; +const MY_USER_ID = '@self:example.org'; + +const createEvent = ( + overrides: Partial = {} +): RtcNotificationEventLike => ({ + type: RTC_NOTIFICATION_EVENT_TYPE, + sender: '@caller:example.org', + roomId: '!room:example.org', + eventId: '$notif', + originServerTs: NOW - 1_000, + isLiveEvent: true, + isEncrypted: false, + relation: { + rel_type: REFERENCE_REL_TYPE, + event_id: '$call', + }, + content: { + sender_ts: NOW - 500, + lifetime: 60_000, + notification_type: 'ring', + 'm.call.intent': 'start_call_dm', + 'm.mentions': { + user_ids: [MY_USER_ID], + }, + }, + ...overrides, +}); + +describe('call intent cross-path consistency', () => { + it('parser and notification bridge agree for the same RTC fields', async () => { + const parsed = await parseIncomingRtcNotification(createEvent(), { + myUserId: MY_USER_ID, + now: NOW, + }); + expect(parsed).toBeDefined(); + + const fromBridge = resolveIncomingCallFromNotificationData( + { + isCall: true, + roomId: parsed!.roomId, + eventId: parsed!.notificationEventId, + callNotificationType: parsed!.notificationType, + callIntentRaw: parsed!.intentRaw, + callRefEventId: parsed!.refEventId, + callSenderId: parsed!.senderId, + callSenderTs: parsed!.senderTs, + callExpiresAt: parsed!.expiresAt, + }, + true, + NOW + ); + + expect(fromBridge).toMatchObject({ + notificationType: parsed!.notificationType, + intentKind: parsed!.intentKind, + intentRaw: parsed!.intentRaw, + senderTs: parsed!.senderTs, + expiresAt: parsed!.expiresAt, + }); + }); + + it('parser and bridge both default start_call_dm to audio', async () => { + const parsed = await parseIncomingRtcNotification( + createEvent({ + content: { + sender_ts: NOW - 500, + lifetime: 60_000, + notification_type: 'ring', + 'm.call.intent': 'start_call_dm', + 'm.mentions': { user_ids: [MY_USER_ID] }, + }, + }), + { myUserId: MY_USER_ID, now: NOW } + ); + + const fromBridge = resolveIncomingCallFromNotificationData( + { + isCall: true, + roomId: '!room:example.org', + eventId: '$notif', + callNotificationType: 'ring', + callIntentRaw: 'start_call_dm', + callSenderTs: NOW - 500, + callExpiresAt: NOW + 59_500, + }, + true, + NOW + ); + + expect(parsed?.intentKind).toBe('audio'); + expect(fromBridge?.intentKind).toBe('audio'); + }); +}); diff --git a/src/app/features/call/callNotificationBridge.ts b/src/app/features/call/callNotificationBridge.ts index 402f91706..60bd2ad74 100644 --- a/src/app/features/call/callNotificationBridge.ts +++ b/src/app/features/call/callNotificationBridge.ts @@ -1,10 +1,9 @@ -import type { - IncomingCall, - IncomingCallIntentKind, - IncomingCallNotificationType, -} from '$state/callEmbed'; - -const MAX_CALL_NOTIFICATION_LIFETIME_MS = 120_000; +import type { IncomingCall } from '$state/callEmbed'; +import { + MAX_CALL_NOTIFICATION_LIFETIME_MS, + normalizeCallIntent, + toCallNotificationTypeOrDefault, +} from './callIntent'; type CallCandidate = { roomId: string; @@ -19,27 +18,8 @@ type CallCandidate = { isDirect: boolean; }; -const toNotificationType = ( - value: string | undefined -): IncomingCallNotificationType | undefined => { - if (value === 'ring' || value === 'notification') return value; - return undefined; -}; - -const normalizeIntentKind = ( - intentKindRaw: string | undefined, - intentRaw: string | undefined -): IncomingCallIntentKind => { - if (intentKindRaw === 'audio' || intentKindRaw === 'video') { - return intentKindRaw; - } - const normalized = intentRaw?.toLowerCase(); - if (normalized?.includes('video')) return 'video'; - return 'audio'; -}; - const fromCandidate = (candidate: CallCandidate, now = Date.now()): IncomingCall | undefined => { - const notificationType = toNotificationType(candidate.notificationTypeRaw) ?? 'ring'; + const notificationType = toCallNotificationTypeOrDefault(candidate.notificationTypeRaw); const senderTs = typeof candidate.senderTsRaw === 'number' && Number.isFinite(candidate.senderTsRaw) ? candidate.senderTsRaw @@ -59,7 +39,7 @@ const fromCandidate = (candidate: CallCandidate, now = Date.now()): IncomingCall senderTs, expiresAt, notificationType, - intentKind: normalizeIntentKind(candidate.intentKindRaw, candidate.intentRaw), + intentKind: normalizeCallIntent(candidate.intentKindRaw, candidate.intentRaw), intentRaw: candidate.intentRaw, isDirect: candidate.isDirect, }; diff --git a/src/app/features/call/callToneSources.test.ts b/src/app/features/call/callToneSources.test.ts new file mode 100644 index 000000000..e138d89aa --- /dev/null +++ b/src/app/features/call/callToneSources.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { resolveCallToneSources } from './callToneSources'; + +vi.mock('./callRingtoneStorage', () => ({ + getCustomCallRingtone: vi.fn(), + getCustomCallRingback: vi.fn(), +})); + +const { getCustomCallRingtone, getCustomCallRingback } = await import('./callRingtoneStorage'); + +describe('resolveCallToneSources', () => { + beforeEach(() => { + vi.mocked(getCustomCallRingtone).mockReset(); + vi.mocked(getCustomCallRingback).mockReset(); + vi.stubGlobal( + 'URL', + Object.assign(URL, { + createObjectURL: vi.fn(() => 'blob:custom'), + revokeObjectURL: vi.fn(), + }) + ); + }); + + it('resolves built-in tones without loading custom storage', async () => { + const resolved = await resolveCallToneSources({ + callRingtoneId: 'sable-default', + callRingbackTone: 'sable-default', + }); + + expect(resolved.incomingUrl).toBeTruthy(); + expect(resolved.outgoingUrl).toBeTruthy(); + expect(getCustomCallRingtone).not.toHaveBeenCalled(); + expect(getCustomCallRingback).not.toHaveBeenCalled(); + resolved.revoke(); + }); + + it('loads custom blobs when custom tones are selected', async () => { + vi.mocked(getCustomCallRingtone).mockResolvedValue({ + blob: new Blob(['a'], { type: 'audio/mpeg' }), + name: 'ring.mp3', + sizeBytes: 4, + durationMs: 1000, + }); + vi.mocked(getCustomCallRingback).mockResolvedValue({ + blob: new Blob(['b'], { type: 'audio/mpeg' }), + name: 'back.mp3', + sizeBytes: 4, + durationMs: 1000, + }); + + const resolved = await resolveCallToneSources({ + callRingtoneId: 'custom', + callRingbackTone: 'custom', + }); + + expect(getCustomCallRingtone).toHaveBeenCalled(); + expect(getCustomCallRingback).toHaveBeenCalled(); + expect(resolved.customRingtoneObjectUrl).toBe('blob:custom'); + resolved.revoke(); + expect(URL.revokeObjectURL).toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/call/callToneSources.ts b/src/app/features/call/callToneSources.ts new file mode 100644 index 000000000..5b5087b44 --- /dev/null +++ b/src/app/features/call/callToneSources.ts @@ -0,0 +1,70 @@ +import type { Settings } from '$state/settings'; +import { getCustomCallRingback, getCustomCallRingtone } from './callRingtoneStorage'; +import { resolveIncomingCallToneUrl, resolveOutgoingRingbackToneUrl } from './callRingtone'; + +export type CallToneSourceSettings = Pick; + +export type ResolvedCallToneSources = { + incomingUrl: string | null; + outgoingUrl: string | null; + customRingtoneObjectUrl?: string; + customRingbackObjectUrl?: string; + revoke: () => void; +}; + +export const resolveCallToneSources = async ( + settings: CallToneSourceSettings +): Promise => { + let customRingtoneUrl: string | undefined; + let customRingbackUrl: string | undefined; + + if (settings.callRingtoneId === 'custom') { + const customRingtone = await getCustomCallRingtone().catch(() => undefined); + if (customRingtone?.blob) { + customRingtoneUrl = URL.createObjectURL(customRingtone.blob); + } + } + + if (settings.callRingbackTone === 'custom') { + const customRingback = await getCustomCallRingback().catch(() => undefined); + if (customRingback?.blob) { + customRingbackUrl = URL.createObjectURL(customRingback.blob); + } + } + + const incomingUrl = resolveIncomingCallToneUrl( + { callRingtoneId: settings.callRingtoneId }, + customRingtoneUrl + ); + const outgoingUrl = resolveOutgoingRingbackToneUrl( + { + callRingtoneId: settings.callRingtoneId, + callRingbackTone: settings.callRingbackTone, + }, + customRingtoneUrl, + customRingbackUrl + ); + + return { + incomingUrl, + outgoingUrl, + customRingtoneObjectUrl: customRingtoneUrl, + customRingbackObjectUrl: customRingbackUrl, + revoke: () => { + if (customRingtoneUrl) URL.revokeObjectURL(customRingtoneUrl); + if (customRingbackUrl) URL.revokeObjectURL(customRingbackUrl); + }, + }; +}; + +export const revokeUnusedCustomToneUrls = ( + resolved: ResolvedCallToneSources, + activeSource: string | null +): void => { + if (resolved.customRingtoneObjectUrl && resolved.customRingtoneObjectUrl !== activeSource) { + URL.revokeObjectURL(resolved.customRingtoneObjectUrl); + } + if (resolved.customRingbackObjectUrl && resolved.customRingbackObjectUrl !== activeSource) { + URL.revokeObjectURL(resolved.customRingbackObjectUrl); + } +}; diff --git a/src/app/features/call/rtcNotificationParser.test.ts b/src/app/features/call/rtcNotificationParser.test.ts index 95709239f..edbcc2ba1 100644 --- a/src/app/features/call/rtcNotificationParser.test.ts +++ b/src/app/features/call/rtcNotificationParser.test.ts @@ -71,7 +71,7 @@ describe('parseIncomingRtcNotification', () => { ); expect(parsed?.notificationType).toBe('notification'); - expect(parsed?.intentKind).toBe('video'); + expect(parsed?.intentKind).toBe('audio'); }); it('ignores expired notifications', async () => { @@ -193,7 +193,7 @@ describe('parseIncomingRtcNotification', () => { sender_ts: NOW - 500, lifetime: 60_000, notification_type: 'ring', - 'm.call.intent': 'start_call_dm', + 'm.call.intent': 'start_call_dm_video', 'm.mentions': { room: true }, }, }), diff --git a/src/app/features/call/rtcNotificationParser.ts b/src/app/features/call/rtcNotificationParser.ts index fe0cdd310..09ddb1505 100644 --- a/src/app/features/call/rtcNotificationParser.ts +++ b/src/app/features/call/rtcNotificationParser.ts @@ -1,9 +1,19 @@ +import { + MAX_CALL_NOTIFICATION_LIFETIME_MS, + normalizeCallIntent, + toCallNotificationType, + type CallIntentKind, + type CallNotificationType, +} from './callIntent'; + export const RTC_NOTIFICATION_EVENT_TYPE = 'org.matrix.msc4075.rtc.notification'; export const RTC_DECLINE_EVENT_TYPE = 'org.matrix.msc4310.rtc.decline'; export const REFERENCE_REL_TYPE = 'm.reference'; -export type NotificationType = 'ring' | 'notification'; -export type NotificationIntentKind = 'audio' | 'video'; +export type NotificationType = CallNotificationType; +export type NotificationIntentKind = CallIntentKind; + +export { MAX_CALL_NOTIFICATION_LIFETIME_MS }; export type RtcNotificationEventLike = { type: string; @@ -62,18 +72,12 @@ export type ParsedRtcDecline = { const isObject = (value: unknown): value is Record => typeof value === 'object' && value !== null; -const normalizeIntentKind = (intent?: string): NotificationIntentKind => - intent && intent.includes('voice') ? 'audio' : 'video'; - const isMentioned = (mentions: RtcMentions | undefined, myUserId: string): boolean => { if (!mentions) return false; if (mentions.room) return true; return Array.isArray(mentions.user_ids) && mentions.user_ids.includes(myUserId); }; -const toNotificationType = (value: unknown): NotificationType | undefined => - value === 'ring' || value === 'notification' ? value : undefined; - const getSenderTimestamp = (contentTs: number, originTs: number): number => contentTs - originTs > 20_000 ? originTs : contentTs; @@ -94,7 +98,7 @@ export const parseIncomingRtcNotification = async ( const senderTsCandidate = content.sender_ts; const lifetimeCandidate = content.lifetime; - const notificationType = toNotificationType(content.notification_type); + const notificationType = toCallNotificationType(content.notification_type); if (typeof senderTsCandidate !== 'number') return undefined; if (typeof lifetimeCandidate !== 'number' || !Number.isFinite(lifetimeCandidate)) @@ -117,7 +121,7 @@ export const parseIncomingRtcNotification = async ( senderTs, expiresAt, notificationType, - intentKind: normalizeIntentKind(intentRaw), + intentKind: normalizeCallIntent(undefined, intentRaw), intentRaw, }; }; @@ -129,14 +133,14 @@ export const parseRtcDecline = ( if (!event.isLiveEvent) return undefined; if (event.type !== RTC_DECLINE_EVENT_TYPE) return undefined; if (event.sender === options.myUserId) return undefined; + if (event.relation?.rel_type !== REFERENCE_REL_TYPE || !event.relation.event_id) { + return undefined; + } return { roomId: event.roomId, declineEventId: event.eventId, - notificationEventId: - event.relation?.rel_type === REFERENCE_REL_TYPE && event.relation.event_id - ? event.relation.event_id - : event.eventId, + notificationEventId: event.relation.event_id, senderId: event.sender, }; }; diff --git a/src/app/features/settings/general/CallSoundSettings.tsx b/src/app/features/settings/general/CallSoundSettings.tsx index 9193c6daf..30c5e4645 100644 --- a/src/app/features/settings/general/CallSoundSettings.tsx +++ b/src/app/features/settings/general/CallSoundSettings.tsx @@ -13,10 +13,9 @@ import { callRingtoneVolumeToGain, clampCallRingtoneVolume, readAudioDurationMs, - resolveIncomingCallToneUrl, - resolveOutgoingRingbackToneUrl, validateCustomCallRingtone, } from '$features/call/callRingtone'; +import { resolveCallToneSources, revokeUnusedCustomToneUrls } from '$features/call/callToneSources'; import { clearCustomCallRingback, clearCustomCallRingtone, @@ -294,33 +293,9 @@ export function CallSoundSettings() { const resolveToneForPreview = useCallback( async (tone: PreviewTone): Promise => { - let customRingtoneUrl: string | undefined; - let customRingbackUrl: string | undefined; - if (callRingtoneId === 'custom') { - const customRingtone = await getCustomCallRingtone(); - if (customRingtone?.blob) { - customRingtoneUrl = URL.createObjectURL(customRingtone.blob); - } - } - if (callRingbackTone === 'custom') { - const customRingback = await getCustomCallRingback(); - if (customRingback?.blob) { - customRingbackUrl = URL.createObjectURL(customRingback.blob); - } - } - - const source = - tone === 'incoming' - ? resolveIncomingCallToneUrl({ callRingtoneId }, customRingtoneUrl) - : resolveOutgoingRingbackToneUrl( - { callRingbackTone, callRingtoneId }, - customRingtoneUrl, - customRingbackUrl - ); - - if (customRingtoneUrl && source !== customRingtoneUrl) URL.revokeObjectURL(customRingtoneUrl); - if (customRingbackUrl && source !== customRingbackUrl) URL.revokeObjectURL(customRingbackUrl); - + const resolved = await resolveCallToneSources({ callRingtoneId, callRingbackTone }); + const source = tone === 'incoming' ? resolved.incomingUrl : resolved.outgoingUrl; + revokeUnusedCustomToneUrls(resolved, source); return source; }, [callRingtoneId, callRingbackTone] diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index 854957923..314609c39 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -24,14 +24,9 @@ import { REFERENCE_REL_TYPE, RTC_NOTIFICATION_EVENT_TYPE, } from '$features/call/rtcNotificationParser'; -import { - callRingtoneVolumeToGain, - canPlayCallAudio, - resolveIncomingCallToneUrl, - resolveOutgoingRingbackToneUrl, -} from '$features/call/callRingtone'; +import { callRingtoneVolumeToGain, canPlayCallAudio } from '$features/call/callRingtone'; import { dismissSystemCallNotifications } from '$features/call/callNotificationBridge'; -import { getCustomCallRingback, getCustomCallRingtone } from '$features/call/callRingtoneStorage'; +import { resolveCallToneSources } from '$features/call/callToneSources'; import { useMatrixClient } from './useMatrixClient'; import { createDebugLogger } from '../utils/debugLogger'; @@ -41,27 +36,6 @@ const MAX_NOTIFICATION_LIFETIME_MS = 120_000; const DECRYPT_TIMEOUT_MS = 8_000; const FALLBACK_INTERVAL_MS = 5_000; const OUTGOING_RING_TIMEOUT_MS = 30_000; -const DECLINE_DEBUG_MAX = 200; - -type DeclineDebugEntry = { - ts: number; - phase: string; - roomId?: string; - eventId?: string; - eventType?: string; - encrypted?: boolean; - detail?: string; -}; - -const pushDeclineDebug = (entry: DeclineDebugEntry): void => { - const scope = globalThis as typeof globalThis & { - __sableDeclineDebug?: DeclineDebugEntry[]; - }; - const queue = scope.__sableDeclineDebug ?? []; - queue.push(entry); - if (queue.length > DECLINE_DEBUG_MAX) queue.shift(); - scope.__sableDeclineDebug = queue; -}; type SessionDescription = Parameters[1]; type RtcMembership = { userId?: string; sender?: string }; @@ -192,6 +166,25 @@ export function useIncomingCallSignaling() { >(new Map()); const outgoingStartRef = useRef(null); + type SignalingHandlerRefs = { + callEmbed: typeof callEmbed; + mDirects: typeof mDirects; + outgoingRingbackAllowed: boolean; + handleIncomingCall: (incoming: IncomingCall) => void; + handleOutgoingDecline: (decline: { + roomId: string; + declineEventId: string; + notificationEventId: string; + senderId: string; + }) => void; + clearIncomingCall: () => void; + stopIncomingRing: () => void; + stopOutgoingRing: () => void; + setMutedRoomId: (roomId: string | null) => void; + }; + + const signalingHandlerRefs = useRef(null); + incomingCallRef.current = incomingCall; mutedRoomIdRef.current = mutedRoomId; @@ -217,58 +210,40 @@ export function useIncomingCallSignaling() { useEffect(() => { let canceled = false; - let customRingtoneUrl: string | undefined; - let customRingbackUrl: string | undefined; + let revokeToneUrls: (() => void) | undefined; const incoming = incomingAudioRef.current; const outgoing = outgoingAudioRef.current; if (!incoming || !outgoing) return undefined; const syncSources = async () => { - if (settings.callRingtoneId === 'custom') { - const customRingtone = await getCustomCallRingtone().catch(() => undefined); - if (customRingtone?.blob) { - customRingtoneUrl = URL.createObjectURL(customRingtone.blob); - } - } + const resolved = await resolveCallToneSources({ + callRingtoneId: settings.callRingtoneId, + callRingbackTone: settings.callRingbackTone, + }); - if (settings.callRingbackTone === 'custom') { - const customRingback = await getCustomCallRingback().catch(() => undefined); - if (customRingback?.blob) { - customRingbackUrl = URL.createObjectURL(customRingback.blob); - } + if (canceled) { + resolved.revoke(); + return; } - if (canceled) return; + revokeToneUrls?.(); + revokeToneUrls = resolved.revoke; incoming.pause(); incoming.currentTime = 0; outgoing.pause(); outgoing.currentTime = 0; - const incomingTone = resolveIncomingCallToneUrl( - { - callRingtoneId: settings.callRingtoneId, - }, - customRingtoneUrl - ); - const outgoingTone = resolveOutgoingRingbackToneUrl( - { - callRingtoneId: settings.callRingtoneId, - callRingbackTone: settings.callRingbackTone, - }, - customRingtoneUrl, - customRingbackUrl - ); const gain = callRingtoneVolumeToGain(settings.callRingtoneVolume); - if (incomingTone) { - incoming.src = incomingTone; + if (resolved.incomingUrl) { + incoming.src = resolved.incomingUrl; } else { incoming.removeAttribute('src'); } - if (outgoingTone) { - outgoing.src = outgoingTone; + if (resolved.outgoingUrl) { + outgoing.src = resolved.outgoingUrl; } else { outgoing.removeAttribute('src'); } @@ -281,8 +256,7 @@ export function useIncomingCallSignaling() { return () => { canceled = true; - if (customRingtoneUrl) URL.revokeObjectURL(customRingtoneUrl); - if (customRingbackUrl) URL.revokeObjectURL(customRingbackUrl); + revokeToneUrls?.(); }; }, [settings.callRingtoneId, settings.callRingbackTone, settings.callRingtoneVolume]); @@ -316,27 +290,11 @@ export function useIncomingCallSignaling() { senderId: string; }) => { if (!callEmbed || callEmbed.roomId !== decline.roomId) { - pushDeclineDebug({ - ts: Date.now(), - phase: 'handle_skip', - roomId: decline.roomId, - eventId: decline.declineEventId, - eventType: RTC_DECLINE_EVENT_TYPE, - detail: 'no_active_embed_for_room', - }); return; } const outgoingRoom = mx.getRoom(decline.roomId); if (!outgoingRoom) { - pushDeclineDebug({ - ts: Date.now(), - phase: 'handle_skip', - roomId: decline.roomId, - eventId: decline.declineEventId, - eventType: RTC_DECLINE_EVENT_TYPE, - detail: 'missing_room', - }); return; } @@ -365,14 +323,6 @@ export function useIncomingCallSignaling() { const treatAsOneToOne = isDirectRoom || remoteJoinedIds.size <= 1; if (!treatAsOneToOne && remoteJoinedIds.size > 0 && !allRemoteDeclined) { - pushDeclineDebug({ - ts: Date.now(), - phase: 'handle_partial', - roomId: decline.roomId, - eventId: decline.declineEventId, - eventType: RTC_DECLINE_EVENT_TYPE, - detail: `${declineState.declinerIds.size}/${remoteJoinedIds.size}`, - }); debugLog.info('call', 'Ignoring partial outgoing decline for group call', { roomId: decline.roomId, declineEventId: decline.declineEventId, @@ -385,14 +335,6 @@ export function useIncomingCallSignaling() { } declinedOutgoingRoomIdRef.current = decline.roomId; - pushDeclineDebug({ - ts: Date.now(), - phase: 'handle_end', - roomId: decline.roomId, - eventId: decline.declineEventId, - eventType: RTC_DECLINE_EVENT_TYPE, - detail: `${declineState.declinerIds.size}/${remoteJoinedIds.size}`, - }); debugLog.info('call', 'Outgoing call declined and ending call', { roomId: decline.roomId, declineEventId: decline.declineEventId, @@ -406,14 +348,6 @@ export function useIncomingCallSignaling() { void callEmbed .hangup() .catch((error) => { - pushDeclineDebug({ - ts: Date.now(), - phase: 'hangup_error', - roomId: decline.roomId, - eventId: decline.declineEventId, - eventType: RTC_DECLINE_EVENT_TYPE, - detail: error instanceof Error ? error.message : String(error), - }); debugLog.warn('call', 'Failed to hang up after outgoing decline', { roomId: decline.roomId, error: error instanceof Error ? error.message : String(error), @@ -421,13 +355,6 @@ export function useIncomingCallSignaling() { Sentry.metrics.count('sable.call.outgoing.decline_hangup_error', 1); }) .finally(() => { - pushDeclineDebug({ - ts: Date.now(), - phase: 'hangup_finally', - roomId: decline.roomId, - eventId: decline.declineEventId, - eventType: RTC_DECLINE_EVENT_TYPE, - }); window.setTimeout(() => { const activeEmbed = store.get(callEmbedAtom); if (activeEmbed !== callEmbed) return; @@ -445,6 +372,18 @@ export function useIncomingCallSignaling() { const incomingRingtoneAllowed = settings.incomingCallSoundEnabled && callAudioAllowed; const outgoingRingbackAllowed = settings.outgoingRingbackEnabled && callAudioAllowed; + signalingHandlerRefs.current = { + callEmbed, + mDirects, + outgoingRingbackAllowed, + handleIncomingCall, + handleOutgoingDecline, + clearIncomingCall, + stopIncomingRing, + stopOutgoingRing, + setMutedRoomId, + }; + useEffect(() => { if (!incomingRingtoneAllowed) { stopIncomingRing(); @@ -511,6 +450,7 @@ export function useIncomingCallSignaling() { if (!mx || !mx.matrixRTC) return undefined; const myUserId = mx.getSafeUserId(); + const handlers = () => signalingHandlerRefs.current!; const parseEvent = async ( event: MatrixEvent, @@ -566,7 +506,7 @@ export function useIncomingCallSignaling() { return { ...parsed, - isDirect: mDirects.has(room.roomId), + isDirect: handlers().mDirects.has(room.roomId), }; }; @@ -575,41 +515,16 @@ export function useIncomingCallSignaling() { room: Room, liveEvent: boolean ): Promise> => { - pushDeclineDebug({ - ts: Date.now(), - phase: 'parse_start', - roomId: room.roomId, - eventId: event.getId() ?? undefined, - eventType: event.getType(), - encrypted: event.isEncrypted(), - }); let eventType = event.getType(); let content = event.getContent(); if (event.isEncrypted()) { const decrypted = await decryptWithTimeout(event, mx); if (!decrypted?.content || !decrypted.type) { - pushDeclineDebug({ - ts: Date.now(), - phase: 'parse_decrypt_fallback', - roomId: room.roomId, - eventId: event.getId() ?? undefined, - eventType: event.getType(), - encrypted: event.isEncrypted(), - detail: 'decrypt_timeout_or_missing_content', - }); Sentry.metrics.count('sable.call.signal.decrypt_timeout', 1); } else { eventType = decrypted.type; content = decrypted.content; - pushDeclineDebug({ - ts: Date.now(), - phase: 'parse_decrypted', - roomId: room.roomId, - eventId: event.getId() ?? undefined, - eventType, - encrypted: event.isEncrypted(), - }); } } @@ -644,15 +559,6 @@ export function useIncomingCallSignaling() { }, { myUserId } ); - pushDeclineDebug({ - ts: Date.now(), - phase: parsed ? 'parse_match' : 'parse_skip', - roomId: room.roomId, - eventId: event.getId() ?? undefined, - eventType, - encrypted: event.isEncrypted(), - detail: parsed ? 'decline_parsed' : 'type_or_sender_mismatch', - }); return parsed; }; @@ -681,42 +587,24 @@ export function useIncomingCallSignaling() { const incoming = await parseEvent(event, room, data.liveEvent); if (incoming) { - handleIncomingCall(incoming); + handlers().handleIncomingCall(incoming); return; } // Avoid decrypting unrelated encrypted timeline traffic; only inspect declines // for the currently active outgoing call room. + const activeEmbed = handlers().callEmbed; const shouldCheckDecline = - !!callEmbed && - callEmbed.roomId === room.roomId && + !!activeEmbed && + activeEmbed.roomId === room.roomId && (event.isEncrypted() || type === RTC_DECLINE_EVENT_TYPE); if (!shouldCheckDecline) { - if (event.isEncrypted() || type === RTC_DECLINE_EVENT_TYPE) { - pushDeclineDebug({ - ts: Date.now(), - phase: 'timeline_skip', - roomId: room.roomId, - eventId: event.getId() ?? undefined, - eventType: type, - encrypted: event.isEncrypted(), - detail: `activeRoom=${callEmbed?.roomId ?? 'none'}`, - }); - } return; } const decline = await parseDeclineEvent(event, room, data.liveEvent); if (decline) { - pushDeclineDebug({ - ts: Date.now(), - phase: 'timeline_match', - roomId: room.roomId, - eventId: event.getId() ?? undefined, - eventType: type, - encrypted: event.isEncrypted(), - }); - handleOutgoingDecline(decline); + handlers().handleOutgoingDecline(decline); return; } }; @@ -732,13 +620,13 @@ export function useIncomingCallSignaling() { notificationEventId: currentIncoming.notificationEventId, }); Sentry.metrics.count('sable.call.timeout', 1); - clearIncomingCall(); + handlers().clearIncomingCall(); return; } const incomingRoom = mx.getRoom(currentIncoming.roomId); if (!incomingRoom) { - clearIncomingCall(); + handlers().clearIncomingCall(); return; } @@ -752,24 +640,24 @@ export function useIncomingCallSignaling() { debugLog.info('call', 'Incoming call cleared after membership drop', { roomId: currentIncoming.roomId, }); - clearIncomingCall(); + handlers().clearIncomingCall(); return; } } - const activeCallRoomId = callEmbed?.roomId; - if (!activeCallRoomId || !outgoingRingbackAllowed) { - stopOutgoingRing(); + const activeCallRoomId = handlers().callEmbed?.roomId; + if (!activeCallRoomId || !handlers().outgoingRingbackAllowed) { + handlers().stopOutgoingRing(); return; } if (declinedOutgoingRoomIdRef.current === activeCallRoomId) { - stopOutgoingRing(); + handlers().stopOutgoingRing(); return; } const outgoingRoom = mx.getRoom(activeCallRoomId); if (!outgoingRoom) { - stopOutgoingRing(); + handlers().stopOutgoingRing(); return; } @@ -782,7 +670,7 @@ export function useIncomingCallSignaling() { const activeCall = isCallActive(myUserId, outgoingRoom, session.sessionDescription); if (!pendingOutgoing || activeCall) { - stopOutgoingRing(); + handlers().stopOutgoingRing(); return; } @@ -793,7 +681,7 @@ export function useIncomingCallSignaling() { } if (outgoingStartRef.current && now - outgoingStartRef.current >= OUTGOING_RING_TIMEOUT_MS) { - stopOutgoingRing(); + handlers().stopOutgoingRing(); return; } @@ -803,7 +691,7 @@ export function useIncomingCallSignaling() { }; const handleSessionEnded = (roomId: string) => { - if (mutedRoomIdRef.current === roomId) setMutedRoomId(null); + if (mutedRoomIdRef.current === roomId) handlers().setMutedRoomId(null); evaluateFallbackState(); }; @@ -819,21 +707,10 @@ export function useIncomingCallSignaling() { mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, evaluateFallbackState); mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded); window.clearInterval(intervalId); - stopIncomingRing(); - stopOutgoingRing(); + handlers().stopIncomingRing(); + handlers().stopOutgoingRing(); }; - }, [ - callEmbed, - mx, - mDirects, - outgoingRingbackAllowed, - handleIncomingCall, - handleOutgoingDecline, - clearIncomingCall, - stopIncomingRing, - stopOutgoingRing, - setMutedRoomId, - ]); + }, [mx]); return null; } From e25ce4e5dd9900248d294cac5761d99372dee987 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Mon, 18 May 2026 20:42:35 -0500 Subject: [PATCH 25/27] extract a bunch of things into their own files and some tests --- src/app/components/IncomingCallModal.tsx | 51 +-- src/app/features/call/CallView.tsx | 2 +- .../features/call/callMembershipState.test.ts | 54 +++ src/app/features/call/callMembershipState.ts | 72 ++++ .../call/callNotificationBridge.test.ts | 22 ++ .../features/call/callNotificationBridge.ts | 12 +- src/app/features/call/callRingtone.test.ts | 6 + src/app/features/call/callRingtone.ts | 5 +- src/app/features/call/callSignalingDecrypt.ts | 47 +++ .../call/callSignalingFallback.test.ts | 116 ++++++ .../features/call/callSignalingFallback.ts | 123 ++++++ src/app/features/call/callSignalingPolicy.ts | 9 + .../call/getIncomingCallBlockers.test.ts | 31 ++ .../features/call/getIncomingCallBlockers.ts | 52 +++ .../call/outgoingDeclineHandler.test.ts | 39 ++ .../features/call/outgoingDeclineHandler.ts | 49 +++ src/app/features/call/rtcTimelineDecline.ts | 58 +++ .../permissions/PermissionGroups.tsx | 2 +- .../common-settings/permissions/Powers.tsx | 6 +- .../permissions/usePermissionItems.ts | 2 +- .../general/CallSoundSettings.test.tsx | 52 ++- .../settings/general/CallSoundSettings.tsx | 273 ++----------- .../general/CallSoundSettingsCards.tsx | 149 +++++++ .../permissions/usePermissionItems.ts | 2 +- src/app/hooks/useCallSignaling.ts | 373 +++++------------- src/app/plugins/call/CallWidgetDriver.ts | 8 - src/app/state/settings.defaults.test.ts | 10 +- src/app/state/settings.ts | 44 +-- src/app/utils/settingsSync.test.ts | 6 - src/app/utils/settingsSync.ts | 6 - src/sw/pushCallNotificationCopy.test.ts | 32 ++ src/sw/pushCallNotificationCopy.ts | 120 ++++++ src/sw/pushNotification.ts | 88 +---- 33 files changed, 1212 insertions(+), 709 deletions(-) create mode 100644 src/app/features/call/callMembershipState.test.ts create mode 100644 src/app/features/call/callMembershipState.ts create mode 100644 src/app/features/call/callSignalingDecrypt.ts create mode 100644 src/app/features/call/callSignalingFallback.test.ts create mode 100644 src/app/features/call/callSignalingFallback.ts create mode 100644 src/app/features/call/callSignalingPolicy.ts create mode 100644 src/app/features/call/getIncomingCallBlockers.test.ts create mode 100644 src/app/features/call/getIncomingCallBlockers.ts create mode 100644 src/app/features/call/outgoingDeclineHandler.test.ts create mode 100644 src/app/features/call/outgoingDeclineHandler.ts create mode 100644 src/app/features/call/rtcTimelineDecline.ts create mode 100644 src/app/features/settings/general/CallSoundSettingsCards.tsx create mode 100644 src/sw/pushCallNotificationCopy.test.ts create mode 100644 src/sw/pushCallNotificationCopy.ts diff --git a/src/app/components/IncomingCallModal.tsx b/src/app/components/IncomingCallModal.tsx index a7ab56ff4..1905165a9 100644 --- a/src/app/components/IncomingCallModal.tsx +++ b/src/app/components/IncomingCallModal.tsx @@ -38,6 +38,7 @@ import { } from '$state/callEmbed'; import { createDebugLogger } from '$utils/debugLogger'; import { dismissSystemCallNotifications } from '$features/call/callNotificationBridge'; +import { getIncomingCallBlockers } from '$features/call/getIncomingCallBlockers'; import { RoomAvatar } from './room-avatar'; import { UserAvatar } from './user-avatar'; @@ -49,12 +50,6 @@ type IncomingCallInternalProps = { onClose: () => void; }; -type CapabilityIssue = { - id: string; - message: string; - shortReason: string; -}; - export function IncomingCallInternal({ room, incomingCall, onClose }: IncomingCallInternalProps) { const mx = useMatrixClient(); const screenSize = useScreenSizeContext(); @@ -86,40 +81,16 @@ export function IncomingCallInternal({ room, incomingCall, onClose }: IncomingCa const hasCallMemberPermission = room.currentState?.maySendStateEvent('org.matrix.msc3401.call.member', myUserId) ?? false; - const capabilityIssues = useMemo(() => { - const issues: CapabilityIssue[] = []; - - if (!canUseWebRTC) { - issues.push({ - id: 'webrtc', - message: 'Your browser does not support WebRTC calling.', - shortReason: 'WebRTC is unavailable in this browser.', - }); - } - if (!livekitSupported) { - issues.push({ - id: 'livekit', - message: 'Your homeserver does not expose a LiveKit call focus.', - shortReason: 'Homeserver call focus is unavailable.', - }); - } - if (!hasCallMemberPermission) { - issues.push({ - id: 'permission', - message: "You don't have permission to join this room's call.", - shortReason: 'Missing permission to join this call.', - }); - } - if (inAnotherCall) { - issues.push({ - id: 'another_call', - message: 'You are already in another call.', - shortReason: 'Finish your current call first.', - }); - } - - return issues; - }, [canUseWebRTC, livekitSupported, hasCallMemberPermission, inAnotherCall]); + const capabilityIssues = useMemo( + () => + getIncomingCallBlockers({ + canUseWebRTC, + livekitSupported, + hasCallMemberPermission, + inAnotherCall, + }), + [canUseWebRTC, livekitSupported, hasCallMemberPermission, inAnotherCall] + ); const canAnswer = capabilityIssues.length === 0; const primaryBlockedReason = capabilityIssues[0]?.shortReason; diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 5298a89b3..b2e7ae452 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -17,7 +17,7 @@ import { callEmbedAtom, callEmbedStartErrorAtom } from '$state/callEmbed'; function LivekitServerMissingMessage() { return ( - Your homeserver does not support calling. + Your homeserver does not support calling. You can still join calls started by others. ); } diff --git a/src/app/features/call/callMembershipState.test.ts b/src/app/features/call/callMembershipState.test.ts new file mode 100644 index 000000000..8f55f8444 --- /dev/null +++ b/src/app/features/call/callMembershipState.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MatrixRTCSession } from '$types/matrix-sdk'; +import { + getCallMembershipPresence, + isCallActive, + isIncomingCallActive, + isOutgoingCallPending, + type SessionDescription, +} from './callMembershipState'; + +const MY_USER_ID = '@self:example.org'; +const SESSION_DESCRIPTION = {} as SessionDescription; +const room = { roomId: '!room:example.org' } as Parameters< + typeof MatrixRTCSession.sessionMembershipsForRoom +>[0]; + +describe('callMembershipState', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('detects incoming call when remote members exist without self', () => { + vi.spyOn(MatrixRTCSession, 'sessionMembershipsForRoom').mockReturnValue([ + { userId: '@remote:example.org' }, + ] as never); + + expect(isIncomingCallActive(MY_USER_ID, room, SESSION_DESCRIPTION)).toBe(true); + expect(isCallActive(MY_USER_ID, room, SESSION_DESCRIPTION)).toBe(false); + expect(isOutgoingCallPending(MY_USER_ID, room, SESSION_DESCRIPTION)).toBe(false); + }); + + it('detects active call when self and remote members exist', () => { + vi.spyOn(MatrixRTCSession, 'sessionMembershipsForRoom').mockReturnValue([ + { userId: MY_USER_ID }, + { userId: '@remote:example.org' }, + ] as never); + + expect(isIncomingCallActive(MY_USER_ID, room, SESSION_DESCRIPTION)).toBe(false); + expect(isCallActive(MY_USER_ID, room, SESSION_DESCRIPTION)).toBe(true); + expect(isOutgoingCallPending(MY_USER_ID, room, SESSION_DESCRIPTION)).toBe(false); + }); + + it('detects pending outgoing call when only self is present', () => { + vi.spyOn(MatrixRTCSession, 'sessionMembershipsForRoom').mockReturnValue([ + { userId: MY_USER_ID }, + ] as never); + + expect(getCallMembershipPresence(MY_USER_ID, room, SESSION_DESCRIPTION)).toEqual({ + hasSelfMember: true, + remoteMemberCount: 0, + }); + expect(isOutgoingCallPending(MY_USER_ID, room, SESSION_DESCRIPTION)).toBe(true); + }); +}); diff --git a/src/app/features/call/callMembershipState.ts b/src/app/features/call/callMembershipState.ts new file mode 100644 index 000000000..239492c56 --- /dev/null +++ b/src/app/features/call/callMembershipState.ts @@ -0,0 +1,72 @@ +import { MatrixRTCSession } from '$types/matrix-sdk'; +import type { Room } from '$types/matrix-sdk'; + +export type SessionDescription = Parameters[1]; + +type RtcMembership = { userId?: string; sender?: string }; + +export type CallMembershipPresence = { + hasSelfMember: boolean; + remoteMemberCount: number; +}; + +const getRoomMemberships = (room: Room, sessionDescription: SessionDescription) => + MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription) as RtcMembership[]; + +export const getCallMembershipPresence = ( + mxUserId: string, + room: Room, + sessionDescription: SessionDescription +): CallMembershipPresence => { + const memberships = getRoomMemberships(room, sessionDescription); + const remoteMemberCount = memberships.filter( + (membership) => (membership.userId || membership.sender) !== mxUserId + ).length; + const hasSelfMember = memberships.some( + (membership) => (membership.userId || membership.sender) === mxUserId + ); + + return { hasSelfMember, remoteMemberCount }; +}; + +export const isIncomingCallActive = ( + mxUserId: string, + room: Room, + sessionDescription: SessionDescription +): boolean => { + const { hasSelfMember, remoteMemberCount } = getCallMembershipPresence( + mxUserId, + room, + sessionDescription + ); + + return remoteMemberCount > 0 && !hasSelfMember; +}; + +export const isCallActive = ( + mxUserId: string, + room: Room, + sessionDescription: SessionDescription +): boolean => { + const { hasSelfMember, remoteMemberCount } = getCallMembershipPresence( + mxUserId, + room, + sessionDescription + ); + + return hasSelfMember && remoteMemberCount > 0; +}; + +export const isOutgoingCallPending = ( + mxUserId: string, + room: Room, + sessionDescription: SessionDescription +): boolean => { + const { hasSelfMember, remoteMemberCount } = getCallMembershipPresence( + mxUserId, + room, + sessionDescription + ); + + return hasSelfMember && remoteMemberCount === 0; +}; diff --git a/src/app/features/call/callNotificationBridge.test.ts b/src/app/features/call/callNotificationBridge.test.ts index b8569df83..25f4ad8e4 100644 --- a/src/app/features/call/callNotificationBridge.test.ts +++ b/src/app/features/call/callNotificationBridge.test.ts @@ -51,6 +51,28 @@ describe('callNotificationBridge', () => { expect(incoming).toBeUndefined(); }); + it('hydrates incoming call from legacy joinCall deep-link params', () => { + const now = 1_000_000; + const params = new URLSearchParams({ + joinCall: 'true', + callType: 'ring', + }); + + const incoming = resolveIncomingCallFromSearchParams( + params, + '!room:test', + '$notif', + true, + now + ); + + expect(incoming).toMatchObject({ + roomId: '!room:test', + notificationEventId: '$notif', + notificationType: 'ring', + }); + }); + it('hydrates incoming call from /to/ deep-link search params', () => { const now = 1_000_000; const params = new URLSearchParams({ diff --git a/src/app/features/call/callNotificationBridge.ts b/src/app/features/call/callNotificationBridge.ts index 60bd2ad74..3a40a1467 100644 --- a/src/app/features/call/callNotificationBridge.ts +++ b/src/app/features/call/callNotificationBridge.ts @@ -82,11 +82,17 @@ export const resolveIncomingCallFromSearchParams = ( isDirect: boolean, now = Date.now() ): IncomingCall | undefined => { - if (searchParams.get('call') !== '1') return undefined; + const isCallDeepLink = + searchParams.get('call') === '1' || + searchParams.get('joinCall') === 'true' || + searchParams.get('joinCall') === '1'; + if (!isCallDeepLink) return undefined; if (!notificationEventId) return undefined; - const senderTsRaw = Number(searchParams.get('callSenderTs')); - const expiresAtRaw = Number(searchParams.get('callExpiresAt')); + const senderTsParam = searchParams.get('callSenderTs'); + const expiresAtParam = searchParams.get('callExpiresAt'); + const senderTsRaw = senderTsParam ? Number(senderTsParam) : Number.NaN; + const expiresAtRaw = expiresAtParam ? Number(expiresAtParam) : Number.NaN; return fromCandidate( { diff --git a/src/app/features/call/callRingtone.test.ts b/src/app/features/call/callRingtone.test.ts index 5225cfff8..0a02117dd 100644 --- a/src/app/features/call/callRingtone.test.ts +++ b/src/app/features/call/callRingtone.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + CALL_RINGBACK_OPTIONS, callRingtoneVolumeToGain, canPlayCallAudio, clampCallRingtoneVolume, @@ -115,4 +116,9 @@ describe('callRingtone', () => { }) ).toEqual({ valid: true }); }); + + it('excludes silent option from ringback choices', () => { + expect(CALL_RINGBACK_OPTIONS.some((option) => option.value === 'silent')).toBe(false); + expect(CALL_RINGBACK_OPTIONS.length).toBeGreaterThan(0); + }); }); diff --git a/src/app/features/call/callRingtone.ts b/src/app/features/call/callRingtone.ts index 206d5f79f..203c41e8f 100644 --- a/src/app/features/call/callRingtone.ts +++ b/src/app/features/call/callRingtone.ts @@ -3,7 +3,6 @@ import NotificationSound from '$public/sound/notification.ogg'; import RingtoneSound from '$public/sound/ringtone.webm'; import { CALL_TONE_IDS, - type CallRingbackTone, type CallRingtoneId, type Settings, } from '$state/settings'; @@ -33,7 +32,9 @@ export const CALL_RINGTONE_OPTIONS: CallToneOption[] = CALL_TONE }) ); -export const CALL_RINGBACK_OPTIONS: CallToneOption[] = CALL_RINGTONE_OPTIONS; +export const CALL_RINGBACK_OPTIONS: CallToneOption[] = CALL_RINGTONE_OPTIONS.filter( + (option) => option.value !== 'silent' +); type ToneSettings = Pick; diff --git a/src/app/features/call/callSignalingDecrypt.ts b/src/app/features/call/callSignalingDecrypt.ts new file mode 100644 index 000000000..c6a59f31b --- /dev/null +++ b/src/app/features/call/callSignalingDecrypt.ts @@ -0,0 +1,47 @@ +import type { CryptoBackend, MatrixClient, MatrixEvent } from '$types/matrix-sdk'; +import { createDebugLogger } from '$utils/debugLogger'; +import { DECRYPT_TIMEOUT_MS } from './callSignalingPolicy'; + +const debugLog = createDebugLogger('CallSignaling'); + +export type DecryptedTimelineEvent = { + type?: string; + content?: unknown; +}; + +export const decryptRtcTimelineEvent = async ( + event: MatrixEvent, + mx: MatrixClient +): Promise => { + const crypto = mx.getCrypto(); + if (!crypto) return undefined; + + try { + if (!event.isBeingDecrypted()) { + await event.attemptDecryption(crypto as CryptoBackend); + } + + const decryptionPromise = event.getDecryptionPromise(); + if (decryptionPromise) { + await Promise.race([ + decryptionPromise, + new Promise((resolve) => { + window.setTimeout(resolve, DECRYPT_TIMEOUT_MS); + }), + ]); + } + } catch (error) { + debugLog.warn('call', 'RTC notification decryption failed', { + eventId: event.getId(), + roomId: event.getRoomId(), + error: error instanceof Error ? error.message : String(error), + }); + return undefined; + } + + const effectiveEvent = event.getEffectiveEvent(); + return { + type: effectiveEvent.type, + content: effectiveEvent.content, + }; +}; diff --git a/src/app/features/call/callSignalingFallback.test.ts b/src/app/features/call/callSignalingFallback.test.ts new file mode 100644 index 000000000..0108c7ce3 --- /dev/null +++ b/src/app/features/call/callSignalingFallback.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import type { IncomingCall } from '$state/callEmbed'; +import { + evaluateIncomingCallFallback, + evaluateOutgoingRingbackFallback, + type OutgoingRingbackState, +} from './callSignalingFallback'; +import { INCOMING_MEMBERSHIP_GRACE_MS, OUTGOING_RING_TIMEOUT_MS } from './callSignalingPolicy'; + +const NOW = 1_700_000_000_000; + +const incomingCall: IncomingCall = { + roomId: '!room:example.org', + notificationEventId: '$notif', + refEventId: '$ref', + senderId: '@caller:example.org', + senderTs: NOW - 1_000, + expiresAt: NOW + 60_000, + notificationType: 'ring', + intentKind: 'audio', + isDirect: true, +}; + +describe('evaluateIncomingCallFallback', () => { + it('clears expired incoming calls', () => { + expect( + evaluateIncomingCallFallback( + { ...incomingCall, expiresAt: NOW - 1 }, + NOW, + { myUserId: '@self:example.org', getRoom: () => null, getSessionDescription: () => ({}) } + ) + ).toEqual({ kind: 'clear', reason: 'expired' }); + }); + + it('keeps incoming call during membership grace window', () => { + expect( + evaluateIncomingCallFallback(incomingCall, NOW, { + myUserId: '@self:example.org', + getRoom: () => ({ roomId: incomingCall.roomId }) as never, + getSessionDescription: () => ({}), + isIncomingActive: () => false, + }) + ).toEqual({ kind: 'none' }); + }); + + it('clears incoming call after grace when membership is inactive', () => { + const action = evaluateIncomingCallFallback( + { ...incomingCall, senderTs: NOW - INCOMING_MEMBERSHIP_GRACE_MS - 1 }, + NOW, + { + myUserId: '@self:example.org', + getRoom: () => ({ roomId: incomingCall.roomId }) as never, + getSessionDescription: () => ({}), + isIncomingActive: () => false, + } + ); + expect(action).toEqual({ kind: 'clear', reason: 'membership_dropped' }); + }); +}); + +describe('evaluateOutgoingRingbackFallback', () => { + const baseContext = { + myUserId: '@self:example.org', + activeCallRoomId: '!room:example.org', + outgoingRingbackAllowed: true, + declinedRoomId: null, + getRoom: () => ({ roomId: '!room:example.org' }) as never, + getSessionDescription: () => ({}), + }; + + it('stops ringback when outgoing call is no longer pending', () => { + const state: OutgoingRingbackState = { + ringRoomId: '!room:example.org', + ringStartedAt: NOW, + }; + const action = evaluateOutgoingRingbackFallback(state, NOW, { + ...baseContext, + getRoom: () => undefined, + }); + expect(action.kind).toBe('stop'); + expect(action.nextState).toEqual({ ringRoomId: null, ringStartedAt: null }); + }); + + it('plays ringback for pending outgoing calls and tracks start time', () => { + const action = evaluateOutgoingRingbackFallback( + { ringRoomId: null, ringStartedAt: null }, + NOW, + { + ...baseContext, + isOutgoingPending: () => true, + isCallActive: () => false, + } + ); + expect(action).toMatchObject({ + kind: 'play', + roomId: '!room:example.org', + started: true, + }); + }); + + it('stops ringback after timeout', () => { + const action = evaluateOutgoingRingbackFallback( + { + ringRoomId: '!room:example.org', + ringStartedAt: NOW - OUTGOING_RING_TIMEOUT_MS, + }, + NOW, + { + ...baseContext, + isOutgoingPending: () => true, + isCallActive: () => false, + } + ); + expect(action.kind).toBe('stop'); + }); +}); diff --git a/src/app/features/call/callSignalingFallback.ts b/src/app/features/call/callSignalingFallback.ts new file mode 100644 index 000000000..8363ab6a4 --- /dev/null +++ b/src/app/features/call/callSignalingFallback.ts @@ -0,0 +1,123 @@ +import type { IncomingCall } from '$state/callEmbed'; +import type { Room } from '$types/matrix-sdk'; +import { + isCallActive, + isIncomingCallActive, + isOutgoingCallPending, + type SessionDescription, +} from './callMembershipState'; +import { INCOMING_MEMBERSHIP_GRACE_MS, OUTGOING_RING_TIMEOUT_MS } from './callSignalingPolicy'; + +export type IncomingFallbackAction = + | { kind: 'none' } + | { kind: 'clear'; reason: 'expired' | 'missing_room' | 'membership_dropped' }; + +export type IncomingFallbackContext = { + myUserId: string; + getRoom: (roomId: string) => Room | null | undefined; + getSessionDescription: (room: Room) => SessionDescription; + isIncomingActive?: typeof isIncomingCallActive; +}; + +export const evaluateIncomingCallFallback = ( + incoming: IncomingCall | null, + now: number, + context: IncomingFallbackContext +): IncomingFallbackAction => { + if (!incoming) return { kind: 'none' }; + if (now >= incoming.expiresAt) return { kind: 'clear', reason: 'expired' }; + + const incomingRoom = context.getRoom(incoming.roomId); + if (!incomingRoom) return { kind: 'clear', reason: 'missing_room' }; + + const sessionDescription = context.getSessionDescription(incomingRoom); + const isIncomingActive = context.isIncomingActive ?? isIncomingCallActive; + if (isIncomingActive(context.myUserId, incomingRoom, sessionDescription)) { + return { kind: 'none' }; + } + + // Session membership can lag behind live RTC notification delivery. + if (now - incoming.senderTs < INCOMING_MEMBERSHIP_GRACE_MS) { + return { kind: 'none' }; + } + + return { kind: 'clear', reason: 'membership_dropped' }; +}; + +export type OutgoingRingbackState = { + ringRoomId: string | null; + ringStartedAt: number | null; +}; + +export type OutgoingRingbackAction = + | { kind: 'stop'; nextState: OutgoingRingbackState } + | { kind: 'play'; roomId: string; nextState: OutgoingRingbackState; started: boolean }; + +export type OutgoingRingbackContext = { + myUserId: string; + activeCallRoomId: string | undefined; + outgoingRingbackAllowed: boolean; + declinedRoomId: string | null; + getRoom: (roomId: string) => Room | null | undefined; + getSessionDescription: (room: Room) => SessionDescription; + isOutgoingPending?: typeof isOutgoingCallPending; + isCallActive?: typeof isCallActive; +}; + +const clearedRingbackState = (): OutgoingRingbackState => ({ + ringRoomId: null, + ringStartedAt: null, +}); + +export const evaluateOutgoingRingbackFallback = ( + state: OutgoingRingbackState, + now: number, + context: OutgoingRingbackContext +): OutgoingRingbackAction => { + const stop = (): OutgoingRingbackAction => ({ + kind: 'stop', + nextState: clearedRingbackState(), + }); + + if (!context.activeCallRoomId || !context.outgoingRingbackAllowed) { + return stop(); + } + if (context.declinedRoomId === context.activeCallRoomId) { + return stop(); + } + + const outgoingRoom = context.getRoom(context.activeCallRoomId); + if (!outgoingRoom) { + return stop(); + } + + const sessionDescription = context.getSessionDescription(outgoingRoom); + const isOutgoingPending = context.isOutgoingPending ?? isOutgoingCallPending; + const isActive = context.isCallActive ?? isCallActive; + const pendingOutgoing = isOutgoingPending( + context.myUserId, + outgoingRoom, + sessionDescription + ); + const activeCall = isActive(context.myUserId, outgoingRoom, sessionDescription); + + if (!pendingOutgoing || activeCall) { + return stop(); + } + + const started = state.ringRoomId !== context.activeCallRoomId; + const nextState: OutgoingRingbackState = started + ? { ringRoomId: context.activeCallRoomId, ringStartedAt: now } + : state; + + if (nextState.ringStartedAt && now - nextState.ringStartedAt >= OUTGOING_RING_TIMEOUT_MS) { + return stop(); + } + + return { + kind: 'play', + roomId: context.activeCallRoomId, + nextState, + started, + }; +}; diff --git a/src/app/features/call/callSignalingPolicy.ts b/src/app/features/call/callSignalingPolicy.ts new file mode 100644 index 000000000..eb9587a24 --- /dev/null +++ b/src/app/features/call/callSignalingPolicy.ts @@ -0,0 +1,9 @@ +/** Shared timing policy for call signaling, notifications, and membership fallback. */ +export const MAX_NOTIFICATION_LIFETIME_MS = 120_000; +export const DECRYPT_TIMEOUT_MS = 8_000; +export const FALLBACK_INTERVAL_MS = 5_000; +export const OUTGOING_RING_TIMEOUT_MS = 30_000; +/** Grace window before clearing incoming call when membership lags behind RTC notification. */ +export const INCOMING_MEMBERSHIP_GRACE_MS = 15_000; +/** Delay before clearing embed after outgoing decline hangup completes. */ +export const OUTGOING_DECLINE_EMBED_CLEAR_MS = 2_000; diff --git a/src/app/features/call/getIncomingCallBlockers.test.ts b/src/app/features/call/getIncomingCallBlockers.test.ts new file mode 100644 index 000000000..1e4cdb1af --- /dev/null +++ b/src/app/features/call/getIncomingCallBlockers.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { getIncomingCallBlockers } from './getIncomingCallBlockers'; + +describe('getIncomingCallBlockers', () => { + it('returns no blockers when all capabilities are available', () => { + expect( + getIncomingCallBlockers({ + canUseWebRTC: true, + livekitSupported: true, + hasCallMemberPermission: true, + inAnotherCall: false, + }) + ).toEqual([]); + }); + + it('returns blockers in priority order', () => { + const issues = getIncomingCallBlockers({ + canUseWebRTC: false, + livekitSupported: false, + hasCallMemberPermission: false, + inAnotherCall: true, + }); + + expect(issues.map((issue) => issue.id)).toEqual([ + 'webrtc', + 'livekit', + 'permission', + 'another_call', + ]); + }); +}); diff --git a/src/app/features/call/getIncomingCallBlockers.ts b/src/app/features/call/getIncomingCallBlockers.ts new file mode 100644 index 000000000..a542e4749 --- /dev/null +++ b/src/app/features/call/getIncomingCallBlockers.ts @@ -0,0 +1,52 @@ +export type IncomingCallBlocker = { + id: string; + message: string; + shortReason: string; +}; + +export type IncomingCallBlockerInput = { + canUseWebRTC: boolean; + livekitSupported: boolean; + hasCallMemberPermission: boolean; + inAnotherCall: boolean; +}; + +export const getIncomingCallBlockers = ({ + canUseWebRTC, + livekitSupported, + hasCallMemberPermission, + inAnotherCall, +}: IncomingCallBlockerInput): IncomingCallBlocker[] => { + const issues: IncomingCallBlocker[] = []; + + if (!canUseWebRTC) { + issues.push({ + id: 'webrtc', + message: 'Your browser does not support WebRTC calling.', + shortReason: 'WebRTC is unavailable in this browser.', + }); + } + if (!livekitSupported) { + issues.push({ + id: 'livekit', + message: 'Your homeserver does not expose a LiveKit call focus.', + shortReason: 'Homeserver call focus is unavailable.', + }); + } + if (!hasCallMemberPermission) { + issues.push({ + id: 'permission', + message: "You don't have permission to join this room's call.", + shortReason: 'Missing permission to join this call.', + }); + } + if (inAnotherCall) { + issues.push({ + id: 'another_call', + message: 'You are already in another call.', + shortReason: 'Finish your current call first.', + }); + } + + return issues; +}; diff --git a/src/app/features/call/outgoingDeclineHandler.test.ts b/src/app/features/call/outgoingDeclineHandler.test.ts new file mode 100644 index 000000000..80f3f3499 --- /dev/null +++ b/src/app/features/call/outgoingDeclineHandler.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { applyOutgoingDeclineToTracker, type OutgoingDeclineTracker } from './outgoingDeclineHandler'; + +const decline = { + roomId: '!room:example.org', + declineEventId: '$decline', + notificationEventId: '$notif', + senderId: '@alice:example.org', +}; + +describe('applyOutgoingDeclineToTracker', () => { + it('ends call immediately for direct rooms', () => { + const tracker: OutgoingDeclineTracker = new Map(); + const decision = applyOutgoingDeclineToTracker(tracker, decline, { + remoteJoinedIds: new Set(['@alice:example.org']), + isDirectRoom: true, + }); + + expect(decision).toEqual({ kind: 'end_call', declinedCount: 1, targetCount: 1 }); + }); + + it('ignores partial declines in group calls until all remotes decline', () => { + const tracker: OutgoingDeclineTracker = new Map(); + const remoteJoinedIds = new Set(['@alice:example.org', '@bob:example.org']); + + const partial = applyOutgoingDeclineToTracker(tracker, decline, { + remoteJoinedIds, + isDirectRoom: false, + }); + expect(partial).toEqual({ kind: 'ignore_partial', declinedCount: 1, targetCount: 2 }); + + const end = applyOutgoingDeclineToTracker( + tracker, + { ...decline, senderId: '@bob:example.org' }, + { remoteJoinedIds, isDirectRoom: false } + ); + expect(end).toEqual({ kind: 'end_call', declinedCount: 2, targetCount: 2 }); + }); +}); diff --git a/src/app/features/call/outgoingDeclineHandler.ts b/src/app/features/call/outgoingDeclineHandler.ts new file mode 100644 index 000000000..ce234c08c --- /dev/null +++ b/src/app/features/call/outgoingDeclineHandler.ts @@ -0,0 +1,49 @@ +export type OutgoingDeclineEvent = { + roomId: string; + declineEventId: string; + notificationEventId: string; + senderId: string; +}; + +export type OutgoingDeclineTrackerState = { + notificationEventId: string; + declinerIds: Set; +}; + +export type OutgoingDeclineTracker = Map; + +export type OutgoingDeclineDecision = + | { kind: 'ignore_partial'; declinedCount: number; targetCount: number } + | { kind: 'end_call'; declinedCount: number; targetCount: number }; + +export const applyOutgoingDeclineToTracker = ( + tracker: OutgoingDeclineTracker, + decline: OutgoingDeclineEvent, + options: { + remoteJoinedIds: Set; + isDirectRoom: boolean; + } +): OutgoingDeclineDecision => { + const trackedDecline = tracker.get(decline.roomId); + const declineState = + trackedDecline && trackedDecline.notificationEventId === decline.notificationEventId + ? trackedDecline + : { + notificationEventId: decline.notificationEventId, + declinerIds: new Set(), + }; + declineState.declinerIds.add(decline.senderId); + tracker.set(decline.roomId, declineState); + + const targetCount = options.remoteJoinedIds.size; + const declinedCount = declineState.declinerIds.size; + const allRemoteDeclined = + targetCount > 0 && [...options.remoteJoinedIds].every((userId) => declineState.declinerIds.has(userId)); + const treatAsOneToOne = options.isDirectRoom || targetCount <= 1; + + if (!treatAsOneToOne && targetCount > 0 && !allRemoteDeclined) { + return { kind: 'ignore_partial', declinedCount, targetCount }; + } + + return { kind: 'end_call', declinedCount, targetCount }; +}; diff --git a/src/app/features/call/rtcTimelineDecline.ts b/src/app/features/call/rtcTimelineDecline.ts new file mode 100644 index 000000000..759f84f1c --- /dev/null +++ b/src/app/features/call/rtcTimelineDecline.ts @@ -0,0 +1,58 @@ +import * as Sentry from '@sentry/react'; +import type { MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; +import { decryptRtcTimelineEvent } from './callSignalingDecrypt'; +import { parseRtcDecline, type ParsedRtcDecline } from './rtcNotificationParser'; + +const relationFromContent = (content: unknown) => { + if (!content || typeof content !== 'object') return undefined; + const maybeRelates = (content as { 'm.relates_to'?: unknown })['m.relates_to']; + if (!maybeRelates || typeof maybeRelates !== 'object') return undefined; + const relation = maybeRelates as { rel_type?: unknown; event_id?: unknown }; + return { + rel_type: typeof relation.rel_type === 'string' ? relation.rel_type : undefined, + event_id: typeof relation.event_id === 'string' ? relation.event_id : undefined, + }; +}; + +export const parseRtcDeclineFromTimelineEvent = async ( + event: MatrixEvent, + room: Room, + liveEvent: boolean, + myUserId: string, + mx: MatrixClient +): Promise => { + let eventType = event.getType(); + let content = event.getContent(); + + if (event.isEncrypted()) { + const decrypted = await decryptRtcTimelineEvent(event, mx); + if (!decrypted?.content || !decrypted.type) { + Sentry.metrics.count('sable.call.signal.decrypt_timeout', 1); + return undefined; + } + eventType = decrypted.type; + content = decrypted.content; + } + + const relation = event.getRelation() ?? relationFromContent(content); + + return parseRtcDecline( + { + type: eventType, + sender: event.getSender() ?? '', + roomId: room.roomId, + eventId: event.getId() ?? '', + originServerTs: event.getTs(), + content, + relation: relation + ? { + rel_type: relation.rel_type, + event_id: relation.event_id, + } + : undefined, + isLiveEvent: liveEvent, + isEncrypted: false, + }, + { myUserId } + ); +}; diff --git a/src/app/features/common-settings/permissions/PermissionGroups.tsx b/src/app/features/common-settings/permissions/PermissionGroups.tsx index 86ff78ef4..d5ae1d9b3 100644 --- a/src/app/features/common-settings/permissions/PermissionGroups.tsx +++ b/src/app/features/common-settings/permissions/PermissionGroups.tsx @@ -186,7 +186,7 @@ export function PermissionGroups({ return ( JSON.stringify(location); + type PeekPermissionsProps = { powerLevels: IPowerLevels; power: number; @@ -67,7 +69,7 @@ function PeekPermissions({ powerLevels, power, permissionGroups, children }: Pee return ( ({ @@ -8,23 +9,7 @@ vi.mock('$state/settings', () => ({ })); vi.mock('$state/hooks/settings', () => ({ - useSetting: (_atom: unknown, key: string) => { - const values: Record = { - incomingCallSoundEnabled: true, - outgoingRingbackEnabled: true, - callRingtoneId: 'sable-default', - callRingbackTone: 'sable-default', - callRingtoneVolume: 80, - callSoundOverrideGlobalNotifications: false, - callCustomRingtoneName: undefined, - callCustomRingtoneSizeBytes: undefined, - callCustomRingtoneDurationMs: undefined, - callCustomRingbackName: undefined, - callCustomRingbackSizeBytes: undefined, - callCustomRingbackDurationMs: undefined, - }; - return [values[key], vi.fn<(value: unknown) => void>()] as const; - }, + useSetting: vi.fn(), })); vi.mock('$features/call/callRingtoneStorage', () => ({ @@ -36,7 +21,38 @@ vi.mock('$features/call/callRingtoneStorage', () => ({ clearCustomCallRingback: vi.fn<() => Promise>(), })); +const defaultSettingValues: Record = { + incomingCallSoundEnabled: true, + outgoingRingbackEnabled: true, + callRingtoneId: 'sable-default', + callRingbackTone: 'sable-default', + callRingtoneVolume: 80, + callSoundOverrideGlobalNotifications: false, +}; + describe('CallSoundSettings', () => { + beforeEach(() => { + vi.mocked(useSetting).mockImplementation((_atom: unknown, key: string) => { + return [defaultSettingValues[key], vi.fn<(value: unknown) => void>()] as const; + }); + }); + + it('falls back to default ringtone when custom ringtone is unavailable', async () => { + const setCallRingtoneId = vi.fn<(value: unknown) => void>(); + vi.mocked(useSetting).mockImplementation((_atom: unknown, key: string) => { + if (key === 'callRingtoneId') { + return ['custom', setCallRingtoneId] as const; + } + return [defaultSettingValues[key], vi.fn<(value: unknown) => void>()] as const; + }); + + render(); + + await waitFor(() => { + expect(setCallRingtoneId).toHaveBeenCalledWith('sable-default'); + }); + }); + it('renders expected call sound setting controls', async () => { render(); diff --git a/src/app/features/settings/general/CallSoundSettings.tsx b/src/app/features/settings/general/CallSoundSettings.tsx index 30c5e4645..3413d0798 100644 --- a/src/app/features/settings/general/CallSoundSettings.tsx +++ b/src/app/features/settings/general/CallSoundSettings.tsx @@ -1,15 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Box, Button, Icon, Icons, Input, Spinner, Switch, Text, toRem } from 'folds'; +import { Box, Icons, Input, Switch, Text, toRem } from 'folds'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; import { SettingMenuSelector } from '$components/setting-menu-selector'; import { useSetting } from '$state/hooks/settings'; -import { settingsAtom, type CallRingbackTone, type CallRingtoneId } from '$state/settings'; +import { settingsAtom, type CallRingtoneId } from '$state/settings'; import { CALL_RINGBACK_OPTIONS, CALL_RINGTONE_OPTIONS, - CUSTOM_CALL_RINGTONE_MAX_BYTES, - CUSTOM_CALL_RINGTONE_MAX_DURATION_MS, callRingtoneVolumeToGain, clampCallRingtoneVolume, readAudioDurationMs, @@ -23,158 +21,21 @@ import { getCustomCallRingtone, putCustomCallRingback, putCustomCallRingtone, + type StoredCallRingtone, } from '$features/call/callRingtoneStorage'; import { SequenceCardStyle } from '$features/settings/styles.css'; -import { bytesToSize, millisecondsToMinutesAndSeconds } from '$utils/common'; - -type PreviewTone = 'incoming' | 'outgoing'; - -function CustomToneMeta({ - fileName, - sizeBytes, - durationMs, - emptyLabel, -}: { - fileName?: string; - sizeBytes?: number; - durationMs?: number; - emptyLabel: string; -}) { - if (!fileName) { - return ( - - {emptyLabel} - - ); - } - - return ( - - {[ - fileName, - typeof sizeBytes === 'number' ? bytesToSize(sizeBytes) : undefined, - typeof durationMs === 'number' ? millisecondsToMinutesAndSeconds(durationMs) : undefined, - ] - .filter(Boolean) - .join(' - ')} - - ); -} - -function CustomToneSettingsCard({ - title, - focusId, - description, - fileName, - sizeBytes, - durationMs, - emptyLabel, - hasCustomTone, - previewing, - previewActions, - onImport, - onPreview, - onReset, -}: { - title: string; - focusId: string; - description: string; - fileName?: string; - sizeBytes?: number; - durationMs?: number; - emptyLabel: string; - hasCustomTone: boolean; - previewing: boolean; - previewActions: { - label: string; - tone: PreviewTone; - icon: (typeof Icons)[keyof typeof Icons]; - }[]; - onImport: () => void; - onPreview: (tone: PreviewTone) => void; - onReset: () => void; -}) { - return ( - - - - - - - {previewActions.map(({ label, tone, icon }) => ( - - ))} - - - - Max file size: {bytesToSize(CUSTOM_CALL_RINGTONE_MAX_BYTES)}. Max duration:{' '} - {millisecondsToMinutesAndSeconds(CUSTOM_CALL_RINGTONE_MAX_DURATION_MS)}. - - - - - ); -} - -const customToneValidationError = ( - reason: 'type' | 'size' | 'duration', - label: 'Ringtone' | 'Ringback' -): string => { - if (reason === 'type') return 'Only audio files are supported.'; - if (reason === 'size') { - return `File is too large. Max ${bytesToSize(CUSTOM_CALL_RINGTONE_MAX_BYTES)} allowed.`; - } +import { + CustomToneSettingsCard, + customToneValidationError, + type CustomToneMetadata, + type PreviewTone, +} from './CallSoundSettingsCards'; - return `${label} must be between 1s and ${millisecondsToMinutesAndSeconds( - CUSTOM_CALL_RINGTONE_MAX_DURATION_MS - )}.`; -}; +const toCustomToneMetadata = (stored: StoredCallRingtone): CustomToneMetadata => ({ + fileName: stored.fileName, + sizeBytes: stored.sizeBytes, + durationMs: stored.durationMs, +}); export function CallSoundSettings() { const [incomingCallSoundEnabled, setIncomingCallSoundEnabled] = useSetting( @@ -193,35 +54,13 @@ export function CallSoundSettings() { ); const [callSoundOverrideGlobalNotifications, setCallSoundOverrideGlobalNotifications] = useSetting(settingsAtom, 'callSoundOverrideGlobalNotifications'); - const [callCustomRingtoneName, setCallCustomRingtoneName] = useSetting( - settingsAtom, - 'callCustomRingtoneName' - ); - const [callCustomRingtoneSizeBytes, setCallCustomRingtoneSizeBytes] = useSetting( - settingsAtom, - 'callCustomRingtoneSizeBytes' - ); - const [callCustomRingtoneDurationMs, setCallCustomRingtoneDurationMs] = useSetting( - settingsAtom, - 'callCustomRingtoneDurationMs' - ); - const [callCustomRingbackName, setCallCustomRingbackName] = useSetting( - settingsAtom, - 'callCustomRingbackName' - ); - const [callCustomRingbackSizeBytes, setCallCustomRingbackSizeBytes] = useSetting( - settingsAtom, - 'callCustomRingbackSizeBytes' - ); - const [callCustomRingbackDurationMs, setCallCustomRingbackDurationMs] = useSetting( - settingsAtom, - 'callCustomRingbackDurationMs' - ); const [previewing, setPreviewing] = useState(false); const [loadingCustomState, setLoadingCustomState] = useState(true); const [hasCustomRingtone, setHasCustomRingtone] = useState(false); const [hasCustomRingback, setHasCustomRingback] = useState(false); + const [customRingtoneMeta, setCustomRingtoneMeta] = useState(null); + const [customRingbackMeta, setCustomRingbackMeta] = useState(null); const [customError, setCustomError] = useState(null); const previewAudioRef = useRef(null); @@ -232,6 +71,8 @@ export function CallSoundSettings() { if (!mounted) return; setHasCustomRingtone(Boolean(ringtone)); setHasCustomRingback(Boolean(ringback)); + setCustomRingtoneMeta(ringtone ? toCustomToneMetadata(ringtone) : null); + setCustomRingbackMeta(ringback ? toCustomToneMetadata(ringback) : null); }) .finally(() => { if (!mounted) return; @@ -270,12 +111,12 @@ export function CallSoundSettings() { option.value === 'custom' ? { ...option, - label: callCustomRingtoneName ? 'Custom File (Imported)' : 'Custom File', + label: customRingtoneMeta ? 'Custom File (Imported)' : 'Custom File', disabled: loadingCustomState, } : option ), - [callCustomRingtoneName, loadingCustomState] + [customRingtoneMeta, loadingCustomState] ); const ringbackOptions = useMemo( () => @@ -283,12 +124,12 @@ export function CallSoundSettings() { option.value === 'custom' ? { ...option, - label: callCustomRingbackName ? 'Custom File (Imported)' : 'Custom File', + label: customRingbackMeta ? 'Custom File (Imported)' : 'Custom File', disabled: loadingCustomState, } : option ), - [callCustomRingbackName, loadingCustomState] + [customRingbackMeta, loadingCustomState] ); const resolveToneForPreview = useCallback( @@ -335,8 +176,8 @@ export function CallSoundSettings() { const importCustomTone = useCallback( ( label: 'Ringtone' | 'Ringback', - putTone: (file: File, durationMs: number) => Promise, - onImported: (file: File, durationMs: number) => void + putTone: (file: File, durationMs: number) => Promise, + onImported: (stored: StoredCallRingtone) => void ) => { setCustomError(null); const input = document.createElement('input'); @@ -359,8 +200,8 @@ export function CallSoundSettings() { return; } - await putTone(file, durationMs); - onImported(file, durationMs); + const stored = await putTone(file, durationMs); + onImported(stored); } catch { setCustomError('Could not import this file. Try a different audio format.'); } @@ -372,72 +213,40 @@ export function CallSoundSettings() { ); const handleImportCustomRingtone = useCallback(() => { - importCustomTone('Ringtone', putCustomCallRingtone, (file, durationMs) => { + importCustomTone('Ringtone', putCustomCallRingtone, (stored) => { setHasCustomRingtone(true); setCallRingtoneId('custom'); - setCallCustomRingtoneName(file.name); - setCallCustomRingtoneSizeBytes(file.size); - setCallCustomRingtoneDurationMs(durationMs); + setCustomRingtoneMeta(toCustomToneMetadata(stored)); }); - }, [ - importCustomTone, - setCallCustomRingtoneDurationMs, - setCallCustomRingtoneName, - setCallCustomRingtoneSizeBytes, - setCallRingtoneId, - ]); + }, [importCustomTone, setCallRingtoneId]); const handleResetCustomRingtone = useCallback(async () => { setCustomError(null); await clearCustomCallRingtone(); setHasCustomRingtone(false); - setCallCustomRingtoneName(undefined); - setCallCustomRingtoneSizeBytes(undefined); - setCallCustomRingtoneDurationMs(undefined); + setCustomRingtoneMeta(null); if (callRingtoneId === 'custom') { setCallRingtoneId('sable-default'); } - }, [ - callRingtoneId, - setCallCustomRingtoneDurationMs, - setCallCustomRingtoneName, - setCallCustomRingtoneSizeBytes, - setCallRingtoneId, - ]); + }, [callRingtoneId, setCallRingtoneId]); const handleImportCustomRingback = useCallback(() => { - importCustomTone('Ringback', putCustomCallRingback, (file, durationMs) => { + importCustomTone('Ringback', putCustomCallRingback, (stored) => { setHasCustomRingback(true); setCallRingbackTone('custom'); - setCallCustomRingbackName(file.name); - setCallCustomRingbackSizeBytes(file.size); - setCallCustomRingbackDurationMs(durationMs); + setCustomRingbackMeta(toCustomToneMetadata(stored)); }); - }, [ - importCustomTone, - setCallCustomRingbackDurationMs, - setCallCustomRingbackName, - setCallCustomRingbackSizeBytes, - setCallRingbackTone, - ]); + }, [importCustomTone, setCallRingbackTone]); const handleResetCustomRingback = useCallback(async () => { setCustomError(null); await clearCustomCallRingback(); setHasCustomRingback(false); - setCallCustomRingbackName(undefined); - setCallCustomRingbackSizeBytes(undefined); - setCallCustomRingbackDurationMs(undefined); + setCustomRingbackMeta(null); if (callRingbackTone === 'custom') { setCallRingbackTone('sable-default'); } - }, [ - callRingbackTone, - setCallCustomRingbackDurationMs, - setCallCustomRingbackName, - setCallCustomRingbackSizeBytes, - setCallRingbackTone, - ]); + }, [callRingbackTone, setCallRingbackTone]); const handleRingtoneSelection = (next: CallRingtoneId) => { if (next === 'custom' && !hasCustomRingtone) { @@ -448,7 +257,7 @@ export function CallSoundSettings() { setCallRingtoneId(next); }; - const handleRingbackSelection = (next: CallRingbackTone) => { + const handleRingbackSelection = (next: CallRingtoneId) => { if (next === 'custom' && !hasCustomRingback) { setCustomError('Import a custom ringback file first.'); return; @@ -562,9 +371,7 @@ export function CallSoundSettings() { title="Custom Ringtone" focusId="custom-call-ringtone" description="Import an audio file for your ringtone." - fileName={callCustomRingtoneName} - sizeBytes={callCustomRingtoneSizeBytes} - durationMs={callCustomRingtoneDurationMs} + metadata={customRingtoneMeta} emptyLabel="No custom ringtone imported." hasCustomTone={hasCustomRingtone} previewing={previewing} @@ -580,9 +387,7 @@ export function CallSoundSettings() { title="Custom Ringback" focusId="custom-call-ringback" description="Import an audio file for outgoing ringback." - fileName={callCustomRingbackName} - sizeBytes={callCustomRingbackSizeBytes} - durationMs={callCustomRingbackDurationMs} + metadata={customRingbackMeta} emptyLabel="No custom ringback imported." hasCustomTone={hasCustomRingback} previewing={previewing} diff --git a/src/app/features/settings/general/CallSoundSettingsCards.tsx b/src/app/features/settings/general/CallSoundSettingsCards.tsx new file mode 100644 index 000000000..92b849730 --- /dev/null +++ b/src/app/features/settings/general/CallSoundSettingsCards.tsx @@ -0,0 +1,149 @@ +import { Box, Button, Icon, Icons, Spinner, Text } from 'folds'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { SequenceCardStyle } from '$features/settings/styles.css'; +import { + CUSTOM_CALL_RINGTONE_MAX_BYTES, + CUSTOM_CALL_RINGTONE_MAX_DURATION_MS, +} from '$features/call/callRingtone'; +import { bytesToSize, millisecondsToMinutesAndSeconds } from '$utils/common'; + +export type PreviewTone = 'incoming' | 'outgoing'; + +export type CustomToneMetadata = { + fileName: string; + sizeBytes: number; + durationMs: number; +}; + +export function CustomToneMeta({ + metadata, + emptyLabel, +}: { + metadata: CustomToneMetadata | null; + emptyLabel: string; +}) { + if (!metadata) { + return ( + + {emptyLabel} + + ); + } + + return ( + + {[ + metadata.fileName, + bytesToSize(metadata.sizeBytes), + millisecondsToMinutesAndSeconds(metadata.durationMs), + ].join(' - ')} + + ); +} + +export function CustomToneSettingsCard({ + title, + focusId, + description, + metadata, + emptyLabel, + hasCustomTone, + previewing, + previewActions, + onImport, + onPreview, + onReset, +}: { + title: string; + focusId: string; + description: string; + metadata: CustomToneMetadata | null; + emptyLabel: string; + hasCustomTone: boolean; + previewing: boolean; + previewActions: { + label: string; + tone: PreviewTone; + icon: (typeof Icons)[keyof typeof Icons]; + }[]; + onImport: () => void; + onPreview: (tone: PreviewTone) => void; + onReset: () => void; +}) { + return ( + + + + + + + {previewActions.map(({ label, tone, icon }) => ( + + ))} + + + + Max file size: {bytesToSize(CUSTOM_CALL_RINGTONE_MAX_BYTES)}. Max duration:{' '} + {millisecondsToMinutesAndSeconds(CUSTOM_CALL_RINGTONE_MAX_DURATION_MS)}. + + + + + ); +} + +export const customToneValidationError = ( + reason: 'type' | 'size' | 'duration', + label: 'Ringtone' | 'Ringback' +): string => { + if (reason === 'type') return 'Only audio files are supported.'; + if (reason === 'size') { + return `File is too large. Max ${bytesToSize(CUSTOM_CALL_RINGTONE_MAX_BYTES)} allowed.`; + } + + return `${label} must be between 1s and ${millisecondsToMinutesAndSeconds( + CUSTOM_CALL_RINGTONE_MAX_DURATION_MS + )}.`; +}; diff --git a/src/app/features/space-settings/permissions/usePermissionItems.ts b/src/app/features/space-settings/permissions/usePermissionItems.ts index 958e64e3a..e05beb02e 100644 --- a/src/app/features/space-settings/permissions/usePermissionItems.ts +++ b/src/app/features/space-settings/permissions/usePermissionItems.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import type { PermissionGroup } from '$features/common-settings/permissions'; -import { CALL_PERMISSIONS_GROUP } from '$features/common-settings/permissions/callPermissions'; +import { CALL_PERMISSIONS_GROUP } from '$features/common-settings/permissions'; import { EventType } from '$types/matrix-sdk'; import { CustomStateEvent } from '$types/matrix/room'; diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index 314609c39..bb7567580 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -1,13 +1,8 @@ import { useCallback, useEffect, useRef } from 'react'; import * as Sentry from '@sentry/react'; import { useAtomValue, useSetAtom, useStore } from 'jotai'; -import type { RoomEventHandlerMap, MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk'; -import { - type CryptoBackend, - MatrixRTCSession, - MatrixRTCSessionManagerEvents, - RoomEvent, -} from '$types/matrix-sdk'; +import type { RoomEventHandlerMap, MatrixEvent, Room } from '$types/matrix-sdk'; +import { MatrixRTCSessionManagerEvents, RoomEvent } from '$types/matrix-sdk'; import { mDirectAtom } from '$state/mDirectList'; import { callEmbedAtom, @@ -18,12 +13,26 @@ import { } from '$state/callEmbed'; import { settingsAtom } from '$state/settings'; import { - parseRtcDecline, parseIncomingRtcNotification, RTC_DECLINE_EVENT_TYPE, REFERENCE_REL_TYPE, RTC_NOTIFICATION_EVENT_TYPE, } from '$features/call/rtcNotificationParser'; +import { decryptRtcTimelineEvent } from '$features/call/callSignalingDecrypt'; +import { + FALLBACK_INTERVAL_MS, + MAX_NOTIFICATION_LIFETIME_MS, + OUTGOING_DECLINE_EMBED_CLEAR_MS, +} from '$features/call/callSignalingPolicy'; +import { + applyOutgoingDeclineToTracker, + type OutgoingDeclineEvent, +} from '$features/call/outgoingDeclineHandler'; +import { parseRtcDeclineFromTimelineEvent } from '$features/call/rtcTimelineDecline'; +import { + evaluateIncomingCallFallback, + evaluateOutgoingRingbackFallback, +} from '$features/call/callSignalingFallback'; import { callRingtoneVolumeToGain, canPlayCallAudio } from '$features/call/callRingtone'; import { dismissSystemCallNotifications } from '$features/call/callNotificationBridge'; import { resolveCallToneSources } from '$features/call/callToneSources'; @@ -32,112 +41,6 @@ import { createDebugLogger } from '../utils/debugLogger'; const debugLog = createDebugLogger('CallSignaling'); -const MAX_NOTIFICATION_LIFETIME_MS = 120_000; -const DECRYPT_TIMEOUT_MS = 8_000; -const FALLBACK_INTERVAL_MS = 5_000; -const OUTGOING_RING_TIMEOUT_MS = 30_000; - -type SessionDescription = Parameters[1]; -type RtcMembership = { userId?: string; sender?: string }; - -const getRoomMemberships = (room: Room, sessionDescription: SessionDescription) => - MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription); - -const getCallMembershipPresence = ( - mxUserId: string, - room: Room, - sessionDescription: SessionDescription -) => { - const memberships = getRoomMemberships(room, sessionDescription) as RtcMembership[]; - const remoteMemberCount = memberships.filter( - (membership) => (membership.userId || membership.sender) !== mxUserId - ).length; - const hasSelfMember = memberships.some( - (membership) => (membership.userId || membership.sender) === mxUserId - ); - - return { hasSelfMember, remoteMemberCount }; -}; - -const isIncomingCallActive = ( - mxUserId: string, - room: Room, - sessionDescription: SessionDescription -): boolean => { - const { hasSelfMember, remoteMemberCount } = getCallMembershipPresence( - mxUserId, - room, - sessionDescription - ); - - return remoteMemberCount > 0 && !hasSelfMember; -}; - -const isCallActive = ( - mxUserId: string, - room: Room, - sessionDescription: SessionDescription -): boolean => { - const { hasSelfMember, remoteMemberCount } = getCallMembershipPresence( - mxUserId, - room, - sessionDescription - ); - - return hasSelfMember && remoteMemberCount > 0; -}; - -const isOutgoingCallPending = ( - mxUserId: string, - room: Room, - sessionDescription: SessionDescription -): boolean => { - const { hasSelfMember, remoteMemberCount } = getCallMembershipPresence( - mxUserId, - room, - sessionDescription - ); - - return hasSelfMember && remoteMemberCount === 0; -}; - -const decryptWithTimeout = async ( - event: MatrixEvent, - mx: MatrixClient -): Promise<{ type?: string; content?: unknown } | undefined> => { - const crypto = mx.getCrypto(); - if (!crypto) return undefined; - - try { - if (!event.isBeingDecrypted()) { - await event.attemptDecryption(crypto as CryptoBackend); - } - - const decryptionPromise = event.getDecryptionPromise(); - if (decryptionPromise) { - await Promise.race([ - decryptionPromise, - new Promise((resolve) => { - window.setTimeout(resolve, DECRYPT_TIMEOUT_MS); - }), - ]); - } - } catch (error) { - debugLog.warn('call', 'RTC notification decryption failed', { - eventId: event.getId(), - roomId: event.getRoomId(), - error: error instanceof Error ? error.message : String(error), - }); - return undefined; - } - - const effectiveEvent = event.getEffectiveEvent(); - return { - type: effectiveEvent.type, - content: effectiveEvent.content, - }; -}; - const canSenderStartCalls = (room: Room, senderId: string): boolean => room.currentState?.maySendStateEvent('org.matrix.msc3401.call.member', senderId) ?? false; @@ -159,6 +62,19 @@ export function useIncomingCallSignaling() { const incomingCallRef = useRef(incomingCall); const mutedRoomIdRef = useRef(mutedRoomId); const seenNotificationIdsRef = useRef>(new Set()); + const MAX_SEEN_NOTIFICATION_IDS = 256; + + const rememberNotificationId = (notificationEventId: string) => { + const seen = seenNotificationIdsRef.current; + if (seen.has(notificationEventId)) return false; + seen.add(notificationEventId); + while (seen.size > MAX_SEEN_NOTIFICATION_IDS) { + const oldest = seen.values().next().value; + if (!oldest) break; + seen.delete(oldest); + } + return true; + }; const outgoingRingRoomIdRef = useRef(null); const declinedOutgoingRoomIdRef = useRef(null); const outgoingDeclinesRef = useRef< @@ -283,12 +199,7 @@ export function useIncomingCallSignaling() { }, [setIncomingCall, stopIncomingRing]); const handleOutgoingDecline = useCallback( - (decline: { - roomId: string; - declineEventId: string; - notificationEventId: string; - senderId: string; - }) => { + (decline: OutgoingDeclineEvent) => { if (!callEmbed || callEmbed.roomId !== decline.roomId) { return; } @@ -298,7 +209,6 @@ export function useIncomingCallSignaling() { return; } - const isDirectRoom = mDirects.has(decline.roomId); const remoteJoinedIds = new Set( outgoingRoom .getJoinedMembers() @@ -306,29 +216,18 @@ export function useIncomingCallSignaling() { .filter((userId) => userId !== mx.getSafeUserId()) ); - const trackedDecline = outgoingDeclinesRef.current.get(decline.roomId); - const declineState = - trackedDecline && trackedDecline.notificationEventId === decline.notificationEventId - ? trackedDecline - : { - notificationEventId: decline.notificationEventId, - declinerIds: new Set(), - }; - declineState.declinerIds.add(decline.senderId); - outgoingDeclinesRef.current.set(decline.roomId, declineState); - - const allRemoteDeclined = - remoteJoinedIds.size > 0 && - [...remoteJoinedIds].every((userId) => declineState.declinerIds.has(userId)); - const treatAsOneToOne = isDirectRoom || remoteJoinedIds.size <= 1; - - if (!treatAsOneToOne && remoteJoinedIds.size > 0 && !allRemoteDeclined) { + const decision = applyOutgoingDeclineToTracker(outgoingDeclinesRef.current, decline, { + remoteJoinedIds, + isDirectRoom: mDirects.has(decline.roomId), + }); + + if (decision.kind === 'ignore_partial') { debugLog.info('call', 'Ignoring partial outgoing decline for group call', { roomId: decline.roomId, declineEventId: decline.declineEventId, notificationEventId: decline.notificationEventId, - declinedCount: declineState.declinerIds.size, - targetCount: remoteJoinedIds.size, + declinedCount: decision.declinedCount, + targetCount: decision.targetCount, }); Sentry.metrics.count('sable.call.outgoing.declined.partial', 1); return; @@ -339,8 +238,8 @@ export function useIncomingCallSignaling() { roomId: decline.roomId, declineEventId: decline.declineEventId, notificationEventId: decline.notificationEventId, - declinedCount: declineState.declinerIds.size, - targetCount: remoteJoinedIds.size, + declinedCount: decision.declinedCount, + targetCount: decision.targetCount, }); Sentry.metrics.count('sable.call.outgoing.declined', 1); stopOutgoingRing(); @@ -359,7 +258,7 @@ export function useIncomingCallSignaling() { const activeEmbed = store.get(callEmbedAtom); if (activeEmbed !== callEmbed) return; setCallEmbed(undefined); - }, 2_000); + }, OUTGOING_DECLINE_EMBED_CLEAR_MS); }); }, [callEmbed, mDirects, mx, setCallEmbed, stopOutgoingRing, store] @@ -402,9 +301,7 @@ export function useIncomingCallSignaling() { const handleIncomingCall = useCallback( (nextIncomingCall: IncomingCall) => { if (mutedRoomIdRef.current === nextIncomingCall.roomId) return; - if (seenNotificationIdsRef.current.has(nextIncomingCall.notificationEventId)) return; - - seenNotificationIdsRef.current.add(nextIncomingCall.notificationEventId); + if (!rememberNotificationId(nextIncomingCall.notificationEventId)) return; setIncomingCall(nextIncomingCall); debugLog.info('call', 'Incoming RTC notification accepted', { @@ -464,7 +361,7 @@ export function useIncomingCallSignaling() { let content = event.getContent(); if (event.isEncrypted()) { - const decrypted = await decryptWithTimeout(event, mx); + const decrypted = await decryptRtcTimelineEvent(event, mx); if (!decrypted?.content || !decrypted.type) { Sentry.metrics.count('sable.call.signal.decrypt_timeout', 1); return undefined; @@ -510,57 +407,7 @@ export function useIncomingCallSignaling() { }; }; - const parseDeclineEvent = async ( - event: MatrixEvent, - room: Room, - liveEvent: boolean - ): Promise> => { - let eventType = event.getType(); - let content = event.getContent(); - - if (event.isEncrypted()) { - const decrypted = await decryptWithTimeout(event, mx); - if (!decrypted?.content || !decrypted.type) { - Sentry.metrics.count('sable.call.signal.decrypt_timeout', 1); - } else { - eventType = decrypted.type; - content = decrypted.content; - } - } - - const relationFromContent = (() => { - if (!content || typeof content !== 'object') return undefined; - const maybeRelates = (content as { 'm.relates_to'?: unknown })['m.relates_to']; - if (!maybeRelates || typeof maybeRelates !== 'object') return undefined; - const relation = maybeRelates as { rel_type?: unknown; event_id?: unknown }; - return { - rel_type: typeof relation.rel_type === 'string' ? relation.rel_type : undefined, - event_id: typeof relation.event_id === 'string' ? relation.event_id : undefined, - }; - })(); - const relation = event.getRelation() ?? relationFromContent; - - const parsed = parseRtcDecline( - { - type: eventType, - sender: event.getSender() ?? '', - roomId: room.roomId, - eventId: event.getId() ?? '', - originServerTs: event.getTs(), - content, - relation: relation - ? { - rel_type: relation.rel_type, - event_id: relation.event_id, - } - : undefined, - isLiveEvent: liveEvent, - isEncrypted: false, - }, - { myUserId } - ); - return parsed; - }; + let timelineHandlerEpoch = 0; const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = async ( event, @@ -571,6 +418,9 @@ export function useIncomingCallSignaling() { ) => { if (!room || !data.liveEvent) return; + const epochAtStart = timelineHandlerEpoch; + const isStale = () => epochAtStart !== timelineHandlerEpoch; + const relation = event.getRelation(); if (relation?.rel_type !== REFERENCE_REL_TYPE && !event.isEncrypted()) return; @@ -586,6 +436,7 @@ export function useIncomingCallSignaling() { if (!event.getId()) return; const incoming = await parseEvent(event, room, data.liveEvent); + if (isStale()) return; if (incoming) { handlers().handleIncomingCall(incoming); return; @@ -602,87 +453,74 @@ export function useIncomingCallSignaling() { return; } - const decline = await parseDeclineEvent(event, room, data.liveEvent); + const decline = await parseRtcDeclineFromTimelineEvent( + event, + room, + data.liveEvent, + myUserId, + mx + ); + if (isStale()) return; if (decline) { handlers().handleOutgoingDecline(decline); - return; } }; - const evaluateFallbackState = () => { - const now = Date.now(); - - const currentIncoming = incomingCallRef.current; - if (currentIncoming) { - if (Date.now() >= currentIncoming.expiresAt) { - debugLog.info('call', 'Incoming call timed out', { - roomId: currentIncoming.roomId, - notificationEventId: currentIncoming.notificationEventId, - }); - Sentry.metrics.count('sable.call.timeout', 1); - handlers().clearIncomingCall(); - return; - } - - const incomingRoom = mx.getRoom(currentIncoming.roomId); - if (!incomingRoom) { - handlers().clearIncomingCall(); - return; - } + const fallbackContext = { + myUserId, + getRoom: (roomId: string) => mx.getRoom(roomId), + getSessionDescription: (room: Room) => mx.matrixRTC.getRoomSession(room).sessionDescription, + }; - const session = mx.matrixRTC.getRoomSession(incomingRoom); - if (!isIncomingCallActive(myUserId, incomingRoom, session.sessionDescription)) { - // Session membership can lag behind live RTC notification delivery. - // Keep ringing for a short grace window before treating the call as ended. - if (now - currentIncoming.senderTs < 15_000) { - return; - } - debugLog.info('call', 'Incoming call cleared after membership drop', { - roomId: currentIncoming.roomId, - }); - handlers().clearIncomingCall(); - return; - } - } + const evaluateIncomingFallback = () => { + const action = evaluateIncomingCallFallback( + incomingCallRef.current, + Date.now(), + fallbackContext + ); + if (action.kind !== 'clear') return; - const activeCallRoomId = handlers().callEmbed?.roomId; - if (!activeCallRoomId || !handlers().outgoingRingbackAllowed) { - handlers().stopOutgoingRing(); - return; - } - if (declinedOutgoingRoomIdRef.current === activeCallRoomId) { - handlers().stopOutgoingRing(); - return; + if (action.reason === 'expired') { + const currentIncoming = incomingCallRef.current; + debugLog.info('call', 'Incoming call timed out', { + roomId: currentIncoming?.roomId, + notificationEventId: currentIncoming?.notificationEventId, + }); + Sentry.metrics.count('sable.call.timeout', 1); + } else if (action.reason === 'membership_dropped') { + debugLog.info('call', 'Incoming call cleared after membership drop', { + roomId: incomingCallRef.current?.roomId, + }); } - const outgoingRoom = mx.getRoom(activeCallRoomId); - if (!outgoingRoom) { - handlers().stopOutgoingRing(); - return; - } + handlers().clearIncomingCall(); + }; - const session = mx.matrixRTC.getRoomSession(outgoingRoom); - const pendingOutgoing = isOutgoingCallPending( - myUserId, - outgoingRoom, - session.sessionDescription + const evaluateOutgoingFallback = () => { + const ringAction = evaluateOutgoingRingbackFallback( + { + ringRoomId: outgoingRingRoomIdRef.current, + ringStartedAt: outgoingStartRef.current, + }, + Date.now(), + { + ...fallbackContext, + activeCallRoomId: handlers().callEmbed?.roomId, + outgoingRingbackAllowed: handlers().outgoingRingbackAllowed, + declinedRoomId: declinedOutgoingRoomIdRef.current, + } ); - const activeCall = isCallActive(myUserId, outgoingRoom, session.sessionDescription); - if (!pendingOutgoing || activeCall) { + outgoingRingRoomIdRef.current = ringAction.nextState.ringRoomId; + outgoingStartRef.current = ringAction.nextState.ringStartedAt; + + if (ringAction.kind === 'stop') { handlers().stopOutgoingRing(); return; } - if (outgoingRingRoomIdRef.current !== activeCallRoomId) { - outgoingRingRoomIdRef.current = activeCallRoomId; - outgoingStartRef.current = now; - debugLog.info('call', 'Outgoing ringing fallback started', { roomId: activeCallRoomId }); - } - - if (outgoingStartRef.current && now - outgoingStartRef.current >= OUTGOING_RING_TIMEOUT_MS) { - handlers().stopOutgoingRing(); - return; + if (ringAction.started) { + debugLog.info('call', 'Outgoing ringing fallback started', { roomId: ringAction.roomId }); } outgoingAudioRef.current?.play().catch(() => { @@ -690,6 +528,11 @@ export function useIncomingCallSignaling() { }); }; + const evaluateFallbackState = () => { + evaluateIncomingFallback(); + evaluateOutgoingFallback(); + }; + const handleSessionEnded = (roomId: string) => { if (mutedRoomIdRef.current === roomId) handlers().setMutedRoomId(null); evaluateFallbackState(); @@ -703,6 +546,7 @@ export function useIncomingCallSignaling() { evaluateFallbackState(); return () => { + timelineHandlerEpoch += 1; mx.off(RoomEvent.Timeline, handleTimelineEvent); mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, evaluateFallbackState); mx.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, handleSessionEnded); @@ -715,6 +559,3 @@ export function useIncomingCallSignaling() { return null; } -export function useCallSignaling() { - return useIncomingCallSignaling(); -} diff --git a/src/app/plugins/call/CallWidgetDriver.ts b/src/app/plugins/call/CallWidgetDriver.ts index 308e698f0..99f89b15d 100644 --- a/src/app/plugins/call/CallWidgetDriver.ts +++ b/src/app/plugins/call/CallWidgetDriver.ts @@ -63,14 +63,6 @@ export class CallWidgetDriver extends WidgetDriver { }); } - const requestedSableCapabilities = ['moe.sable.thumbnails', 'moe.sable.media_proxy'].filter( - (cap) => requested.has(cap) - ); - debugLog.info('call', 'Sable-only capability request status', { - roomId: this.inRoomId, - requestedSableCapabilities, - }); - return new Set(allow); } diff --git a/src/app/state/settings.defaults.test.ts b/src/app/state/settings.defaults.test.ts index 4b242f076..198730cdf 100644 --- a/src/app/state/settings.defaults.test.ts +++ b/src/app/state/settings.defaults.test.ts @@ -63,7 +63,7 @@ describe('mergePersistedSettings', () => { expect(mergedDefault.callRingbackTone).toBe('classic-soft'); }); - it('drops invalid custom ringtone metadata during migration', () => { + it('ignores legacy custom tone metadata keys during migration', () => { localStorage.setItem( 'settings', JSON.stringify({ @@ -76,12 +76,8 @@ describe('mergePersistedSettings', () => { }) ); const merged = mergePersistedSettings(localStorage.getItem('settings'), {}); - expect(merged.callCustomRingtoneName).toBe('tone.ogg'); - expect(merged.callCustomRingtoneSizeBytes).toBeUndefined(); - expect(merged.callCustomRingtoneDurationMs).toBeNull(); - expect(merged.callCustomRingbackName).toBe('ringback.ogg'); - expect(merged.callCustomRingbackSizeBytes).toBeUndefined(); - expect(merged.callCustomRingbackDurationMs).toBeNull(); + expect(merged).not.toHaveProperty('callCustomRingtoneName'); + expect(merged).not.toHaveProperty('callCustomRingbackName'); }); }); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 9be3792b0..a3e4ee4b4 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -37,7 +37,6 @@ export const CALL_TONE_IDS = [ 'custom', ] as const; export type CallRingtoneId = (typeof CALL_TONE_IDS)[number]; -export type CallRingbackTone = CallRingtoneId; export type ThemeRemoteFavorite = { fullUrl: string; @@ -166,14 +165,8 @@ export interface Settings { outgoingRingbackEnabled: boolean; callRingtoneVolume: number; callRingtoneId: CallRingtoneId; - callRingbackTone: CallRingbackTone; + callRingbackTone: CallRingtoneId; callSoundOverrideGlobalNotifications: boolean; - callCustomRingtoneName?: string; - callCustomRingtoneSizeBytes?: number; - callCustomRingtoneDurationMs?: number; - callCustomRingbackName?: string; - callCustomRingbackSizeBytes?: number; - callCustomRingbackDurationMs?: number; faviconForMentionsOnly: boolean; highlightMentions: boolean; pkCompat: boolean; @@ -312,12 +305,6 @@ export const defaultSettings: Settings = { callRingtoneId: 'sable-default', callRingbackTone: 'sable-default', callSoundOverrideGlobalNotifications: false, - callCustomRingtoneName: undefined, - callCustomRingtoneSizeBytes: undefined, - callCustomRingtoneDurationMs: undefined, - callCustomRingbackName: undefined, - callCustomRingbackSizeBytes: undefined, - callCustomRingbackDurationMs: undefined, faviconForMentionsOnly: false, highlightMentions: true, pkCompat: false, @@ -367,12 +354,6 @@ function cloneDefaultSettings(): Settings { } const CALL_TONE_ID_SET = new Set(CALL_TONE_IDS); -const CALL_AUDIO_METADATA_NUMBER_KEYS = [ - 'callCustomRingtoneSizeBytes', - 'callCustomRingtoneDurationMs', - 'callCustomRingbackSizeBytes', - 'callCustomRingbackDurationMs', -] as const; const isCallToneId = (value: unknown): value is CallRingtoneId => CALL_TONE_ID_SET.has(value); @@ -424,11 +405,17 @@ function migrateParsedLocalStorage(parsed: Record): void { delete parsed.callRingbackTone; } - for (const key of CALL_AUDIO_METADATA_NUMBER_KEYS) { - const value = parsed[key]; - if (typeof value === 'number' && (!Number.isFinite(value) || value < 0)) { - delete parsed[key]; - } + const legacyCallCustomMetadataKeys = [ + 'callCustomRingtoneName', + 'callCustomRingtoneSizeBytes', + 'callCustomRingtoneDurationMs', + 'callCustomRingbackName', + 'callCustomRingbackSizeBytes', + 'callCustomRingbackDurationMs', + ] as const; + + for (const key of legacyCallCustomMetadataKeys) { + delete parsed[key]; } } @@ -547,13 +534,6 @@ function sanitizeSettingsKey(key: keyof Settings, val: unknown): unknown { case 'callRingtoneVolume': if (typeof val !== 'number' || !Number.isFinite(val)) return undefined; return clampPercent(val); - case 'callCustomRingtoneSizeBytes': - case 'callCustomRingtoneDurationMs': - case 'callCustomRingbackSizeBytes': - case 'callCustomRingbackDurationMs': - return typeof val === 'number' && Number.isFinite(val) && val >= 0 - ? Math.round(val) - : undefined; case 'renderUserCards': return val === 'both' || val === 'light' || val === 'dark' || val === 'none' ? val diff --git a/src/app/utils/settingsSync.test.ts b/src/app/utils/settingsSync.test.ts index c8622b936..499a73a33 100644 --- a/src/app/utils/settingsSync.test.ts +++ b/src/app/utils/settingsSync.test.ts @@ -37,12 +37,6 @@ describe('NON_SYNCABLE_KEYS', () => { 'callRingtoneId', 'callRingbackTone', 'callSoundOverrideGlobalNotifications', - 'callCustomRingtoneName', - 'callCustomRingtoneSizeBytes', - 'callCustomRingtoneDurationMs', - 'callCustomRingbackName', - 'callCustomRingbackSizeBytes', - 'callCustomRingbackDurationMs', 'developerTools', 'settingsSyncEnabled', ] as const; diff --git a/src/app/utils/settingsSync.ts b/src/app/utils/settingsSync.ts index b3f3f7020..bd565356b 100644 --- a/src/app/utils/settingsSync.ts +++ b/src/app/utils/settingsSync.ts @@ -21,12 +21,6 @@ export const NON_SYNCABLE_KEYS = new Set([ 'callRingtoneId', 'callRingbackTone', 'callSoundOverrideGlobalNotifications', - 'callCustomRingtoneName', - 'callCustomRingtoneSizeBytes', - 'callCustomRingtoneDurationMs', - 'callCustomRingbackName', - 'callCustomRingbackSizeBytes', - 'callCustomRingbackDurationMs', // Developer / diagnostic 'developerTools', // Sync toggle itself must never be uploaded (it's device-local) diff --git a/src/sw/pushCallNotificationCopy.test.ts b/src/sw/pushCallNotificationCopy.test.ts new file mode 100644 index 000000000..6d8b19404 --- /dev/null +++ b/src/sw/pushCallNotificationCopy.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { resolveCallNotificationCopy } from './pushCallNotificationCopy'; + +describe('resolveCallNotificationCopy', () => { + it('uses generic room-call copy when previews are hidden', () => { + expect( + resolveCallNotificationCopy({ + notificationType: 'notification', + intentKind: 'audio', + showPreviewDetails: false, + }) + ).toEqual({ + title: 'Room call started', + body: 'Open Sable to join.', + }); + }); + + it('uses sender and room details for ring notifications', () => { + expect( + resolveCallNotificationCopy({ + notificationType: 'ring', + intentKind: 'video', + senderDisplayName: 'Alice', + roomName: 'General', + showPreviewDetails: true, + }) + ).toEqual({ + title: 'Incoming video call', + body: 'Alice is calling you in General', + }); + }); +}); diff --git a/src/sw/pushCallNotificationCopy.ts b/src/sw/pushCallNotificationCopy.ts new file mode 100644 index 000000000..ea0d31942 --- /dev/null +++ b/src/sw/pushCallNotificationCopy.ts @@ -0,0 +1,120 @@ +type CallNotificationType = 'ring' | 'notification'; +type CallIntentKind = 'audio' | 'video'; + +export type CallNotificationCopyContext = { + notificationType: CallNotificationType; + intentKind: CallIntentKind; + senderDisplayName?: string; + roomName?: string; + showPreviewDetails: boolean; +}; + +type CopyTemplate = { + title: string | ((ctx: CallNotificationCopyContext) => string); + body: string | ((ctx: CallNotificationCopyContext) => string | undefined); +}; + +type CopyRule = { + when: (ctx: CallNotificationCopyContext) => boolean; + template: CopyTemplate; +}; + +const firstMatchingTemplate = ( + ctx: CallNotificationCopyContext, + rules: CopyRule[] +): CopyTemplate | undefined => { + for (const rule of rules) { + if (rule.when(ctx)) return rule.template; + } + return undefined; +}; + +const resolveTemplate = ( + ctx: CallNotificationCopyContext, + template: CopyTemplate +): { title: string; body: string | undefined } => ({ + title: template.title, + body: typeof template.body === 'function' ? template.body(ctx) : template.body, +}); + +const ROOM_CALL_RULES: CopyRule[] = [ + { + when: (ctx) => !ctx.showPreviewDetails, + template: { title: 'Room call started', body: 'Open Sable to join.' }, + }, + { + when: (ctx) => Boolean(ctx.senderDisplayName && ctx.roomName), + template: { + title: 'Room call started', + body: (ctx) => `${ctx.senderDisplayName} started a call in ${ctx.roomName}`, + }, + }, + { + when: (ctx) => Boolean(ctx.roomName), + template: { title: 'Room call started', body: (ctx) => `A call started in ${ctx.roomName}` }, + }, + { + when: (ctx) => Boolean(ctx.senderDisplayName), + template: { + title: 'Room call started', + body: (ctx) => `${ctx.senderDisplayName} started a call`, + }, + }, + { + when: () => true, + template: { title: 'Room call started', body: 'A room call started.' }, + }, +]; + +const RING_CALL_RULES: CopyRule[] = [ + { + when: (ctx) => !ctx.showPreviewDetails, + template: { + title: (ctx) => (ctx.intentKind === 'video' ? 'Incoming video call' : 'Incoming voice call'), + body: 'Open Sable to answer.', + }, + }, + { + when: (ctx) => Boolean(ctx.senderDisplayName && ctx.roomName), + template: { + title: (ctx) => (ctx.intentKind === 'video' ? 'Incoming video call' : 'Incoming voice call'), + body: (ctx) => `${ctx.senderDisplayName} is calling you in ${ctx.roomName}`, + }, + }, + { + when: (ctx) => Boolean(ctx.senderDisplayName), + template: { + title: (ctx) => (ctx.intentKind === 'video' ? 'Incoming video call' : 'Incoming voice call'), + body: (ctx) => `${ctx.senderDisplayName} is calling you`, + }, + }, + { + when: (ctx) => Boolean(ctx.roomName), + template: { + title: (ctx) => (ctx.intentKind === 'video' ? 'Incoming video call' : 'Incoming voice call'), + body: (ctx) => `Incoming call in ${ctx.roomName}`, + }, + }, + { + when: () => true, + template: { + title: (ctx) => (ctx.intentKind === 'video' ? 'Incoming video call' : 'Incoming voice call'), + body: 'Incoming call', + }, + }, +]; + +export const resolveCallNotificationCopy = ( + ctx: CallNotificationCopyContext +): { title: string; body: string | undefined } => { + const rules = ctx.notificationType === 'notification' ? ROOM_CALL_RULES : RING_CALL_RULES; + const template = firstMatchingTemplate(ctx, rules); + if (!template) { + return { title: 'Incoming call', body: undefined }; + } + + return { + title: typeof template.title === 'function' ? template.title(ctx) : template.title, + body: typeof template.body === 'function' ? template.body(ctx) : template.body, + }; +}; diff --git a/src/sw/pushNotification.ts b/src/sw/pushNotification.ts index 175207a49..fdc6a8e7f 100644 --- a/src/sw/pushNotification.ts +++ b/src/sw/pushNotification.ts @@ -1,12 +1,14 @@ /* oxlint-disable no-console */ // Keep the service worker import graph narrow, the app barrel pulls in runtime Matrix SDK modules that break SW script evaluation import { EventType } from 'matrix-js-sdk/lib/@types/event'; +import { normalizeCallIntent } from '../app/features/call/callIntent'; import { buildRoomMessageNotification, DEFAULT_NOTIFICATION_ICON, DEFAULT_NOTIFICATION_BADGE, resolveNotificationPreviewText, } from '../app/utils/notificationStyle'; +import { resolveCallNotificationCopy } from './pushCallNotificationCopy'; type NotificationSettings = { showMessageContent: boolean; @@ -37,13 +39,6 @@ interface MatrixPushData { const resolveSilent = (): boolean => false; const MAX_CALL_NOTIFICATION_LIFETIME_MS = 120_000; -const normalizeCallIntentKind = (intentRaw: string | undefined): 'audio' | 'video' => { - if (!intentRaw) return 'audio'; - const normalized = intentRaw.toLowerCase(); - if (normalized.includes('video')) return 'video'; - return 'audio'; -}; - const isCallNotificationType = (value: unknown): value is 'ring' | 'notification' => value === 'ring' || value === 'notification'; @@ -74,75 +69,6 @@ const getCallTiming = ( }; }; -const resolveCallNotificationCopy = ( - notificationType: 'ring' | 'notification', - intentKind: 'audio' | 'video', - senderDisplayName: string | undefined, - roomName: string | undefined, - showPreviewDetails: boolean -): { title: string; body: string | undefined } => { - if (notificationType === 'notification') { - if (!showPreviewDetails) { - return { - title: 'Room call started', - body: 'Open Sable to join.', - }; - } - if (roomName && senderDisplayName) { - return { - title: 'Room call started', - body: `${senderDisplayName} started a call in ${roomName}`, - }; - } - if (roomName) { - return { - title: 'Room call started', - body: `A call started in ${roomName}`, - }; - } - if (senderDisplayName) { - return { - title: 'Room call started', - body: `${senderDisplayName} started a call`, - }; - } - return { - title: 'Room call started', - body: 'A room call started.', - }; - } - - const title = intentKind === 'video' ? 'Incoming video call' : 'Incoming voice call'; - if (!showPreviewDetails) { - return { - title, - body: 'Open Sable to answer.', - }; - } - if (senderDisplayName && roomName) { - return { - title, - body: `${senderDisplayName} is calling you in ${roomName}`, - }; - } - if (senderDisplayName) { - return { - title, - body: `${senderDisplayName} is calling you`, - }; - } - if (roomName) { - return { - title, - body: `Incoming call in ${roomName}`, - }; - } - return { - title, - body: 'Incoming call', - }; -}; - export const createPushNotifications = ( self: ServiceWorkerGlobalScope, getNotificationSettings: () => NotificationSettings @@ -188,17 +114,17 @@ export const createPushNotifications = ( typeof pushData?.content?.['m.call.intent'] === 'string' ? pushData.content['m.call.intent'] : undefined; - const intentKind = normalizeCallIntentKind(intentRaw); + const intentKind = normalizeCallIntent(undefined, intentRaw); const senderDisplayName = pushData?.sender_display_name; const roomName = pushData?.room_name; const showPreviewDetails = getNotificationSettings().showMessageContent; - const copy = resolveCallNotificationCopy( - notificationTypeRaw, + const copy = resolveCallNotificationCopy({ + notificationType: notificationTypeRaw, intentKind, senderDisplayName, roomName, - showPreviewDetails - ); + showPreviewDetails, + }); const originTs = typeof pushData.timestamp === 'number' ? pushData.timestamp : Date.now(); const { senderTs, expiresAt } = getCallTiming(pushData.content, originTs); From 30798ac266dffcf0c4c28890354bfe5362fbd947 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Mon, 18 May 2026 23:25:38 -0500 Subject: [PATCH 26/27] typecheck and cleanup --- src/app/features/call/callIncomingIngress.ts | 6 + src/app/features/call/callIntent.test.ts | 20 +- src/app/features/call/callIntent.ts | 23 ++- .../features/call/callIntentCrossPath.test.ts | 6 +- src/app/features/call/callMembershipState.ts | 13 ++ src/app/features/call/callSignalingDecrypt.ts | 13 +- src/app/features/call/callToneSources.test.ts | 8 +- .../call/outgoingDeclineHandler.test.ts | 10 + .../features/call/outgoingDeclineHandler.ts | 5 + .../call/rtcNotificationParser.test.ts | 4 +- .../general/CallSoundSettings.test.tsx | 2 +- src/app/hooks/useCallEmbed.ts | 4 +- src/app/hooks/useCallSignaling.ts | 188 ++++++++++++------ src/app/pages/client/ClientNonUIFeatures.tsx | 8 +- src/app/pages/client/ToRoomEvent.tsx | 7 +- src/app/plugins/call/CallControl.ts | 1 - src/sw/pushCallNotificationCopy.ts | 8 - 17 files changed, 228 insertions(+), 98 deletions(-) create mode 100644 src/app/features/call/callIncomingIngress.ts diff --git a/src/app/features/call/callIncomingIngress.ts b/src/app/features/call/callIncomingIngress.ts new file mode 100644 index 000000000..2b12470f8 --- /dev/null +++ b/src/app/features/call/callIncomingIngress.ts @@ -0,0 +1,6 @@ +import type { IncomingCall } from '$state/callEmbed'; + +export const isIncomingCallSuppressed = ( + incoming: IncomingCall, + mutedRoomId: string | null +): boolean => mutedRoomId === incoming.roomId; diff --git a/src/app/features/call/callIntent.test.ts b/src/app/features/call/callIntent.test.ts index be790119e..aa8ed4eef 100644 --- a/src/app/features/call/callIntent.test.ts +++ b/src/app/features/call/callIntent.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { ElementCallIntent } from '$plugins/call/types'; import { normalizeCallIntent, toCallNotificationType, @@ -9,16 +10,23 @@ describe('callIntent', () => { describe('normalizeCallIntent', () => { it('prefers explicit intent kind', () => { expect(normalizeCallIntent('video', 'start_call_dm_voice')).toBe('video'); - expect(normalizeCallIntent('audio', 'start_call_dm_video')).toBe('audio'); + expect(normalizeCallIntent('audio', 'start_call_dm')).toBe('audio'); }); - it('infers voice and video from intent raw string', () => { - expect(normalizeCallIntent(undefined, 'start_call_dm_voice')).toBe('audio'); - expect(normalizeCallIntent(undefined, 'start_call_dm_video')).toBe('video'); + it('maps Element Call voice intents to audio', () => { + expect(normalizeCallIntent(undefined, ElementCallIntent.StartCallDMVoice)).toBe('audio'); + expect(normalizeCallIntent(undefined, ElementCallIntent.JoinExistingVoice)).toBe('audio'); }); - it('defaults DM start without voice/video markers to audio', () => { - expect(normalizeCallIntent(undefined, 'start_call_dm')).toBe('audio'); + it('maps Element Call non-voice intents to video', () => { + expect(normalizeCallIntent(undefined, ElementCallIntent.StartCallDM)).toBe('video'); + expect(normalizeCallIntent(undefined, ElementCallIntent.JoinExisting)).toBe('video'); + expect(normalizeCallIntent(undefined, ElementCallIntent.StartCall)).toBe('video'); + }); + + it('defaults unknown intents to audio', () => { + expect(normalizeCallIntent(undefined, undefined)).toBe('audio'); + expect(normalizeCallIntent(undefined, 'unknown_intent')).toBe('audio'); }); }); diff --git a/src/app/features/call/callIntent.ts b/src/app/features/call/callIntent.ts index d419aac90..4259ddd83 100644 --- a/src/app/features/call/callIntent.ts +++ b/src/app/features/call/callIntent.ts @@ -1,15 +1,32 @@ +import { ElementCallIntent } from '$plugins/call/types'; + export type CallIntentKind = 'audio' | 'video'; export type CallNotificationType = 'ring' | 'notification'; export const MAX_CALL_NOTIFICATION_LIFETIME_MS = 120_000; +const VOICE_INTENTS = new Set([ + ElementCallIntent.StartCallVoice, + ElementCallIntent.JoinExistingVoice, + ElementCallIntent.StartCallDMVoice, + ElementCallIntent.JoinExistingDMVoice, +]); + +const KNOWN_INTENTS = new Set(Object.values(ElementCallIntent)); + export const normalizeCallIntent = (intentKindRaw?: string, intentRaw?: string): CallIntentKind => { if (intentKindRaw === 'audio' || intentKindRaw === 'video') { return intentKindRaw; } - const normalized = intentRaw?.toLowerCase(); - if (normalized?.includes('voice')) return 'audio'; - if (normalized?.includes('video')) return 'video'; + if (!intentRaw) return 'audio'; + + const normalized = intentRaw.toLowerCase(); + if (VOICE_INTENTS.has(normalized) || normalized.includes('voice')) { + return 'audio'; + } + if (KNOWN_INTENTS.has(normalized) || normalized.includes('video')) { + return 'video'; + } return 'audio'; }; diff --git a/src/app/features/call/callIntentCrossPath.test.ts b/src/app/features/call/callIntentCrossPath.test.ts index 774fd189c..4d45718ad 100644 --- a/src/app/features/call/callIntentCrossPath.test.ts +++ b/src/app/features/call/callIntentCrossPath.test.ts @@ -69,7 +69,7 @@ describe('call intent cross-path consistency', () => { }); }); - it('parser and bridge both default start_call_dm to audio', async () => { + it('parser and bridge both map start_call_dm to video', async () => { const parsed = await parseIncomingRtcNotification( createEvent({ content: { @@ -97,7 +97,7 @@ describe('call intent cross-path consistency', () => { NOW ); - expect(parsed?.intentKind).toBe('audio'); - expect(fromBridge?.intentKind).toBe('audio'); + expect(parsed?.intentKind).toBe('video'); + expect(fromBridge?.intentKind).toBe('video'); }); }); diff --git a/src/app/features/call/callMembershipState.ts b/src/app/features/call/callMembershipState.ts index 239492c56..3d0f4b33a 100644 --- a/src/app/features/call/callMembershipState.ts +++ b/src/app/features/call/callMembershipState.ts @@ -70,3 +70,16 @@ export const isOutgoingCallPending = ( return hasSelfMember && remoteMemberCount === 0; }; + +export const getRemoteRtcMemberUserIds = ( + mxUserId: string, + room: Room, + sessionDescription: SessionDescription +): Set => { + const memberships = getRoomMemberships(room, sessionDescription); + return new Set( + memberships + .map((membership) => membership.userId || membership.sender) + .filter((userId): userId is string => !!userId && userId !== mxUserId) + ); +}; diff --git a/src/app/features/call/callSignalingDecrypt.ts b/src/app/features/call/callSignalingDecrypt.ts index c6a59f31b..3765a37a2 100644 --- a/src/app/features/call/callSignalingDecrypt.ts +++ b/src/app/features/call/callSignalingDecrypt.ts @@ -16,6 +16,8 @@ export const decryptRtcTimelineEvent = async ( const crypto = mx.getCrypto(); if (!crypto) return undefined; + if (event.isDecryptionFailure()) return undefined; + try { if (!event.isBeingDecrypted()) { await event.attemptDecryption(crypto as CryptoBackend); @@ -23,10 +25,13 @@ export const decryptRtcTimelineEvent = async ( const decryptionPromise = event.getDecryptionPromise(); if (decryptionPromise) { + let timeoutId: ReturnType | undefined; await Promise.race([ - decryptionPromise, + decryptionPromise.finally(() => { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + }), new Promise((resolve) => { - window.setTimeout(resolve, DECRYPT_TIMEOUT_MS); + timeoutId = window.setTimeout(resolve, DECRYPT_TIMEOUT_MS); }), ]); } @@ -39,6 +44,10 @@ export const decryptRtcTimelineEvent = async ( return undefined; } + if (event.isBeingDecrypted() || event.isDecryptionFailure()) { + return undefined; + } + const effectiveEvent = event.getEffectiveEvent(); return { type: effectiveEvent.type, diff --git a/src/app/features/call/callToneSources.test.ts b/src/app/features/call/callToneSources.test.ts index e138d89aa..123843d18 100644 --- a/src/app/features/call/callToneSources.test.ts +++ b/src/app/features/call/callToneSources.test.ts @@ -2,8 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { resolveCallToneSources } from './callToneSources'; vi.mock('./callRingtoneStorage', () => ({ - getCustomCallRingtone: vi.fn(), - getCustomCallRingback: vi.fn(), + getCustomCallRingtone: vi.fn<() => Promise>(), + getCustomCallRingback: vi.fn<() => Promise>(), })); const { getCustomCallRingtone, getCustomCallRingback } = await import('./callRingtoneStorage'); @@ -15,8 +15,8 @@ describe('resolveCallToneSources', () => { vi.stubGlobal( 'URL', Object.assign(URL, { - createObjectURL: vi.fn(() => 'blob:custom'), - revokeObjectURL: vi.fn(), + createObjectURL: vi.fn<() => string>(() => 'blob:custom'), + revokeObjectURL: vi.fn<() => void>(), }) ); }); diff --git a/src/app/features/call/outgoingDeclineHandler.test.ts b/src/app/features/call/outgoingDeclineHandler.test.ts index 80f3f3499..edcfb8994 100644 --- a/src/app/features/call/outgoingDeclineHandler.test.ts +++ b/src/app/features/call/outgoingDeclineHandler.test.ts @@ -36,4 +36,14 @@ describe('applyOutgoingDeclineToTracker', () => { ); expect(end).toEqual({ kind: 'end_call', declinedCount: 2, targetCount: 2 }); }); + + it('ignores declines when there are no remote RTC targets in a group room', () => { + const tracker: OutgoingDeclineTracker = new Map(); + const decision = applyOutgoingDeclineToTracker(tracker, decline, { + remoteJoinedIds: new Set(), + isDirectRoom: false, + }); + + expect(decision).toEqual({ kind: 'ignore_partial', declinedCount: 1, targetCount: 0 }); + }); }); diff --git a/src/app/features/call/outgoingDeclineHandler.ts b/src/app/features/call/outgoingDeclineHandler.ts index ce234c08c..500fb52ef 100644 --- a/src/app/features/call/outgoingDeclineHandler.ts +++ b/src/app/features/call/outgoingDeclineHandler.ts @@ -37,6 +37,11 @@ export const applyOutgoingDeclineToTracker = ( const targetCount = options.remoteJoinedIds.size; const declinedCount = declineState.declinerIds.size; + + if (targetCount === 0 && !options.isDirectRoom) { + return { kind: 'ignore_partial', declinedCount, targetCount }; + } + const allRemoteDeclined = targetCount > 0 && [...options.remoteJoinedIds].every((userId) => declineState.declinerIds.has(userId)); const treatAsOneToOne = options.isDirectRoom || targetCount <= 1; diff --git a/src/app/features/call/rtcNotificationParser.test.ts b/src/app/features/call/rtcNotificationParser.test.ts index edbcc2ba1..95709239f 100644 --- a/src/app/features/call/rtcNotificationParser.test.ts +++ b/src/app/features/call/rtcNotificationParser.test.ts @@ -71,7 +71,7 @@ describe('parseIncomingRtcNotification', () => { ); expect(parsed?.notificationType).toBe('notification'); - expect(parsed?.intentKind).toBe('audio'); + expect(parsed?.intentKind).toBe('video'); }); it('ignores expired notifications', async () => { @@ -193,7 +193,7 @@ describe('parseIncomingRtcNotification', () => { sender_ts: NOW - 500, lifetime: 60_000, notification_type: 'ring', - 'm.call.intent': 'start_call_dm_video', + 'm.call.intent': 'start_call_dm', 'm.mentions': { room: true }, }, }), diff --git a/src/app/features/settings/general/CallSoundSettings.test.tsx b/src/app/features/settings/general/CallSoundSettings.test.tsx index d91c34ee9..53e25852c 100644 --- a/src/app/features/settings/general/CallSoundSettings.test.tsx +++ b/src/app/features/settings/general/CallSoundSettings.test.tsx @@ -9,7 +9,7 @@ vi.mock('$state/settings', () => ({ })); vi.mock('$state/hooks/settings', () => ({ - useSetting: vi.fn(), + useSetting: vi.fn(), })); vi.mock('$features/call/callRingtoneStorage', () => ({ diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts index edcc55738..e3364f4fa 100644 --- a/src/app/hooks/useCallEmbed.ts +++ b/src/app/hooks/useCallEmbed.ts @@ -126,9 +126,7 @@ export const useCallJoined = (embed?: CallEmbed): boolean => { ); useEffect(() => { - if (!embed) { - setJoined(false); - } + setJoined(embed?.joined ?? false); }, [embed]); return joined; diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index bb7567580..a75a63613 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -36,6 +36,8 @@ import { import { callRingtoneVolumeToGain, canPlayCallAudio } from '$features/call/callRingtone'; import { dismissSystemCallNotifications } from '$features/call/callNotificationBridge'; import { resolveCallToneSources } from '$features/call/callToneSources'; +import { isIncomingCallSuppressed } from '$features/call/callIncomingIngress'; +import { getRemoteRtcMemberUserIds } from '$features/call/callMembershipState'; import { useMatrixClient } from './useMatrixClient'; import { createDebugLogger } from '../utils/debugLogger'; @@ -81,6 +83,8 @@ export function useIncomingCallSignaling() { Map }> >(new Map()); const outgoingStartRef = useRef(null); + const activeOutgoingNotificationIdRef = useRef(null); + const seenDeclineEventIdsRef = useRef>(new Set()); type SignalingHandlerRefs = { callEmbed: typeof callEmbed; @@ -107,6 +111,8 @@ export function useIncomingCallSignaling() { useEffect(() => { declinedOutgoingRoomIdRef.current = null; outgoingDeclinesRef.current.clear(); + activeOutgoingNotificationIdRef.current = null; + seenDeclineEventIdsRef.current.clear(); }, [callEmbed]); useEffect(() => { @@ -204,17 +210,33 @@ export function useIncomingCallSignaling() { return; } + if (seenDeclineEventIdsRef.current.has(decline.declineEventId)) { + return; + } + seenDeclineEventIdsRef.current.add(decline.declineEventId); + + const activeNotificationId = activeOutgoingNotificationIdRef.current; + if (activeNotificationId && decline.notificationEventId !== activeNotificationId) { + debugLog.info('call', 'Ignoring stale outgoing decline for previous notification', { + roomId: decline.roomId, + declineEventId: decline.declineEventId, + notificationEventId: decline.notificationEventId, + activeNotificationId, + }); + return; + } + const outgoingRoom = mx.getRoom(decline.roomId); if (!outgoingRoom) { return; } - const remoteJoinedIds = new Set( - outgoingRoom - .getJoinedMembers() - .map((member) => member.userId) - .filter((userId) => userId !== mx.getSafeUserId()) - ); + const myUserId = mx.getSafeUserId(); + const sessionDescription = mx.matrixRTC.getRoomSession(outgoingRoom).sessionDescription; + let remoteJoinedIds = getRemoteRtcMemberUserIds(myUserId, outgoingRoom, sessionDescription); + if (remoteJoinedIds.size === 0) { + remoteJoinedIds = new Set([decline.senderId]); + } const decision = applyOutgoingDeclineToTracker(outgoingDeclinesRef.current, decline, { remoteJoinedIds, @@ -270,37 +292,11 @@ export function useIncomingCallSignaling() { }); const incomingRingtoneAllowed = settings.incomingCallSoundEnabled && callAudioAllowed; const outgoingRingbackAllowed = settings.outgoingRingbackEnabled && callAudioAllowed; - - signalingHandlerRefs.current = { - callEmbed, - mDirects, - outgoingRingbackAllowed, - handleIncomingCall, - handleOutgoingDecline, - clearIncomingCall, - stopIncomingRing, - stopOutgoingRing, - setMutedRoomId, - }; - - useEffect(() => { - if (!incomingRingtoneAllowed) { - stopIncomingRing(); - } - if (!outgoingRingbackAllowed) { - stopOutgoingRing(); - } - }, [incomingRingtoneAllowed, outgoingRingbackAllowed, stopIncomingRing, stopOutgoingRing]); - - useEffect(() => { - if (!incomingCall) { - stopIncomingRing(); - } - }, [incomingCall, stopIncomingRing]); + const incomingToneIsSilent = settings.callRingtoneId === 'silent'; const handleIncomingCall = useCallback( (nextIncomingCall: IncomingCall) => { - if (mutedRoomIdRef.current === nextIncomingCall.roomId) return; + if (isIncomingCallSuppressed(nextIncomingCall, mutedRoomIdRef.current)) return; if (!rememberNotificationId(nextIncomingCall.notificationEventId)) return; setIncomingCall(nextIncomingCall); @@ -324,25 +320,78 @@ export function useIncomingCallSignaling() { dm: String(nextIncomingCall.isDirect), }, }); - - if (!incomingRingtoneAllowed) { - stopIncomingRing(); - return; - } - - incomingAudioRef.current - ?.play() - .then(() => { - setCallSoundBlocked(false); - }) - .catch(() => { - setCallSoundBlocked(true); - Sentry.metrics.count('sable.call.ringtone.blocked', 1); - }); }, - [incomingRingtoneAllowed, setCallSoundBlocked, setIncomingCall, stopIncomingRing] + [setIncomingCall] ); + const playIncomingRing = useCallback(() => { + if (!incomingRingtoneAllowed || incomingToneIsSilent) { + stopIncomingRing(); + return; + } + + const audio = incomingAudioRef.current; + if (!audio?.src) { + stopIncomingRing(); + return; + } + + if (callEmbed && incomingCall && callEmbed.roomId !== incomingCall.roomId) { + stopIncomingRing(); + return; + } + + audio + .play() + .then(() => { + setCallSoundBlocked(false); + }) + .catch(() => { + setCallSoundBlocked(true); + Sentry.metrics.count('sable.call.ringtone.blocked', 1); + }); + }, [ + callEmbed, + incomingCall, + incomingRingtoneAllowed, + incomingToneIsSilent, + setCallSoundBlocked, + stopIncomingRing, + ]); + + signalingHandlerRefs.current = { + callEmbed, + mDirects, + outgoingRingbackAllowed, + handleIncomingCall, + handleOutgoingDecline, + clearIncomingCall, + stopIncomingRing, + stopOutgoingRing, + setMutedRoomId, + }; + + useEffect(() => { + if (!incomingRingtoneAllowed) { + stopIncomingRing(); + } + if (!outgoingRingbackAllowed) { + stopOutgoingRing(); + } + }, [incomingRingtoneAllowed, outgoingRingbackAllowed, stopIncomingRing, stopOutgoingRing]); + + useEffect(() => { + if (!incomingCall) { + stopIncomingRing(); + return; + } + if (isIncomingCallSuppressed(incomingCall, mutedRoomId)) { + setIncomingCall(null); + return; + } + playIncomingRing(); + }, [incomingCall, mutedRoomId, playIncomingRing, setIncomingCall, stopIncomingRing]); + useEffect(() => { if (!mx || !mx.matrixRTC) return undefined; @@ -432,8 +481,19 @@ export function useIncomingCallSignaling() { ) { return; } - if (event.getSender() === myUserId) return; - if (!event.getId()) return; + const senderId = event.getSender(); + const eventId = event.getId(); + if (!senderId || !eventId) return; + + if (senderId === myUserId) { + if ( + type === RTC_NOTIFICATION_EVENT_TYPE && + handlers().callEmbed?.roomId === room.roomId + ) { + activeOutgoingNotificationIdRef.current = eventId; + } + return; + } const incoming = await parseEvent(event, room, data.liveEvent); if (isStale()) return; @@ -442,13 +502,18 @@ export function useIncomingCallSignaling() { return; } - // Avoid decrypting unrelated encrypted timeline traffic; only inspect declines - // for the currently active outgoing call room. + // Only inspect declines for the active outgoing call room. Cleartext declines are + // cheap; encrypted events are decrypted only when they might be RTC declines. const activeEmbed = handlers().callEmbed; + if (!activeEmbed || activeEmbed.roomId !== room.roomId) { + return; + } + if (event.isDecryptionFailure()) { + return; + } const shouldCheckDecline = - !!activeEmbed && - activeEmbed.roomId === room.roomId && - (event.isEncrypted() || type === RTC_DECLINE_EVENT_TYPE); + type === RTC_DECLINE_EVENT_TYPE || + (event.isEncrypted() && relation?.rel_type === REFERENCE_REL_TYPE); if (!shouldCheckDecline) { return; } @@ -523,9 +588,12 @@ export function useIncomingCallSignaling() { debugLog.info('call', 'Outgoing ringing fallback started', { roomId: ringAction.roomId }); } - outgoingAudioRef.current?.play().catch(() => { - Sentry.metrics.count('sable.call.ringback.blocked', 1); - }); + const outgoingAudio = outgoingAudioRef.current; + if (outgoingAudio && (ringAction.started || outgoingAudio.paused)) { + outgoingAudio.play().catch(() => { + Sentry.metrics.count('sable.call.ringback.blocked', 1); + }); + } }; const evaluateFallbackState = () => { diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 9ee1d3a76..acaa0e7fc 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -45,7 +45,6 @@ import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { registrationAtom } from '$state/serviceWorkerRegistration'; import { pendingNotificationAtom, inAppBannerAtom, activeSessionIdAtom } from '$state/sessions'; -import { incomingCallAtom } from '$state/callEmbed'; import { buildRoomMessageNotification, resolveNotificationPreviewText, @@ -62,6 +61,8 @@ import { getBlobCacheStats } from '$hooks/useBlobCache'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; import { resolveIncomingCallFromNotificationData } from '$features/call/callNotificationBridge'; +import { isIncomingCallSuppressed } from '$features/call/callIncomingIngress'; +import { incomingCallAtom, mutedCallRoomIdAtom } from '$state/callEmbed'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; @@ -612,6 +613,7 @@ export function HandleNotificationClick() { const setPending = useSetAtom(pendingNotificationAtom); const setActiveSessionId = useSetAtom(activeSessionIdAtom); const setIncomingCall = useSetAtom(incomingCallAtom); + const mutedRoomId = useAtomValue(mutedCallRoomIdAtom); const mDirects = useAtomValue(mDirectAtom); const navigate = useNavigate(); @@ -643,14 +645,14 @@ export function HandleNotificationClick() { data as Record, mDirects.has(roomId) ); - if (incomingCall) { + if (incomingCall && !isIncomingCallSuppressed(incomingCall, mutedRoomId)) { setIncomingCall(incomingCall); } }; navigator.serviceWorker.addEventListener('message', handleMessage); return () => navigator.serviceWorker.removeEventListener('message', handleMessage); - }, [mDirects, navigate, setActiveSessionId, setIncomingCall, setPending]); + }, [mDirects, mutedRoomId, navigate, setActiveSessionId, setIncomingCall, setPending]); return null; } diff --git a/src/app/pages/client/ToRoomEvent.tsx b/src/app/pages/client/ToRoomEvent.tsx index e08f0de6c..a1a9bd833 100644 --- a/src/app/pages/client/ToRoomEvent.tsx +++ b/src/app/pages/client/ToRoomEvent.tsx @@ -3,8 +3,9 @@ import { useParams, useSearchParams } from 'react-router-dom'; import { useAtomValue, useSetAtom } from 'jotai'; import { activeSessionIdAtom, pendingNotificationAtom } from '$state/sessions'; import { mDirectAtom } from '$state/mDirectList'; -import { incomingCallAtom } from '$state/callEmbed'; +import { incomingCallAtom, mutedCallRoomIdAtom } from '$state/callEmbed'; import { resolveIncomingCallFromSearchParams } from '$features/call/callNotificationBridge'; +import { isIncomingCallSuppressed } from '$features/call/callIncomingIngress'; // ToRoomEvent handles /to/:user_id/:room_id/:event_id? — the canonical deep-link // URL used by the service worker's notificationclick handler. @@ -22,6 +23,7 @@ export function ToRoomEvent() { const { user_id: userId, room_id: roomId, event_id: eventId } = useParams(); const [searchParams] = useSearchParams(); const mDirects = useAtomValue(mDirectAtom); + const mutedRoomId = useAtomValue(mutedCallRoomIdAtom); const setActiveSessionId = useSetAtom(activeSessionIdAtom); const setPending = useSetAtom(pendingNotificationAtom); const setIncomingCall = useSetAtom(incomingCallAtom); @@ -39,7 +41,7 @@ export function ToRoomEvent() { eventId, mDirects.has(roomId) ); - if (incomingCall) { + if (incomingCall && !isIncomingCallSuppressed(incomingCall, mutedRoomId)) { setIncomingCall(incomingCall); } @@ -48,6 +50,7 @@ export function ToRoomEvent() { }, [ eventId, mDirects, + mutedRoomId, roomId, searchParams, setActiveSessionId, diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index d25b21d6f..47cbdc22e 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -131,7 +131,6 @@ export class CallControl extends EventEmitter implements CallControlState { if (callDocument) { callDocument.querySelectorAll('audio, video').forEach((el) => { el.muted = shouldMute; - if (shouldMute) el.volume = 0; }); } } diff --git a/src/sw/pushCallNotificationCopy.ts b/src/sw/pushCallNotificationCopy.ts index ea0d31942..86a398300 100644 --- a/src/sw/pushCallNotificationCopy.ts +++ b/src/sw/pushCallNotificationCopy.ts @@ -29,14 +29,6 @@ const firstMatchingTemplate = ( return undefined; }; -const resolveTemplate = ( - ctx: CallNotificationCopyContext, - template: CopyTemplate -): { title: string; body: string | undefined } => ({ - title: template.title, - body: typeof template.body === 'function' ? template.body(ctx) : template.body, -}); - const ROOM_CALL_RULES: CopyRule[] = [ { when: (ctx) => !ctx.showPreviewDetails, From 0009272dc0d3f6de5050a164f10a39b98f50b9e8 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Mon, 18 May 2026 23:41:56 -0500 Subject: [PATCH 27/27] formatting --- src/app/features/call/callNotificationBridge.test.ts | 8 +------- src/app/features/call/callRingtone.ts | 6 +----- src/app/features/call/callSignalingFallback.test.ts | 10 +++++----- src/app/features/call/callSignalingFallback.ts | 6 +----- src/app/features/call/outgoingDeclineHandler.test.ts | 5 ++++- src/app/features/call/outgoingDeclineHandler.ts | 3 ++- src/app/hooks/useCallSignaling.ts | 6 +----- 7 files changed, 15 insertions(+), 29 deletions(-) diff --git a/src/app/features/call/callNotificationBridge.test.ts b/src/app/features/call/callNotificationBridge.test.ts index 25f4ad8e4..82c067b30 100644 --- a/src/app/features/call/callNotificationBridge.test.ts +++ b/src/app/features/call/callNotificationBridge.test.ts @@ -58,13 +58,7 @@ describe('callNotificationBridge', () => { callType: 'ring', }); - const incoming = resolveIncomingCallFromSearchParams( - params, - '!room:test', - '$notif', - true, - now - ); + const incoming = resolveIncomingCallFromSearchParams(params, '!room:test', '$notif', true, now); expect(incoming).toMatchObject({ roomId: '!room:test', diff --git a/src/app/features/call/callRingtone.ts b/src/app/features/call/callRingtone.ts index 203c41e8f..4b1f3f9b3 100644 --- a/src/app/features/call/callRingtone.ts +++ b/src/app/features/call/callRingtone.ts @@ -1,11 +1,7 @@ import InviteSound from '$public/sound/invite.ogg'; import NotificationSound from '$public/sound/notification.ogg'; import RingtoneSound from '$public/sound/ringtone.webm'; -import { - CALL_TONE_IDS, - type CallRingtoneId, - type Settings, -} from '$state/settings'; +import { CALL_TONE_IDS, type CallRingtoneId, type Settings } from '$state/settings'; export type CallToneOption = { value: T; diff --git a/src/app/features/call/callSignalingFallback.test.ts b/src/app/features/call/callSignalingFallback.test.ts index 0108c7ce3..cb9de6a9d 100644 --- a/src/app/features/call/callSignalingFallback.test.ts +++ b/src/app/features/call/callSignalingFallback.test.ts @@ -24,11 +24,11 @@ const incomingCall: IncomingCall = { describe('evaluateIncomingCallFallback', () => { it('clears expired incoming calls', () => { expect( - evaluateIncomingCallFallback( - { ...incomingCall, expiresAt: NOW - 1 }, - NOW, - { myUserId: '@self:example.org', getRoom: () => null, getSessionDescription: () => ({}) } - ) + evaluateIncomingCallFallback({ ...incomingCall, expiresAt: NOW - 1 }, NOW, { + myUserId: '@self:example.org', + getRoom: () => null, + getSessionDescription: () => ({}), + }) ).toEqual({ kind: 'clear', reason: 'expired' }); }); diff --git a/src/app/features/call/callSignalingFallback.ts b/src/app/features/call/callSignalingFallback.ts index 8363ab6a4..1bcedd6e4 100644 --- a/src/app/features/call/callSignalingFallback.ts +++ b/src/app/features/call/callSignalingFallback.ts @@ -94,11 +94,7 @@ export const evaluateOutgoingRingbackFallback = ( const sessionDescription = context.getSessionDescription(outgoingRoom); const isOutgoingPending = context.isOutgoingPending ?? isOutgoingCallPending; const isActive = context.isCallActive ?? isCallActive; - const pendingOutgoing = isOutgoingPending( - context.myUserId, - outgoingRoom, - sessionDescription - ); + const pendingOutgoing = isOutgoingPending(context.myUserId, outgoingRoom, sessionDescription); const activeCall = isActive(context.myUserId, outgoingRoom, sessionDescription); if (!pendingOutgoing || activeCall) { diff --git a/src/app/features/call/outgoingDeclineHandler.test.ts b/src/app/features/call/outgoingDeclineHandler.test.ts index edcfb8994..9ea224500 100644 --- a/src/app/features/call/outgoingDeclineHandler.test.ts +++ b/src/app/features/call/outgoingDeclineHandler.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { applyOutgoingDeclineToTracker, type OutgoingDeclineTracker } from './outgoingDeclineHandler'; +import { + applyOutgoingDeclineToTracker, + type OutgoingDeclineTracker, +} from './outgoingDeclineHandler'; const decline = { roomId: '!room:example.org', diff --git a/src/app/features/call/outgoingDeclineHandler.ts b/src/app/features/call/outgoingDeclineHandler.ts index 500fb52ef..482c42c3e 100644 --- a/src/app/features/call/outgoingDeclineHandler.ts +++ b/src/app/features/call/outgoingDeclineHandler.ts @@ -43,7 +43,8 @@ export const applyOutgoingDeclineToTracker = ( } const allRemoteDeclined = - targetCount > 0 && [...options.remoteJoinedIds].every((userId) => declineState.declinerIds.has(userId)); + targetCount > 0 && + [...options.remoteJoinedIds].every((userId) => declineState.declinerIds.has(userId)); const treatAsOneToOne = options.isDirectRoom || targetCount <= 1; if (!treatAsOneToOne && targetCount > 0 && !allRemoteDeclined) { diff --git a/src/app/hooks/useCallSignaling.ts b/src/app/hooks/useCallSignaling.ts index a75a63613..1a40381b2 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -486,10 +486,7 @@ export function useIncomingCallSignaling() { if (!senderId || !eventId) return; if (senderId === myUserId) { - if ( - type === RTC_NOTIFICATION_EVENT_TYPE && - handlers().callEmbed?.roomId === room.roomId - ) { + if (type === RTC_NOTIFICATION_EVENT_TYPE && handlers().callEmbed?.roomId === room.roomId) { activeOutgoingNotificationIdRef.current = eventId; } return; @@ -626,4 +623,3 @@ export function useIncomingCallSignaling() { return null; } -