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/package.json b/package.json index 452bf7f96..bec601762 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-wasm": "^6.2.2", - "@sableclient/sable-call-embedded": "1.1.4", + "@sableclient/sable-call-embedded": "1.1.5", "@sentry/vite-plugin": "^5.3.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -141,4 +141,4 @@ "jsdom>undici": "^7.28.0" } } -} \ No newline at end of file +} 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 c61cbe023..d696cd56b 100644 --- a/src/app/components/IncomingCallModal.tsx +++ b/src/app/components/IncomingCallModal.tsx @@ -1,77 +1,213 @@ import { + Avatar, Box, + Button, + color, Dialog, Header, IconButton, - 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 { composerIcon, menuIcon, Phone, userFallbackIcon, X } from '$components/icons/phosphor'; +import { UserAvatar } from './user-avatar'; +import { + composerIcon, + menuIcon, + Phone, + X, + Hash, + VideoCamera, + User, + sizedIcon, +} from '$components/icons/phosphor'; 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); + Sentry.metrics.count('sable.call.answered', 1, { + attributes: { + type: incomingCall.notificationType, + dm: String(incomingCall.isDirect), + intent: incomingCall.intentKind, + }, + }); + 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.declined', 1); + Sentry.metrics.count(`sable.call.${action}d`, 1, { + attributes: { + type: incomingCall.notificationType, + dm: String(incomingCall.isDirect), + }, + }); + 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 - + {composerIcon(X)}
- + - userFallbackIcon('lg')} - /> + {showCallerAvatar ? ( + sizedIcon(User, '200', { filled: true })} + /> + ) : ( + sizedIcon(Hash, '200', { filled: true })} + /> + )} - {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 ( }> @@ -146,13 +342,12 @@ export function IncomingCallModal() {
- +
diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx deleted file mode 100644 index cfb8df8a9..000000000 --- a/src/app/features/call/CallControls.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import type { MouseEventHandler } from 'react'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import type { RectCords } from 'folds'; -import { - Box, - Button, - config, - IconButton, - Menu, - MenuItem, - PopOut, - Spinner, - Text, - toRem, -} from 'folds'; -import { - DotsThreeOutlineVerticalIcon, - sizedIcon, - PhoneDisconnect, -} from '$components/icons/phosphor'; -import FocusTrap from 'focus-trap-react'; -import { SequenceCard } from '$components/sequence-card'; -import type { CallEmbed } from '$plugins/call'; -import { useCallControlState } from '$plugins/call'; -import { stopPropagation } from '$utils/keyboard'; -import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; -import { useRoom } from '$hooks/useRoom'; -import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; -import { useCallPreferences } from '$state/hooks/callPreferences'; -import * as css from './styles.css'; -import { - ChatButton, - ControlDivider, - MicrophoneButton, - ScreenShareButton, - SoundButton, - VideoButton, -} from './Controls'; - -type CallControlsProps = { - callEmbed: CallEmbed; -}; -export function CallControls({ callEmbed }: CallControlsProps) { - const room = useRoom(); - const controlRef = useRef(null); - - const screenSize = useScreenSizeContext(); - const compact = screenSize === ScreenSize.Mobile; - - const { microphone, video, sound, screenshare, spotlight } = useCallControlState( - callEmbed.control - ); - - const { setPreferences } = useCallPreferences(); - - useEffect(() => { - setPreferences({ microphone, video, sound }); - }, [microphone, video, sound, setPreferences]); - - const [cords, setCords] = useState(); - - const handleOpenMenu: MouseEventHandler = (evt) => { - setCords(evt.currentTarget.getBoundingClientRect()); - }; - - const handleSpotlightClick = () => { - callEmbed.control.toggleSpotlight(); - setCords(undefined); - }; - - const handleReactionsClick = () => { - callEmbed.control.toggleReactions(); - setCords(undefined); - }; - - const handleSettingsClick = () => { - callEmbed.control.toggleSettings(); - setCords(undefined); - }; - - const [hangupState, hangup] = useAsyncCallback( - useCallback(() => callEmbed.hangup(), [callEmbed]) - ); - const exiting = - hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success; - - return ( - - - - callEmbed.control.toggleMicrophone()} - /> - callEmbed.control.toggleSound()} /> - - - {!compact && } - - - callEmbed.control.toggleVideo()} /> - callEmbed.control.toggleScreenshare()} - /> - - {!compact && } - - {room?.isCallRoom() && } - - setCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - - - - - {spotlight ? 'Grid View' : 'Spotlight View'} - - - - - Reactions - - - - - Settings - - - - - - } - > - - {sizedIcon(DotsThreeOutlineVerticalIcon, '300', { filled: !!cords })} - - - - - - - - ); -} diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 861af9175..b89e1c7f2 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'; @@ -14,13 +11,20 @@ import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; 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 +32,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 +66,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 +117,22 @@ function CallPrescreen() { - {!inOtherCall && - (hasPermission ? ( - + {!callStartCapabilities.inAnotherCall && + (callStartCapabilities.hasCallMemberPermission ? ( + ) : ( ))} - {inOtherCall && } + {callStartCapabilities.inAnotherCall && } + {showEmbedError && ( + + )} @@ -116,34 +142,12 @@ function CallPrescreen() { type CallJoinedProps = { containerRef: RefObject; - joined: boolean; }; -function CallJoined({ joined, containerRef }: CallJoinedProps) { - const callEmbed = useCallEmbed(); - +function CallJoined({ containerRef }: CallJoinedProps) { return ( - - {callEmbed && joined && ( -
-
- -
-
- )}
); } @@ -156,6 +160,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 +176,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 +208,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 +225,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 ( } - + {resizable && ( + {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 27eba6d0b..190ad7bef 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -55,6 +55,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; @@ -896,6 +897,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 e45e32cd6..c95955518 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -57,10 +57,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'; @@ -614,6 +617,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(() => { @@ -639,11 +645,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; } @@ -867,7 +881,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..52a406ae9 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -11,6 +11,7 @@ import { import { CallWidgetDriver } from './CallWidgetDriver'; import { trimTrailingSlash } from '../../utils/common'; import type { ElementCallThemeKind, ElementMediaStateDetail } from './types'; +import { color, config } from 'folds'; import { ElementCallIntent, ElementWidgetActions } from './types'; import { CallControl } from './CallControl'; import { CallControlState } from './CallControlState'; @@ -18,6 +19,49 @@ import { createDebugLogger } from '../../utils/debugLogger'; const debugLog = createDebugLogger('CallEmbed'); +const resolveCssVar = (variable: string): string => { + const match = variable.match(/var\((--[^,)]+)/); + if (match && match[1]) { + const bodyVal = window.getComputedStyle(document.body).getPropertyValue(match[1]).trim(); + if (bodyVal) return bodyVal; + const docElVal = window + .getComputedStyle(document.documentElement) + .getPropertyValue(match[1]) + .trim(); + if (docElVal) return docElVal; + } + return variable; +}; + +const SABLE_THEME_TOKENS = { + light: { + '--sable-bg-container': '#ffffff', + '--sable-surface-var-container': '#e4e4e7', + '--sable-surface-var-container-hover': '#d4d4d8', + '--sable-surface-var-container-active': '#a1a1aa', + '--sable-surface-container': '#f4f4f5', + '--sable-surface-container-line': '#d4d4d8', + '--sable-surface-on-container': '#18181b', + }, + dark: { + '--sable-bg-container': '#1b1a21', + '--sable-surface-var-container': '#121116', + '--sable-surface-var-container-hover': '#1b1a21', + '--sable-surface-var-container-active': '#24232c', + '--sable-surface-container': '#24232c', + '--sable-surface-container-line': '#403f4c', + '--sable-surface-on-container': '#eae8f0', + }, +} as const; + +const resolveSableThemeToken = (variable: string, fallback: string): string => { + const isDark = + document.documentElement.className.includes('dark-theme') || + document.body.className.includes('dark-theme'); + const theme = isDark ? SABLE_THEME_TOKENS.dark : SABLE_THEME_TOKENS.light; + return theme[variable as keyof typeof theme] || fallback; +}; + export class CallEmbed { private mx: MatrixClient; @@ -40,22 +84,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 +143,38 @@ 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'); + params.append('waitForCallPickup', 'false'); + } + + 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 = { @@ -228,10 +320,8 @@ export class CallEmbed { this.readUpToMap[room.roomId] = roomEvent.getId()!; }); - // Attach listeners for feeding events - the underlying widget classes handle permissions for us. - // Bind once and store via disposables so the same function reference is used for removal. - // Using .bind(this) at call-site would create a new function every time, making .off() a no-op - // and causing MaxListeners warnings when the embed is recreated during sync retries. + // Bind handlers once and route removal through `disposables` so listeners can be + // cleanly torn down when the embed is recreated. const boundOnEvent = this.onEvent.bind(this); const boundOnEventDecrypted = this.onEventDecrypted.bind(this); const boundOnStateUpdate = this.onStateUpdate.bind(this); @@ -282,12 +372,208 @@ export class CallEmbed { if (!doc) return; doc.body.style.setProperty('background', 'none', 'important'); - const controls = doc.body.querySelector('[data-testid="incall_leave"]')?.parentElement - ?.parentElement; - if (controls) { - controls.style.setProperty('position', 'absolute'); - controls.style.setProperty('visibility', 'hidden'); - } + + // Copy stylesheets from parent just in case + const syncStyles = () => { + Array.from(document.styleSheets).forEach((sheet) => { + try { + if (!sheet.href || sheet.href.startsWith(window.location.origin)) { + const rules = Array.from(sheet.cssRules) + .map((r) => r.cssText) + .join('\\n'); + if (rules && !doc.head.innerHTML.includes(rules.substring(0, 50))) { + const styleEl = doc.createElement('style'); + styleEl.textContent = rules; + doc.head.append(styleEl); + } + } else if (sheet.href) { + const link = doc.createElement('link'); + link.rel = 'stylesheet'; + link.href = sheet.href; + doc.head.append(link); + } + } catch { + // Ignore CORS errors + } + }); + }; + syncStyles(); + + const updateInjectedCSS = () => { + const styleId = 'sable-call-embed-styles'; + let styleEl = doc.getElementById(styleId); + if (!styleEl) { + styleEl = doc.createElement('style'); + styleEl.id = styleId; + doc.head.append(styleEl); + } + + const appFontFamily = window.getComputedStyle(document.body).fontFamily; + + styleEl.textContent = ` + :root { + /* Backgrounds */ + --cpd-color-bg-canvas-default: ${resolveSableThemeToken('--sable-bg-container', resolveCssVar(color.Background.Container))} !important; + --cpd-color-bg-surface-default: ${resolveSableThemeToken('--sable-surface-var-container', resolveCssVar(color.SurfaceVariant.Container))} !important; + + /* Soft Fills for normal buttons */ + --cpd-color-bg-subtle-primary: ${resolveSableThemeToken('--sable-surface-var-container', resolveCssVar(color.SurfaceVariant.Container))} !important; + --cpd-color-bg-subtle-secondary: ${resolveSableThemeToken('--sable-surface-var-container', resolveCssVar(color.SurfaceVariant.Container))} !important; + --cpd-color-bg-action-secondary-rest: ${resolveSableThemeToken('--sable-surface-var-container', resolveCssVar(color.SurfaceVariant.Container))} !important; + --cpd-color-bg-action-secondary-hovered: ${resolveSableThemeToken('--sable-surface-var-container-hover', resolveCssVar(color.SurfaceVariant.ContainerHover))} !important; + --cpd-color-bg-action-secondary-pressed: ${resolveSableThemeToken('--sable-surface-var-container-active', resolveCssVar(color.SurfaceVariant.ContainerActive))} !important; + + /* Soft Fills for primary/active buttons */ + --cpd-color-bg-action-primary-rest: ${resolveCssVar(color.Primary.Container)} !important; + --cpd-color-bg-action-primary-hovered: ${resolveCssVar(color.Primary.ContainerHover)} !important; + --cpd-color-bg-action-primary-pressed: ${resolveCssVar(color.Primary.ContainerActive)} !important; + + /* Soft Fills for critical buttons (Hangup) */ + --cpd-color-bg-critical-primary: ${resolveCssVar(color.Critical.Container)} !important; + + /* Borders */ + --cpd-color-border-interactive-primary: ${resolveCssVar(color.Primary.Main)} !important; + --cpd-color-border-interactive-secondary: ${resolveCssVar(color.Surface.ContainerLine)} !important; + --cpd-color-border-focused: ${resolveCssVar(color.Primary.Main)} !important; + + /* Typography and Icons */ + --cpd-font-family-sans: "Nunito Variable", sans-serif !important; + --cpd-color-text-primary: ${resolveCssVar(color.Background.OnContainer)} !important; + --cpd-color-text-secondary: ${resolveCssVar(color.Surface.OnContainer)} !important; + --cpd-color-icon-primary: ${resolveCssVar(color.Background.OnContainer)} !important; + --cpd-color-icon-secondary: ${resolveCssVar(color.Surface.OnContainer)} !important; + --cpd-color-icon-tertiary: ${resolveCssVar(color.SurfaceVariant.OnContainer)} !important; + + /* Icons/Text on Soft Fill Backgrounds */ + --cpd-color-icon-on-solid-primary: ${resolveCssVar(color.Primary.OnContainer)} !important; + --cpd-color-text-on-solid-primary: ${resolveCssVar(color.Primary.OnContainer)} !important; + --cpd-color-icon-critical-primary: ${resolveCssVar(color.Critical.OnContainer)} !important; + --cpd-color-text-critical-primary: ${resolveCssVar(color.Critical.OnContainer)} !important; + } + + /* Enforce rounded rectangles instead of circles */ + [class*="button_"], [class*="Button_"], button { + border-radius: ${resolveCssVar(config.radii.R400)} !important; + } + + /* Completely dismantle Element Call's grouping pills to match Sable's discrete buttons */ + [data-testid="footer-container"] [class*="_container_"] { + background-color: transparent !important; + border: none !important; + gap: ${resolveCssVar(config.space.S100)} !important; + } + + /* Ensure primary/muted buttons maintain a solid border by applying to both the element and its background overlays */ + [class*="button_"][data-kind="primary"], [class*="Button_"][data-kind="primary"], button[data-kind="primary"], + [class*="button_"][data-kind="primary"]::before, [class*="Button_"][data-kind="primary"]::before, button[data-kind="primary"]::before, + [class*="button_"][data-kind="primary"]::after, [class*="Button_"][data-kind="primary"]::after, button[data-kind="primary"]::after { + border: 1px solid ${resolveCssVar(color.Primary.ContainerLine)} !important; + box-sizing: border-box !important; + } + [class*="button_"][data-kind="primary"][class*="_destructive_"], + [class*="button_"][data-kind="primary"][class*="_destructive_"]::before, + [class*="button_"][data-kind="primary"][class*="_destructive_"]::after { + border: 1px solid ${resolveCssVar(color.Critical.ContainerLine)} !important; + box-sizing: border-box !important; + } + + /* Fix secondary buttons inside the footer to have Sable's exact container styling */ + [data-testid="footer-container"] button[data-kind="secondary"], + [data-testid="footer-container"] button[aria-haspopup="menu"] { + background-color: ${resolveSableThemeToken('--sable-surface-var-container', resolveCssVar(color.SurfaceVariant.Container))} !important; + border: 1px solid ${resolveSableThemeToken('--sable-surface-var-container-line', resolveCssVar(color.Surface.ContainerLine))} !important; + color: var(--cpd-color-icon-secondary) !important; + } + [data-testid="footer-container"] button[data-kind="secondary"]:hover, + [data-testid="footer-container"] button[aria-haspopup="menu"]:hover { + background-color: ${resolveSableThemeToken('--sable-surface-var-container-hover', resolveCssVar(color.SurfaceVariant.ContainerHover))} !important; + } + + [class*="button_"]::before, [class*="Button_"]::before, button::before, + [class*="button_"]::after, [class*="Button_"]::after, button::after, + [data-testid="footer-container"] [class*="_container_"]::before, + [data-testid="footer-container"] [class*="_container_"]::after { + border-radius: inherit !important; + } + + .tile { + border-radius: ${resolveCssVar(config.radii.R500)} !important; + } + + .tile.speaking::before { + background: ${resolveCssVar(color.Primary.Main)} !important; + opacity: 0.8 !important; + } + + /* Settings 3-dots button overrides */ + [data-testid="settings-bottom-left"] { + --cpd-icon-button-size: 48px !important; + background-color: ${resolveSableThemeToken('--sable-surface-var-container', resolveCssVar(color.SurfaceVariant.Container))} !important; + border-radius: ${resolveCssVar(config.radii.R400)} !important; + } + + + + /* Slider overrides */ + [role="slider"], [class*="handle"] { + background-color: ${resolveCssVar(color.Primary.Main)} !important; + box-shadow: 0 0 0 2px ${resolveCssVar(color.Surface.Container)} !important; + } + [class*="highlight"] { + background-color: ${resolveCssVar(color.Primary.Main)} !important; + } + [class*="track"] { + background-color: ${resolveCssVar(color.SurfaceVariant.ContainerHover)} !important; + outline: none !important; + } + + /* Tooltips and Menus */ + [role="tooltip"], .cpd-tooltip, [data-radix-popper-content-wrapper] > div, div[class*="_tooltip_"] { + background-color: ${resolveCssVar(color.Surface.Container)} !important; + color: ${resolveCssVar(color.Surface.OnContainer)} !important; + border: 1px solid ${resolveCssVar(color.Surface.ContainerLine)} !important; + border-radius: ${resolveCssVar(config.radii.R400)} !important; + padding: ${resolveCssVar(config.space.S200)} ${resolveCssVar(config.space.S300)} !important; + font-size: ${resolveCssVar(config.fontSize.B300)} !important; + box-shadow: 0 4px 6px ${resolveCssVar(color.Other.Shadow)} !important; + } + + /* Ensure tooltip text inside wrapper inherits correctly */ + [role="tooltip"] *, .cpd-tooltip *, [data-radix-popper-content-wrapper] * { + color: inherit !important; + } + /* Use parent app's font for emojis/reactions */ + [class*="reaction" i], [class*="emoji" i], [class*="reaction" i] * { + font-family: ${appFontFamily} !important; + } + `; + }; + + // Sync theme classes from parent html/body + const syncThemeClasses = () => { + doc.documentElement.className = document.documentElement.className; + doc.body.className = document.body.className; + + const theme = document.documentElement.getAttribute('data-theme'); + if (theme) doc.documentElement.setAttribute('data-theme', theme); + + // Re-evaluate vars and update CSS on theme change + updateInjectedCSS(); + }; + + // Initial injection + syncThemeClasses(); + + const observer = new MutationObserver(syncThemeClasses); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class', 'data-theme', 'style'], + }); + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class', 'data-theme', 'style'], + }); + this.disposables.push(() => observer.disconnect()); } private onEvent(ev: MatrixEvent): void { @@ -307,6 +593,16 @@ export class CallEmbed { }); } + private feedStateUpdateForTimelineEvent(ev: MatrixEvent): void { + if (this.call === null) return; + if (!ev.isState()) return; + const raw = ev.getEffectiveEvent() as IRoomEvent | undefined; + if (raw === undefined) return; + this.call.feedStateUpdate(raw).catch((e) => { + console.error('Error sending state update to widget: ', e); + }); + } + private async onToDeviceEvent(ev: MatrixEvent): Promise { await this.mx.decryptEventIfNeeded(ev); if (ev.isDecryptionFailure()) return; @@ -364,7 +660,7 @@ export class CallEmbed { return true; } - // We can't say for sure whether the widget has seen the event; let's + // We can't say for sure whether the widget has seen the event // just assume that it has return false; } @@ -411,7 +707,10 @@ export class CallEmbed { this.call.feedEvent(raw as IRoomEvent).catch((e) => { console.error('Error sending event to widget: ', e); }); + this.feedStateUpdateForTimelineEvent(ev); } + } else if (ev.isState()) { + this.feedStateUpdateForTimelineEvent(ev); } } 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..ef1e35370 --- /dev/null +++ b/src/app/plugins/call/elementCallDomAdapter.ts @@ -0,0 +1,108 @@ +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 => { + return queryFirst(doc, { + key: 'settings_button', + selectors: [ + '[data-testid="settings-bottom-left"]', + '[data-testid="settings-bottom-center"]', + 'button[aria-label*="Settings" i]', + ], + }); +}; + +export const getReactionsButton = (doc: Document | undefined): HTMLElement | undefined => { + 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 74b6aebf9..2b798084d 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', () => { @@ -65,6 +113,27 @@ 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({}); + }); + it('accepts icon base size px values from 0 upward', () => { expect( sanitizeSettingsDefaults({ diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 0ec9d3bb2..ffd58ff2a 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -36,6 +36,14 @@ export type PerRoomShowRoomIcon = { }; 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; @@ -190,6 +198,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; @@ -343,6 +357,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, @@ -394,6 +414,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; @@ -431,6 +457,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( @@ -546,6 +603,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[], +};