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/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/components/IncomingCallModal.test.tsx b/src/app/components/IncomingCallModal.test.tsx new file mode 100644 index 000000000..608902ab3 --- /dev/null +++ b/src/app/components/IncomingCallModal.test.tsx @@ -0,0 +1,167 @@ +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, 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', +})); + +vi.mock('$utils/room', () => ({ + getRoomAvatarUrl: () => null, + getMemberDisplayName: () => 'Alice', +})); + +vi.mock('$hooks/useRoomNavigate', () => ({ + useRoomNavigate: () => ({ + navigateRoom: navigateRoomMock, + }), +})); + +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<(...args: unknown[]) => void>(), + metrics: { + count: vi.fn<(...args: unknown[]) => void>(), + }, +})); + +vi.mock('$utils/debugLogger', () => ({ + createDebugLogger: () => ({ + info: vi.fn<(...args: unknown[]) => void>(), + warn: vi.fn<(...args: unknown[]) => void>(), + error: vi.fn<(...args: unknown[]) => void>(), + }), +})); + +describe('IncomingCallInternal', () => { + 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', + 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); + webRtcSupportedMock.mockReset().mockReturnValue(true); + livekitSupportedMock.mockReset().mockReturnValue(true); + }); + + it('closes the modal when decline is pressed', async () => { + const onClose = vi.fn<() => void>(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Decline call' })); + + 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(); + + fireEvent.click(screen.getByRole('button', { name: /answer/i })); + + 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(); + }); + + 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/components/IncomingCallModal.tsx b/src/app/components/IncomingCallModal.tsx index 9652adf5d..1905165a9 100644 --- a/src/app/components/IncomingCallModal.tsx +++ b/src/app/components/IncomingCallModal.tsx @@ -1,78 +1,205 @@ import { + Avatar, Box, + Button, + color, Dialog, Header, - IconButton, Icon, + IconButton, Icons, - Text, - Button, - Avatar, - config, Overlay, - OverlayCenter, OverlayBackdrop, + 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 { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { autoJoinCallIntentAtom, - incomingCallRoomIdAtom, + callSoundBlockedAtom, + incomingCallAtom, mutedCallRoomIdAtom, + type IncomingCall, } 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'; 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 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 setCallSoundBlocked = useSetAtom(callSoundBlockedAtom); + const callSoundBlocked = useAtomValue(callSoundBlockedAtom); + 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( + () => + getIncomingCallBlockers({ + canUseWebRTC, + livekitSupported, + hasCallMemberPermission, + inAnotherCall, + }), + [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 = () => { - debugLog.info('call', 'Incoming call answered', { roomId: room.roomId }); + if (!canAnswer) return; + setCallSoundBlocked(false); + + 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 }); + void dismissSystemCallNotifications(room.roomId); onClose(); navigateRoom(room.roomId); }; - const handleDecline = async () => { - debugLog.info('call', 'Incoming call declined', { roomId: room.roomId }); + const handleDeclineOrIgnore = () => { + setCallSoundBlocked(false); + 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); + setMutedRoomId(room.roomId); + void dismissSystemCallNotifications(room.roomId); onClose(); + + if (isDirectRing) { + 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); + }); + } + }; + + const handleModalKeyDown = (evt: ReactKeyboardEvent) => { + if (evt.key === 'Escape') { + evt.preventDefault(); + evt.stopPropagation(); + handleDeclineOrIgnore(); + return; + } + if (evt.key === 'Enter' && canAnswer) { + evt.preventDefault(); + evt.stopPropagation(); + handleAnswer(); + } }; return ( - +
Incoming Call - +
- + - } - /> + {showCallerAvatar ? ( + } + /> + ) : ( + } + /> + )} - {roomName} + {title} - Incoming voice chat request + {incomingLabel} + + + {showCallerAvatar ? `Room: ${subtitle}` : `Caller: ${subtitle}`} + {capabilityIssues.length > 0 && ( + + {capabilityIssues.map((issue) => ( + + {issue.message} + + ))} + + )} + + {!canAnswer && primaryBlockedReason && ( + + {primaryBlockedReason} + + )} + {callSoundBlocked && ( + + Call sound was blocked by your browser. Click any call action to re-enable sound. + + )}
); } 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 +334,12 @@ export function IncomingCallModal() {
- +
diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 861af9175..b2e7ae452 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -1,12 +1,9 @@ -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 { useAtomValue } from 'jotai'; 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 +12,20 @@ import * as css from './styles.css'; import { CallMemberRenderer } from './CallMemberCard'; import { PrescreenControls } from './PrescreenControls'; import { CallControls } from './CallControls'; -import { EventType } from '$types/matrix-sdk'; +import { callEmbedAtom, callEmbedStartErrorAtom } from '$state/callEmbed'; function LivekitServerMissingMessage() { return ( - Your homeserver does not support calling. But you can still join call started by others. + Your homeserver does not support calling. You can still join calls started by others. + + ); +} + +function WebRTCMissingError() { + return ( + + Your browser does not support WebRTC, which is required for calling. ); } @@ -28,19 +33,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 +67,37 @@ 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 WidgetPreparationErrorMessage({ message }: { message: string }) { + return ( + + {message} ); } 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 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 callEmbed = useCallEmbed(); - const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId; - - const canJoin = hasPermission && (livekitSupported || hasParticipant); + const canJoin = callStartCapabilities.canStart; return ( @@ -100,13 +118,22 @@ function CallPrescreen() { - {!inOtherCall && - (hasPermission ? ( - + {!callStartCapabilities.inAnotherCall && + (callStartCapabilities.hasCallMemberPermission ? ( + ) : ( ))} - {inOtherCall && } + {callStartCapabilities.inAnotherCall && } + {showEmbedError && ( + + )} @@ -156,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); @@ -166,17 +199,20 @@ 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); const handleMove = useCallback( (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]); @@ -195,12 +231,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 +248,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 ( mutedRoomId === incoming.roomId; diff --git a/src/app/features/call/callIntent.test.ts b/src/app/features/call/callIntent.test.ts new file mode 100644 index 000000000..aa8ed4eef --- /dev/null +++ b/src/app/features/call/callIntent.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { ElementCallIntent } from '$plugins/call/types'; +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')).toBe('audio'); + }); + + it('maps Element Call voice intents to audio', () => { + expect(normalizeCallIntent(undefined, ElementCallIntent.StartCallDMVoice)).toBe('audio'); + expect(normalizeCallIntent(undefined, ElementCallIntent.JoinExistingVoice)).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'); + }); + }); + + 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..4259ddd83 --- /dev/null +++ b/src/app/features/call/callIntent.ts @@ -0,0 +1,39 @@ +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; + } + 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'; +}; + +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..4d45718ad --- /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 map start_call_dm to video', 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('video'); + expect(fromBridge?.intentKind).toBe('video'); + }); +}); 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..3d0f4b33a --- /dev/null +++ b/src/app/features/call/callMembershipState.ts @@ -0,0 +1,85 @@ +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; +}; + +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/callNotificationBridge.test.ts b/src/app/features/call/callNotificationBridge.test.ts new file mode 100644 index 000000000..82c067b30 --- /dev/null +++ b/src/app/features/call/callNotificationBridge.test.ts @@ -0,0 +1,100 @@ +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 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({ + 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..3a40a1467 --- /dev/null +++ b/src/app/features/call/callNotificationBridge.ts @@ -0,0 +1,133 @@ +import type { IncomingCall } from '$state/callEmbed'; +import { + MAX_CALL_NOTIFICATION_LIFETIME_MS, + normalizeCallIntent, + toCallNotificationTypeOrDefault, +} from './callIntent'; + +type CallCandidate = { + roomId: string; + notificationEventId: string; + notificationTypeRaw?: string; + intentKindRaw?: string; + intentRaw?: string; + refEventIdRaw?: string; + senderIdRaw?: string; + senderTsRaw?: number; + expiresAtRaw?: number; + isDirect: boolean; +}; + +const fromCandidate = (candidate: CallCandidate, now = Date.now()): IncomingCall | undefined => { + const notificationType = toCallNotificationTypeOrDefault(candidate.notificationTypeRaw); + 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: normalizeCallIntent(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 => { + const isCallDeepLink = + searchParams.get('call') === '1' || + searchParams.get('joinCall') === 'true' || + searchParams.get('joinCall') === '1'; + if (!isCallDeepLink) return undefined; + if (!notificationEventId) return undefined; + + 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( + { + 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/features/call/callRingtone.test.ts b/src/app/features/call/callRingtone.test.ts new file mode 100644 index 000000000..0a02117dd --- /dev/null +++ b/src/app/features/call/callRingtone.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { + CALL_RINGBACK_OPTIONS, + callRingtoneVolumeToGain, + canPlayCallAudio, + clampCallRingtoneVolume, + resolveIncomingCallToneUrl, + resolveOutgoingRingbackToneUrl, + validateCustomCallRingtone, +} 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: 'minimal-ping', callRingtoneId: 'minimal-ping' }, + 'blob:https://example.test/custom' + ) + ).toContain('/public/sound/notification.ogg'); + expect( + resolveOutgoingRingbackToneUrl( + { 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'); + + 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); + }); + + 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 }); + }); + + 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 new file mode 100644 index 000000000..4b1f3f9b3 --- /dev/null +++ b/src/app/features/call/callRingtone.ts @@ -0,0 +1,140 @@ +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'; + +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; + +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.filter( + (option) => option.value !== 'silent' +); + +type ToneSettings = Pick; + +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, + customRingbackUrl?: string +): string | null => { + if (settings.callRingbackTone === 'custom') { + return customRingbackUrl ?? resolveIncomingCallToneUrl(settings, customRingtoneUrl); + } + if (settings.callRingbackTone === 'silent') return null; + if (settings.callRingbackTone === settings.callRingtoneId) { + return resolveIncomingCallToneUrl(settings, customRingtoneUrl); + } + return resolveBuiltInTone(settings.callRingbackTone); +}; + +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; + }); + +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/call/callRingtoneStorage.ts b/src/app/features/call/callRingtoneStorage.ts new file mode 100644 index 000000000..f4c0bafe6 --- /dev/null +++ b/src/app/features/call/callRingtoneStorage.ts @@ -0,0 +1,96 @@ +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; + 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' }); + }); + }); +} + +async function putCustomCallAudio( + key: string, + file: File, + durationMs: number +): Promise { + const db = await openDb(); + const entry: StoredCallRingtone = { + id: 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; +} + +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(key); + req.addEventListener('error', () => reject(req.error)); + req.addEventListener('success', () => { + resolve(req.result as StoredCallRingtone | undefined); + }); + }); +} + +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(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/call/callSignalingDecrypt.ts b/src/app/features/call/callSignalingDecrypt.ts new file mode 100644 index 000000000..3765a37a2 --- /dev/null +++ b/src/app/features/call/callSignalingDecrypt.ts @@ -0,0 +1,56 @@ +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; + + if (event.isDecryptionFailure()) return undefined; + + try { + if (!event.isBeingDecrypted()) { + await event.attemptDecryption(crypto as CryptoBackend); + } + + const decryptionPromise = event.getDecryptionPromise(); + if (decryptionPromise) { + let timeoutId: ReturnType | undefined; + await Promise.race([ + decryptionPromise.finally(() => { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + }), + new Promise((resolve) => { + timeoutId = 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; + } + + if (event.isBeingDecrypted() || event.isDecryptionFailure()) { + 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..cb9de6a9d --- /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..1bcedd6e4 --- /dev/null +++ b/src/app/features/call/callSignalingFallback.ts @@ -0,0 +1,119 @@ +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/callStartCapabilities.ts b/src/app/features/call/callStartCapabilities.ts new file mode 100644 index 000000000..c2f72a7a1 --- /dev/null +++ b/src/app/features/call/callStartCapabilities.ts @@ -0,0 +1,59 @@ +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/call/callToneSources.test.ts b/src/app/features/call/callToneSources.test.ts new file mode 100644 index 000000000..123843d18 --- /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<() => Promise>(), + getCustomCallRingback: vi.fn<() => Promise>(), +})); + +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<() => string>(() => 'blob:custom'), + revokeObjectURL: vi.fn<() => void>(), + }) + ); + }); + + 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/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..9ea224500 --- /dev/null +++ b/src/app/features/call/outgoingDeclineHandler.test.ts @@ -0,0 +1,52 @@ +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 }); + }); + + 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 new file mode 100644 index 000000000..482c42c3e --- /dev/null +++ b/src/app/features/call/outgoingDeclineHandler.ts @@ -0,0 +1,55 @@ +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; + + 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; + + if (!treatAsOneToOne && targetCount > 0 && !allRemoteDeclined) { + return { kind: 'ignore_partial', declinedCount, targetCount }; + } + + return { kind: 'end_call', declinedCount, targetCount }; +}; diff --git a/src/app/features/call/rtcNotificationParser.test.ts b/src/app/features/call/rtcNotificationParser.test.ts new file mode 100644 index 000000000..95709239f --- /dev/null +++ b/src/app/features/call/rtcNotificationParser.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from 'vitest'; +import { + parseRtcDecline, + RTC_DECLINE_EVENT_TYPE, + 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'); + }); +}); + +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 new file mode 100644 index 000000000..09ddb1505 --- /dev/null +++ b/src/app/features/call/rtcNotificationParser.ts @@ -0,0 +1,146 @@ +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 = CallNotificationType; +export type NotificationIntentKind = CallIntentKind; + +export { MAX_CALL_NOTIFICATION_LIFETIME_MS }; + +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; +}; + +export type ParsedRtcDecline = { + roomId: string; + declineEventId: string; + notificationEventId: string; + senderId: string; +}; + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +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 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 = toCallNotificationType(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: normalizeCallIntent(undefined, intentRaw), + 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/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 9508741cc..d5ae1d9b3 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); diff --git a/src/app/features/common-settings/permissions/Powers.tsx b/src/app/features/common-settings/permissions/Powers.tsx index 7ad8630c4..413bb99d5 100644 --- a/src/app/features/common-settings/permissions/Powers.tsx +++ b/src/app/features/common-settings/permissions/Powers.tsx @@ -6,7 +6,7 @@ import { Box, Button, Chip, Text, PopOut, Menu, Scroll, toRem, config, color } f import { SequenceCard } from '$components/sequence-card'; import { getPowers, usePowerLevelTags } from '$hooks/usePowerLevelTags'; import { SettingTile } from '$components/setting-tile'; -import type { IPowerLevels } from '$hooks/usePowerLevels'; +import type { IPowerLevels, PermissionLocation } from '$hooks/usePowerLevels'; import { getPermissionPower } from '$hooks/usePowerLevels'; import { useRoom } from '$hooks/useRoom'; import { PowerColorBadge, PowerIcon } from '$components/power'; @@ -19,6 +19,8 @@ import { useRoomCreators } from '$hooks/useRoomCreators'; import { SequenceCardStyle } from '$features/common-settings/styles.css'; import type { PermissionGroup } from './types'; +const getPermissionLocationKey = (location: PermissionLocation): string => JSON.stringify(location); + type PeekPermissionsProps = { powerLevels: IPowerLevels; power: number; @@ -67,7 +69,7 @@ function PeekPermissions({ powerLevels, power, permissionGroups, children }: Pee 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/room/RoomCallButton.test.tsx b/src/app/features/room/RoomCallButton.test.tsx new file mode 100644 index 000000000..7f4ce6ae6 --- /dev/null +++ b/src/app/features/room/RoomCallButton.test.tsx @@ -0,0 +1,88 @@ +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<(...args: unknown[]) => void>(), + useCallJoinedMock: vi.fn<() => boolean>(), +})); + +vi.mock('$hooks/useCallEmbed', () => ({ + useCallStart: () => startCallMock, + useCallJoined: () => useCallJoinedMock(), +})); + +vi.mock('jotai', async (importOriginal: () => Promise) => { + 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('starts a voice call from the voice button', async () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /start voice call/i })); + + await waitFor(() => { + expect(startCallMock).toHaveBeenCalledWith(room, { + microphone: true, + video: false, + sound: true, + }); + }); + }); + + it('starts a video call from the video button', async () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /start video call/i })); + + await waitFor(() => { + expect(startCallMock).toHaveBeenCalledWith(room, { + microphone: true, + video: true, + sound: true, + }); + }); + }); + + it('hides video button when video start is disabled', () => { + render( + + ); + + 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 3c60e5935..43840cf01 100644 --- a/src/app/features/room/RoomCallButton.tsx +++ b/src/app/features/room/RoomCallButton.tsx @@ -1,62 +1,63 @@ import { IconButton, Icon, Icons, TooltipProvider, Tooltip, Text } 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'; interface RoomCallButtonProps { room: Room; + direct: boolean; + defaultPreferences: CallPreferences; + kind: 'voice' | 'video'; + allowVideoStart?: boolean; } -export function RoomCallButton({ room }: RoomCallButtonProps) { - const startCall = useCallStart(); +export function RoomCallButton({ + room, + direct, + defaultPreferences, + kind, + allowVideoStart = true, +}: RoomCallButtonProps) { + const startCall = useCallStart(direct); const callEmbed = useAtomValue(callEmbedAtom); const joined = useCallJoined(callEmbed); - const mx = useMatrixClient(); - const { microphone, video, sound } = useCallPreferences(); 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 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 startSelectedCall = () => { + startCall(room, { + microphone: defaultPreferences.microphone, + video: startingVideoCall, + sound: defaultPreferences.sound, + }); }; + 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 ( - Start Voice Call + {inAnotherCall ? ( + Already in another call + ) : callStartingInThisRoom ? ( + Call is starting + ) : ( + {readyCopy} + )} } > @@ -64,10 +65,11 @@ export function RoomCallButton({ room }: RoomCallButtonProps) { - + )} diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index f45e6172f..850a3dd9d 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, @@ -91,6 +91,8 @@ 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 { useCallStartCapabilities } from '$hooks/useCallStartCapabilities'; import { JumpToTime } from './jump-to-time'; import { RoomPinMenu } from './room-pin-menu'; import * as css from './RoomViewHeader.css'; @@ -355,6 +357,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( @@ -362,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; @@ -725,7 +725,25 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { )} - {canUseCalls && shouldShowCallButton && } + {!room.isCallRoom() && + callStartCapabilities.canRenderCallButton && + shouldShowCallButton && ( + <> + + + + )} ({ + CALL_TONE_IDS: ['sable-default', 'classic-soft', 'minimal-ping', 'silent', 'custom'], + settingsAtom: {}, +})); + +vi.mock('$state/hooks/settings', () => ({ + useSetting: vi.fn(), +})); + +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>(), +})); + +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(); + + 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(); + 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 new file mode 100644 index 000000000..3413d0798 --- /dev/null +++ b/src/app/features/settings/general/CallSoundSettings.tsx @@ -0,0 +1,406 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +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 CallRingtoneId } from '$state/settings'; +import { + CALL_RINGBACK_OPTIONS, + CALL_RINGTONE_OPTIONS, + callRingtoneVolumeToGain, + clampCallRingtoneVolume, + readAudioDurationMs, + validateCustomCallRingtone, +} from '$features/call/callRingtone'; +import { resolveCallToneSources, revokeUnusedCustomToneUrls } from '$features/call/callToneSources'; +import { + clearCustomCallRingback, + clearCustomCallRingtone, + getCustomCallRingback, + getCustomCallRingtone, + putCustomCallRingback, + putCustomCallRingtone, + type StoredCallRingtone, +} from '$features/call/callRingtoneStorage'; +import { SequenceCardStyle } from '$features/settings/styles.css'; +import { + CustomToneSettingsCard, + customToneValidationError, + type CustomToneMetadata, + type PreviewTone, +} from './CallSoundSettingsCards'; + +const toCustomToneMetadata = (stored: StoredCallRingtone): CustomToneMetadata => ({ + fileName: stored.fileName, + sizeBytes: stored.sizeBytes, + durationMs: stored.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 [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); + + useEffect(() => { + let mounted = true; + Promise.all([getCustomCallRingtone(), getCustomCallRingback()]) + .then(([ringtone, ringback]) => { + if (!mounted) return; + setHasCustomRingtone(Boolean(ringtone)); + setHasCustomRingback(Boolean(ringback)); + setCustomRingtoneMeta(ringtone ? toCustomToneMetadata(ringtone) : null); + setCustomRingbackMeta(ringback ? toCustomToneMetadata(ringback) : null); + }) + .finally(() => { + if (!mounted) return; + setLoadingCustomState(false); + }); + + return () => { + mounted = false; + previewAudioRef.current?.pause(); + previewAudioRef.current = null; + }; + }, []); + + useEffect(() => { + if (!loadingCustomState && !hasCustomRingtone && callRingtoneId === 'custom') { + setCallRingtoneId('sable-default'); + setCustomError('Custom ringtone is not available on this device. Falling back to default.'); + } + 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( + () => + CALL_RINGTONE_OPTIONS.map((option) => + option.value === 'custom' + ? { + ...option, + label: customRingtoneMeta ? 'Custom File (Imported)' : 'Custom File', + disabled: loadingCustomState, + } + : option + ), + [customRingtoneMeta, loadingCustomState] + ); + const ringbackOptions = useMemo( + () => + CALL_RINGBACK_OPTIONS.map((option) => + option.value === 'custom' + ? { + ...option, + label: customRingbackMeta ? 'Custom File (Imported)' : 'Custom File', + disabled: loadingCustomState, + } + : option + ), + [customRingbackMeta, loadingCustomState] + ); + + const resolveToneForPreview = useCallback( + async (tone: PreviewTone): Promise => { + const resolved = await resolveCallToneSources({ callRingtoneId, callRingbackTone }); + const source = tone === 'incoming' ? resolved.incomingUrl : resolved.outgoingUrl; + revokeUnusedCustomToneUrls(resolved, source); + return source; + }, + [callRingtoneId, callRingbackTone] + ); + + const playPreviewTone = useCallback( + async (tone: PreviewTone) => { + 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 importCustomTone = useCallback( + ( + label: 'Ringtone' | 'Ringback', + putTone: (file: File, durationMs: number) => Promise, + onImported: (stored: StoredCallRingtone) => 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) { + setCustomError(customToneValidationError(validation.reason, label)); + return; + } + + const stored = await putTone(file, durationMs); + onImported(stored); + } catch { + setCustomError('Could not import this file. Try a different audio format.'); + } + }); + + input.click(); + }, + [] + ); + + const handleImportCustomRingtone = useCallback(() => { + importCustomTone('Ringtone', putCustomCallRingtone, (stored) => { + setHasCustomRingtone(true); + setCallRingtoneId('custom'); + setCustomRingtoneMeta(toCustomToneMetadata(stored)); + }); + }, [importCustomTone, setCallRingtoneId]); + + const handleResetCustomRingtone = useCallback(async () => { + setCustomError(null); + await clearCustomCallRingtone(); + setHasCustomRingtone(false); + setCustomRingtoneMeta(null); + if (callRingtoneId === 'custom') { + setCallRingtoneId('sable-default'); + } + }, [callRingtoneId, setCallRingtoneId]); + + const handleImportCustomRingback = useCallback(() => { + importCustomTone('Ringback', putCustomCallRingback, (stored) => { + setHasCustomRingback(true); + setCallRingbackTone('custom'); + setCustomRingbackMeta(toCustomToneMetadata(stored)); + }); + }, [importCustomTone, setCallRingbackTone]); + + const handleResetCustomRingback = useCallback(async () => { + setCustomError(null); + await clearCustomCallRingback(); + setHasCustomRingback(false); + setCustomRingbackMeta(null); + if (callRingbackTone === 'custom') { + setCallRingbackTone('sable-default'); + } + }, [callRingbackTone, setCallRingbackTone]); + + const handleRingtoneSelection = (next: CallRingtoneId) => { + if (next === 'custom' && !hasCustomRingtone) { + setCustomError('Import a custom ringtone file first.'); + return; + } + setCustomError(null); + setCallRingtoneId(next); + }; + + const handleRingbackSelection = (next: CallRingtoneId) => { + 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; + setCallRingtoneVolume(clampCallRingtoneVolume(parsed)); + }; + + return ( + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + handleVolumeChange(evt.currentTarget.value)} + outlined + /> + } + /> + + + + } + /> + + + + {customError && ( + + {customError} + + )} + + ); +} 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/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/features/space-settings/permissions/usePermissionItems.ts b/src/app/features/space-settings/permissions/usePermissionItems.ts index 697d98abe..e05beb02e 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'; 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, diff --git a/src/app/hooks/useAutoJoinCall.ts b/src/app/hooks/useAutoJoinCall.ts index 99b59d298..0f95d9485 100644 --- a/src/app/hooks/useAutoJoinCall.ts +++ b/src/app/hooks/useAutoJoinCall.ts @@ -1,24 +1,44 @@ import { useEffect } from 'react'; -import { useAtom } from 'jotai'; +import { useAtom, useAtomValue } from 'jotai'; import { useCallStart } from '$hooks/useCallEmbed'; 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 startCall = useCallStart(); + 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) { - startCall(room); + const startCall = mDirects.has(room.roomId) ? startDirectCall : startRoomCall; + startCall(room, { + microphone: callPreferences.microphone, + video: autoJoinIntent.video, + sound: callPreferences.sound, + }); setAutoJoinIntent(null); } } - }, [selectedRoomId, autoJoinIntent, startCall, setAutoJoinIntent, mx]); + }, [ + selectedRoomId, + autoJoinIntent, + setAutoJoinIntent, + mx, + mDirects, + callPreferences.microphone, + callPreferences.sound, + startDirectCall, + startRoomCall, + ]); } diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts index fef17cdde..e3364f4fa 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; @@ -112,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 73cf864e2..1a40381b2 100644 --- a/src/app/hooks/useCallSignaling.ts +++ b/src/app/hooks/useCallSignaling.ts @@ -1,247 +1,625 @@ -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, useStore } from 'jotai'; +import type { RoomEventHandlerMap, MatrixEvent, Room } from '$types/matrix-sdk'; +import { MatrixRTCSessionManagerEvents, RoomEvent } from '$types/matrix-sdk'; import { mDirectAtom } from '$state/mDirectList'; -import { incomingCallRoomIdAtom, mutedCallRoomIdAtom } from '$state/callEmbed'; -import RingtoneSound from '$public/sound/ringtone.webm'; +import { + callEmbedAtom, + callSoundBlockedAtom, + incomingCallAtom, + mutedCallRoomIdAtom, + type IncomingCall, +} from '$state/callEmbed'; +import { settingsAtom } from '$state/settings'; +import { + 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'; +import { isIncomingCallSuppressed } from '$features/call/callIncomingIngress'; +import { getRemoteRtcMemberUserIds } from '$features/call/callMembershipState'; import { useMatrixClient } from './useMatrixClient'; import { createDebugLogger } from '../utils/debugLogger'; const debugLog = createDebugLogger('CallSignaling'); -type CallPhase = 'IDLE' | 'RINGING_OUT' | 'RINGING_IN' | 'ACTIVE' | 'ENDED'; +const canSenderStartCalls = (room: Room, senderId: string): boolean => + room.currentState?.maySendStateEvent('org.matrix.msc3401.call.member', senderId) ?? false; -interface SignalState { - incoming: string | null; - outgoing: string | null; -} - -export function useCallSignaling() { +export function useIncomingCallSignaling() { const mx = useMatrixClient(); - const setIncomingCall = useSetAtom(incomingCallRoomIdAtom); + const store = useStore(); + const callEmbed = useAtomValue(callEmbedAtom); 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 setCallEmbed = useSetAtom(callEmbedAtom); 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 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< + Map }> + >(new Map()); 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); + const activeOutgoingNotificationIdRef = useRef(null); + const seenDeclineEventIdsRef = useRef>(new Set()); + + 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; useEffect(() => { - const inc = new Audio(RingtoneSound); - inc.loop = true; - incomingAudioRef.current = inc; + declinedOutgoingRoomIdRef.current = null; + outgoingDeclinesRef.current.clear(); + activeOutgoingNotificationIdRef.current = null; + seenDeclineEventIdsRef.current.clear(); + }, [callEmbed]); + + useEffect(() => { + const incoming = new Audio(); + incoming.loop = true; + incomingAudioRef.current = incoming; - const out = new Audio(RingtoneSound); - out.loop = true; - outgoingAudioRef.current = out; + const outgoing = new Audio(); + outgoing.loop = true; + outgoingAudioRef.current = outgoing; return () => { - inc.pause(); - out.pause(); + incoming.pause(); + outgoing.pause(); }; }, []); - const stopRinging = useCallback(() => { + useEffect(() => { + let canceled = false; + let revokeToneUrls: (() => void) | undefined; + + const incoming = incomingAudioRef.current; + const outgoing = outgoingAudioRef.current; + if (!incoming || !outgoing) return undefined; + + const syncSources = async () => { + const resolved = await resolveCallToneSources({ + callRingtoneId: settings.callRingtoneId, + callRingbackTone: settings.callRingbackTone, + }); + + if (canceled) { + resolved.revoke(); + return; + } + + revokeToneUrls?.(); + revokeToneUrls = resolved.revoke; + + incoming.pause(); + incoming.currentTime = 0; + outgoing.pause(); + outgoing.currentTime = 0; + + const gain = callRingtoneVolumeToGain(settings.callRingtoneVolume); + + if (resolved.incomingUrl) { + incoming.src = resolved.incomingUrl; + } else { + incoming.removeAttribute('src'); + } + if (resolved.outgoingUrl) { + outgoing.src = resolved.outgoingUrl; + } else { + outgoing.removeAttribute('src'); + } + + incoming.volume = gain; + outgoing.volume = gain; + }; + + syncSources(); + + return () => { + canceled = true; + revokeToneUrls?.(); + }; + }, [settings.callRingtoneId, settings.callRingbackTone, settings.callRingtoneVolume]); + + const stopIncomingRing = useCallback(() => { incomingAudioRef.current?.pause(); - outgoingAudioRef.current?.pause(); if (incomingAudioRef.current) incomingAudioRef.current.currentTime = 0; + setCallSoundBlocked(false); + }, [setCallSoundBlocked]); + + 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(() => { + const activeIncomingCall = incomingCallRef.current; + stopIncomingRing(); setIncomingCall(null); - }, [setIncomingCall]); - - const playOutgoingRinging = useCallback((roomId: string) => { - if (outgoingAudioRef.current && ringingRoomIdRef.current !== roomId) { - outgoingAudioRef.current.play().catch(() => {}); - ringingRoomIdRef.current = roomId; + if (activeIncomingCall) { + void dismissSystemCallNotifications(activeIncomingCall.roomId); } - }, []); + }, [setIncomingCall, stopIncomingRing]); + + const handleOutgoingDecline = useCallback( + (decline: OutgoingDeclineEvent) => { + if (!callEmbed || callEmbed.roomId !== decline.roomId) { + 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 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 playRinging = useCallback( - (roomId: string) => { - if (incomingAudioRef.current && ringingRoomIdRef.current !== roomId) { - incomingAudioRef.current.play().catch(() => {}); - ringingRoomIdRef.current = roomId; - setIncomingCall(roomId); + 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: decision.declinedCount, + targetCount: decision.targetCount, + }); + 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: decision.declinedCount, + targetCount: decision.targetCount, + }); + Sentry.metrics.count('sable.call.outgoing.declined', 1); + stopOutgoingRing(); + + 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(() => { + window.setTimeout(() => { + const activeEmbed = store.get(callEmbedAtom); + if (activeEmbed !== callEmbed) return; + setCallEmbed(undefined); + }, OUTGOING_DECLINE_EMBED_CLEAR_MS); + }); + }, + [callEmbed, mDirects, mx, setCallEmbed, stopOutgoingRing, store] + ); + + const callAudioAllowed = canPlayCallAudio({ + isNotificationSounds: settings.isNotificationSounds, + callSoundOverrideGlobalNotifications: settings.callSoundOverrideGlobalNotifications, + }); + const incomingRingtoneAllowed = settings.incomingCallSoundEnabled && callAudioAllowed; + const outgoingRingbackAllowed = settings.outgoingRingbackEnabled && callAudioAllowed; + const incomingToneIsSilent = settings.callRingtoneId === 'silent'; + + const handleIncomingCall = useCallback( + (nextIncomingCall: IncomingCall) => { + if (isIncomingCallSuppressed(nextIncomingCall, mutedRoomIdRef.current)) return; + if (!rememberNotificationId(nextIncomingCall.notificationEventId)) return; + 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), + }, + }); }, [setIncomingCall] ); - // 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; + 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; - const checkDMsForActiveCalls = () => { - const myUserId = mx.getUserId(); - const now = Date.now(); - - 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; - } - - // 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; - } - - // 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; - } - - // 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; - } - - // 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'; - } - } - - return acc; + const myUserId = mx.getSafeUserId(); + const handlers = () => signalingHandlerRefs.current!; + + 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 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 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, }, - { incoming: null, outgoing: null } + { + myUserId, + now: Date.now(), + maxLifetimeMs: MAX_NOTIFICATION_LIFETIME_MS, + } ); - if (signal.incoming) { - playRingingRef.current(signal.incoming); - } else if (signal.outgoing) { - playOutgoingRingingRef.current(signal.outgoing); - } else { - stopRingingRef.current(); - if (!signal.outgoing) outgoingStartRef.current = null; + 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; } + + return { + ...parsed, + isDirect: handlers().mDirects.has(room.roomId), + }; }; - const interval = setInterval(checkDMsForActiveCalls, 1000); + let timelineHandlerEpoch = 0; + + const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = async ( + event, + room, + _toStartOfTimeline, + _removed, + data + ) => { + 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; + + const type = event.getType(); + if ( + type !== RTC_NOTIFICATION_EVENT_TYPE && + type !== RTC_DECLINE_EVENT_TYPE && + !event.isEncrypted() + ) { + 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; + if (incoming) { + handlers().handleIncomingCall(incoming); + return; + } - const handleUpdate = () => checkDMsForActiveCalls(); + // 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 = + type === RTC_DECLINE_EVENT_TYPE || + (event.isEncrypted() && relation?.rel_type === REFERENCE_REL_TYPE); + if (!shouldCheckDecline) { + return; + } + + const decline = await parseRtcDeclineFromTimelineEvent( + event, + room, + data.liveEvent, + myUserId, + mx + ); + if (isStale()) return; + if (decline) { + handlers().handleOutgoingDecline(decline); + } + }; + + const fallbackContext = { + myUserId, + getRoom: (roomId: string) => mx.getRoom(roomId), + getSessionDescription: (room: Room) => mx.matrixRTC.getRoomSession(room).sessionDescription, + }; + + const evaluateIncomingFallback = () => { + const action = evaluateIncomingCallFallback( + incomingCallRef.current, + Date.now(), + fallbackContext + ); + if (action.kind !== 'clear') 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, + }); + } + + handlers().clearIncomingCall(); + }; + + const evaluateOutgoingFallback = () => { + const ringAction = evaluateOutgoingRingbackFallback( + { + ringRoomId: outgoingRingRoomIdRef.current, + ringStartedAt: outgoingStartRef.current, + }, + Date.now(), + { + ...fallbackContext, + activeCallRoomId: handlers().callEmbed?.roomId, + outgoingRingbackAllowed: handlers().outgoingRingbackAllowed, + declinedRoomId: declinedOutgoingRoomIdRef.current, + } + ); + + outgoingRingRoomIdRef.current = ringAction.nextState.ringRoomId; + outgoingStartRef.current = ringAction.nextState.ringStartedAt; + + if (ringAction.kind === 'stop') { + handlers().stopOutgoingRing(); + return; + } + + if (ringAction.started) { + debugLog.info('call', 'Outgoing ringing fallback started', { roomId: ringAction.roomId }); + } + + const outgoingAudio = outgoingAudioRef.current; + if (outgoingAudio && (ringAction.started || outgoingAudio.paused)) { + outgoingAudio.play().catch(() => { + Sentry.metrics.count('sable.call.ringback.blocked', 1); + }); + } + }; + + const evaluateFallbackState = () => { + evaluateIncomingFallback(); + evaluateOutgoingFallback(); + }; const handleSessionEnded = (roomId: string) => { - if (mutedRoomIdRef.current === roomId) setMutedRoomId(null); - callPhaseRef.current[roomId] = 'IDLE'; - checkDMsForActiveCalls(); + if (mutedRoomIdRef.current === roomId) handlers().setMutedRoomId(null); + 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); + timelineHandlerEpoch += 1; + 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); + handlers().stopIncomingRing(); + handlers().stopOutgoingRing(); }; - }, [mx, mDirects, setMutedRoomId]); // stable: volatile deps accessed via refs above + }, [mx]); return null; } diff --git a/src/app/hooks/useCallStartCapabilities.test.ts b/src/app/hooks/useCallStartCapabilities.test.ts new file mode 100644 index 000000000..0f3c45b28 --- /dev/null +++ b/src/app/hooks/useCallStartCapabilities.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import type { Room } from '$types/matrix-sdk'; +import { evaluateCallStartCapabilities } from '$features/call/callStartCapabilities'; + +const createRoom = (roomId: string, canSend = true): Room => + ({ + 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] + ); +}; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index f847e0856..acaa0e7fc 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -56,10 +56,13 @@ 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'; +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'; @@ -609,6 +612,9 @@ type ClientNonUIFeaturesProps = { 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(); useEffect(() => { @@ -634,11 +640,19 @@ export function HandleNotificationClick() { if (!roomId) return; setPending({ roomId, eventId, targetSessionId: userId }); + + const incomingCall = resolveIncomingCallFromNotificationData( + data as Record, + mDirects.has(roomId) + ); + if (incomingCall && !isIncomingCallSuppressed(incomingCall, mutedRoomId)) { + setIncomingCall(incomingCall); + } }; navigator.serviceWorker.addEventListener('message', handleMessage); return () => navigator.serviceWorker.removeEventListener('message', handleMessage); - }, [setPending, setActiveSessionId, navigate]); + }, [mDirects, mutedRoomId, navigate, setActiveSessionId, setIncomingCall, setPending]); return null; } @@ -862,7 +876,7 @@ function SettingsSyncFeature() { } export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { - useCallSignaling(); + useIncomingCallSignaling(); return ( <> diff --git a/src/app/pages/client/HandleNotificationClick.test.tsx b/src/app/pages/client/HandleNotificationClick.test.tsx new file mode 100644 index 000000000..85eb4f641 --- /dev/null +++ b/src/app/pages/client/HandleNotificationClick.test.tsx @@ -0,0 +1,112 @@ +import { render, waitFor } from '@testing-library/react'; +import { Provider, createStore } from 'jotai'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it } 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/pages/client/ToRoomEvent.tsx b/src/app/pages/client/ToRoomEvent.tsx index 3073b44e9..a1a9bd833 100644 --- a/src/app/pages/client/ToRoomEvent.tsx +++ b/src/app/pages/client/ToRoomEvent.tsx @@ -1,7 +1,11 @@ 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, 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. @@ -17,8 +21,12 @@ 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 mutedRoomId = useAtomValue(mutedCallRoomIdAtom); const setActiveSessionId = useSetAtom(activeSessionIdAtom); const setPending = useSetAtom(pendingNotificationAtom); + const setIncomingCall = useSetAtom(incomingCallAtom); useEffect(() => { if (!roomId) return; @@ -26,9 +34,30 @@ 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 && !isIncomingCallSuppressed(incomingCall, mutedRoomId)) { + 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, + mutedRoomId, + roomId, + searchParams, + setActiveSessionId, + setIncomingCall, + setPending, + userId, + ]); return null; } diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index fb2e6ff44..47cbdc22e 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'], }); } @@ -131,10 +122,15 @@ export class CallControl extends EventEmitter implements CallControlState { } 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 = !sound; if (callDocument) { - callDocument.querySelectorAll('audio').forEach((el) => { - el.muted = !sound; + callDocument.querySelectorAll('audio, video').forEach((el) => { + el.muted = shouldMute; }); } } @@ -160,8 +156,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 +211,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 new file mode 100644 index 000000000..637760942 --- /dev/null +++ b/src/app/plugins/call/CallEmbed.intent.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; +import { vi } from 'vitest'; + +vi.mock('../../utils/debugLogger', () => ({ + createDebugLogger: () => ({ + 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 { CallEmbed } from './CallEmbed'; +import { ElementCallIntent } from './types'; + +type IntentCase = { + dm: boolean; + ongoing: boolean; + video: boolean; + 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 }, + { 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: ElementCallIntent.StartCallVoice }, + { dm: false, ongoing: true, video: true, expected: ElementCallIntent.JoinExisting }, + { dm: false, ongoing: true, video: false, expected: ElementCallIntent.JoinExistingVoice }, +]; + +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); + }); +}); + +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; + + 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(); + }); + + 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 b31323d4b..752f465e5 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'); @@ -40,22 +41,44 @@ 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( mx: MatrixClient, room: Room, intent: ElementCallIntent, - themeKind: ElementCallThemeKind + themeKind: ElementCallThemeKind, + elementCallUrl?: string ): Widget { const userId = mx.getSafeUserId(); const deviceId = mx.getDeviceId() ?? ''; @@ -77,12 +100,37 @@ export class CallEmbed { perParticipantE2EE: room.hasEncryptionStateEvent().toString(), lang: 'en-EN', theme: themeKind, + header: 'none', }); - const widgetUrl = new URL( - `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`, - window.location.origin - ); + if (!room.isCallRoom() && CallEmbed.startingCall(intent)) { + params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification'); + } + + 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 = { @@ -282,8 +330,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..99f89b15d 100644 --- a/src/app/plugins/call/CallWidgetDriver.ts +++ b/src/app/plugins/call/CallWidgetDriver.ts @@ -52,7 +52,17 @@ 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, + }); + } + 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..8c6f6ceca --- /dev/null +++ b/src/app/plugins/call/callEmbedError.ts @@ -0,0 +1,28 @@ +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 + : typeof error === 'string' + ? error + : error == null + ? '' + : JSON.stringify(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..5cba1ccfd --- /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<(...args: unknown[]) => void>(), + warn: vi.fn<(...args: unknown[]) => void>(), + error: vi.fn<(...args: unknown[]) => void>(), + debug: vi.fn<(...args: unknown[]) => void>(), + }), +})); +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/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', diff --git a/src/app/plugins/call/utils.test.ts b/src/app/plugins/call/utils.test.ts new file mode 100644 index 000000000..116466b71 --- /dev/null +++ b/src/app/plugins/call/utils.test.ts @@ -0,0 +1,78 @@ +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.test.ts b/src/app/state/callEmbed.test.ts new file mode 100644 index 000000000..35e37d76a --- /dev/null +++ b/src/app/state/callEmbed.test.ts @@ -0,0 +1,45 @@ +import { createStore } from 'jotai'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { callEmbedAtom, callEmbedStartErrorAtom } from './callEmbed'; + +const distributionMock = vi.fn<(...args: unknown[]) => void>(); + +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<() => void>(); + const disposeB = vi.fn<() => void>(); + 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<() => void>(); + 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/callEmbed.ts b/src/app/state/callEmbed.ts index 84bc0748f..0dbe3dd69 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,12 +31,50 @@ export const callEmbedAtom = atom( + (get) => get(baseCallEmbedStartErrorAtom), + (_get, set, nextError) => { + set(baseCallEmbedStartErrorAtom, nextError); + } +); + export const callChatAtom = atom(false); -export const incomingCallRoomIdAtom = 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); +export const callSoundBlockedAtom = atom(false); diff --git a/src/app/state/settings.defaults.test.ts b/src/app/state/settings.defaults.test.ts index d2b51cd56..198730cdf 100644 --- a/src/app/state/settings.defaults.test.ts +++ b/src/app/state/settings.defaults.test.ts @@ -31,6 +31,54 @@ 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('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('ignores legacy custom tone metadata keys during migration', () => { + localStorage.setItem( + 'settings', + JSON.stringify({ + callCustomRingtoneName: 'tone.ogg', + callCustomRingtoneSizeBytes: -5, + callCustomRingtoneDurationMs: Number.NaN, + callCustomRingbackName: 'ringback.ogg', + callCustomRingbackSizeBytes: -7, + callCustomRingbackDurationMs: Number.NaN, + }) + ); + const merged = mergePersistedSettings(localStorage.getItem('settings'), {}); + expect(merged).not.toHaveProperty('callCustomRingtoneName'); + expect(merged).not.toHaveProperty('callCustomRingbackName'); + }); }); describe('sanitizeSettingsDefaults', () => { @@ -64,4 +112,25 @@ describe('sanitizeSettingsDefaults', () => { }); expect(sanitizeSettingsDefaults({ rightSwipeAction: 'nope' })).toEqual({}); }); + + it('sanitizes ringtone settings defaults', () => { + expect( + sanitizeSettingsDefaults({ + callRingtoneId: 'classic-soft', + callRingbackTone: 'minimal-ping', + callRingtoneVolume: 73.7, + }) + ).toEqual({ + callRingtoneId: 'classic-soft', + callRingbackTone: 'minimal-ping', + 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 b1b744c1f..a3e4ee4b4 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -29,6 +29,14 @@ export enum ShowRoomIcon { Never = 'never', } export type JumboEmojiSize = 'none' | 'extraSmall' | 'small' | 'normal' | 'large' | 'extraLarge'; +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 ThemeRemoteFavorite = { fullUrl: string; @@ -153,6 +161,12 @@ export interface Settings { subspaceHierarchyLimit: number; alwaysShowCallButton: boolean; joinCallOnSingleClick: boolean; + incomingCallSoundEnabled: boolean; + outgoingRingbackEnabled: boolean; + callRingtoneVolume: number; + callRingtoneId: CallRingtoneId; + callRingbackTone: CallRingtoneId; + callSoundOverrideGlobalNotifications: boolean; faviconForMentionsOnly: boolean; highlightMentions: boolean; pkCompat: boolean; @@ -285,6 +299,12 @@ export const defaultSettings: Settings = { subspaceHierarchyLimit: 3, alwaysShowCallButton: false, joinCallOnSingleClick: true, + incomingCallSoundEnabled: true, + outgoingRingbackEnabled: true, + callRingtoneVolume: 80, + callRingtoneId: 'sable-default', + callRingbackTone: 'sable-default', + callSoundOverrideGlobalNotifications: false, faviconForMentionsOnly: false, highlightMentions: true, pkCompat: false, @@ -333,6 +353,12 @@ function cloneDefaultSettings(): Settings { }; } +const CALL_TONE_ID_SET = new Set(CALL_TONE_IDS); + +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; @@ -360,6 +386,37 @@ function migrateParsedLocalStorage(parsed: Record): void { } delete parsed.themeChatPreviewAnyUrl; delete parsed.themeChatPreviewApprovedCatalogOnly; + + if (typeof parsed.callRingtoneVolume === 'number' && Number.isFinite(parsed.callRingtoneVolume)) { + parsed.callRingtoneVolume = clampPercent(parsed.callRingtoneVolume); + } + + if (!isCallToneId(parsed.callRingtoneId)) { + 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 (!isCallToneId(parsed.callRingbackTone)) { + delete parsed.callRingbackTone; + } + + const legacyCallCustomMetadataKeys = [ + 'callCustomRingtoneName', + 'callCustomRingtoneSizeBytes', + 'callCustomRingtoneDurationMs', + 'callCustomRingbackName', + 'callCustomRingbackSizeBytes', + 'callCustomRingbackDurationMs', + ] as const; + + for (const key of legacyCallCustomMetadataKeys) { + delete parsed[key]; + } } export function mergePersistedSettings( @@ -471,6 +528,12 @@ function sanitizeSettingsKey(key: keyof Settings, val: unknown): unknown { : undefined; case 'rightSwipeAction': return val === RightSwipeAction.Members || val === RightSwipeAction.Reply ? val : undefined; + case 'callRingtoneId': + case 'callRingbackTone': + return isCallToneId(val) ? val : undefined; + case 'callRingtoneVolume': + if (typeof val !== 'number' || !Number.isFinite(val)) return undefined; + return clampPercent(val); case 'renderUserCards': return val === 'both' || val === 'light' || val === 'dark' || val === 'none' ? val 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 + ); +}; diff --git a/src/app/utils/settingsSync.test.ts b/src/app/utils/settingsSync.test.ts index 608a94343..499a73a33 100644 --- a/src/app/utils/settingsSync.test.ts +++ b/src/app/utils/settingsSync.test.ts @@ -31,6 +31,12 @@ describe('NON_SYNCABLE_KEYS', () => { 'isPeopleDrawer', 'isWidgetDrawer', 'memberSortFilterIndex', + 'incomingCallSoundEnabled', + 'outgoingRingbackEnabled', + 'callRingtoneVolume', + 'callRingtoneId', + 'callRingbackTone', + 'callSoundOverrideGlobalNotifications', 'developerTools', 'settingsSyncEnabled', ] as const; @@ -138,6 +144,7 @@ describe('deserializeFromSync', () => { settings: { pageZoom: 200, isPeopleDrawer: false, + callRingtoneVolume: 20, settingsSyncEnabled: true, developerTools: true, }, @@ -146,12 +153,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..bd565356b 100644 --- a/src/app/utils/settingsSync.ts +++ b/src/app/utils/settingsSync.ts @@ -14,6 +14,13 @@ 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', // Developer / diagnostic 'developerTools', // Sync toggle itself must never be uploaded (it's device-local) diff --git a/src/sw.ts b/src/sw.ts index 78255b701..ac3b9720b 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') { @@ -623,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); @@ -665,7 +717,26 @@ 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)) { + const silentWavBytes = createSilentWavBytes(); + const silentWavBuffer = new Uint8Array(silentWavBytes).buffer; + event.respondWith( + Promise.resolve( + new Response(silentWavBuffer, { + status: 200, + headers: { + 'Content-Type': 'audio/wav', + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }) + ) + ); + return; + } + + if (!mediaPath(url)) return; const { clientId } = event; @@ -837,6 +908,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 +944,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 +1000,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/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..86a398300 --- /dev/null +++ b/src/sw/pushCallNotificationCopy.ts @@ -0,0 +1,112 @@ +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 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 d040d066e..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; @@ -15,8 +17,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 +37,37 @@ interface MatrixPushData { } const resolveSilent = (): boolean => false; +const MAX_CALL_NOTIFICATION_LIFETIME_MS = 120_000; + +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, + }; +}; export const createPushNotifications = ( self: ServiceWorkerGlobalScope, @@ -38,13 +79,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 +105,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 = normalizeCallIntent(undefined, 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({ + notificationType: 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) => { 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[], +};