From d9aea41b16c76968bfb49be1bbce51acefdd2154 Mon Sep 17 00:00:00 2001 From: Frank Klaassen <639906+syphernl@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:24:18 +0200 Subject: [PATCH 1/2] Extract IncomingCallToast ring/lifecycle logic into useIncomingCallToast hook No behavioural change: pulls the ring/timeout/auto-dismiss lifecycle and join/decline actions out of IncomingCallToast into a reusable hook, so a future prominent (full-screen) incoming-call surface can share the same logic instead of duplicating it. Signed-off-by: Frank Klaassen <639906+syphernl@users.noreply.github.com> --- apps/web/src/hooks/useIncomingCallToast.tsx | 247 ++++++++++++++++++ apps/web/src/toasts/IncomingCallToast.tsx | 272 +++----------------- 2 files changed, 281 insertions(+), 238 deletions(-) create mode 100644 apps/web/src/hooks/useIncomingCallToast.tsx diff --git a/apps/web/src/hooks/useIncomingCallToast.tsx b/apps/web/src/hooks/useIncomingCallToast.tsx new file mode 100644 index 00000000000..f33dc920237 --- /dev/null +++ b/apps/web/src/hooks/useIncomingCallToast.tsx @@ -0,0 +1,247 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { useCallback, useContext, useEffect, useRef, useState } from "react"; +import { + type Room, + type MatrixEvent, + type RoomMember, + RoomEvent, + EventType, + MatrixEventEvent, +} from "matrix-js-sdk/src/matrix"; +import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; + +import { SDKContext } from "../contexts/SDKContext"; +import defaultDispatcher from "../dispatcher/dispatcher"; +import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; +import { Action } from "../dispatcher/actions"; +import ToastStore from "../stores/ToastStore"; +import { useCall, useParticipatingMembers } from "./useCall"; +import { type ButtonEvent } from "../components/views/elements/AccessibleButton"; +import { type Call, CallEvent } from "../models/Call"; +import { AudioID } from "../LegacyCallHandler"; +import { useEventEmitter, useTypedEventEmitter } from "./useEventEmitter"; +import { CallStore, CallStoreEvent } from "../stores/CallStore"; +import DMRoomMap from "../utils/DMRoomMap"; +import { useDispatcher } from "./useDispatcher"; +import { type ActionPayload } from "../dispatcher/payloads"; + +/** + * Get the ts when the notification event was sent (see {@link IncomingCallToast}). + */ +export const getNotificationEventSendTs = (event: MatrixEvent): number => { + const content = event.getContent() as Partial; + const sendTs = content.sender_ts; + if (sendTs && Math.abs(sendTs - event.getTs()) >= 15000) { + return event.getTs(); + } + return sendTs ?? event.getTs(); +}; + +const MAX_RING_TIME_MS = 90 * 1000; + +export interface IncomingCallToastState { + /** The room the call is in, if known. */ + room?: Room; + /** Whether this is an audio-only call. */ + isVoice: boolean; + /** Whether the notification is a "ring" (1:1/explicit) rather than a group "notify". */ + isRing: boolean; + /** The 1:1 counterparty's user id, if this is a DM call. */ + otherUserId?: string; + /** Members currently participating in the call. */ + members: RoomMember[]; + /** True if the user is already connected to a different call. */ + otherCallIsOngoing: boolean; + /** Join-with-video toggle state (video calls only). */ + videoToggle: boolean; + setVideoToggle: (value: boolean) => void; + /** Join the call (skipping the lobby). */ + onJoin: (e?: ButtonEvent) => void; + /** Open the call in the room (showing the lobby). */ + onExpand: (e?: ButtonEvent) => void; + /** Decline the incoming call and dismiss. */ + onDecline: (e: ButtonEvent) => Promise; + /** Dismiss the toast/popup and stop ringing. */ + dismiss: () => void; +} + +/** + * All lifecycle and actions for an incoming Element Call (MatrixRTC) notification: + * ringing, the ring timeout, auto-dismissal (remote decline, answered elsewhere, + * session ended, redaction, viewing the call), and the join/decline actions. + * + * Shared between the compact {@link IncomingCallToast} and the prominent + * full-screen {@link IncomingCallView} so both surfaces behave identically. + */ +export function useIncomingCallToast(notificationEvent: MatrixEvent, toastKey: string): IncomingCallToastState { + const sdkContext = useContext(SDKContext); + const legacyCallHandler = sdkContext.legacyCallHandler; + const roomId = notificationEvent.getRoomId()!; + const notificationContent = notificationEvent.getContent() as Partial; + const room = sdkContext.client?.getRoom(roomId) ?? undefined; + const call = useCall(roomId); + + const otherRoomHasCall = (): boolean => + Array.from(CallStore.instance.connectedCalls).some((c) => c.roomId !== roomId); + const [otherCallIsOngoing, setOtherCallIsOngoing] = useState(otherRoomHasCall); + useEventEmitter(CallStore.instance, CallStoreEvent.ConnectedCalls, () => { + setOtherCallIsOngoing(otherRoomHasCall()); + }); + + const isRing = notificationContent.notification_type === "ring"; + + // Start ringing for a "ring" notification, and stop on unmount as a backstop + // for surfaces that disappear without going through dismiss() (e.g. the toast + // being filtered out when the full-screen setting toggles, or a store reset). + const soundHasStarted = useRef(false); + useEffect(() => { + if (isRing && !soundHasStarted.current && !legacyCallHandler.isPlaying(AudioID.Ring)) { + soundHasStarted.current = true; + void legacyCallHandler.play(AudioID.Ring); + } + return () => { + if (soundHasStarted.current) legacyCallHandler.pause(AudioID.Ring); + }; + }, [isRing, legacyCallHandler]); + + const dismiss = useCallback((): void => { + ToastStore.sharedInstance().dismissToast(toastKey); + legacyCallHandler.pause(AudioID.Ring); + }, [toastKey, legacyCallHandler]); + + // Dismiss if the notification/call event is redacted. + // NOTE: this condition is effectively always-true (pre-existing behaviour + // carried over from IncomingCallToast): the array is built from `ev` and then + // tested against `ev.getId()`. It dismisses on any redaction in the room. Left + // as-is to preserve behaviour and existing tests; tracked for a separate fix. + useTypedEventEmitter(room, MatrixEventEvent.BeforeRedaction, (ev: MatrixEvent) => { + if ([ev.getId(), ev.getRelation()?.event_id].includes(ev.getId())) { + dismiss(); + } + }); + + // Dismiss if the session ended remotely. + const onCall = useCallback( + (c: Call, callRoomId: string): void => { + if (callRoomId !== notificationEvent.getRoomId()) return; + if (c === null || c.participants.size === 0) { + dismiss(); + } + }, + [dismiss, notificationEvent], + ); + + // Dismiss if we declined from this device (our own RTCDecline). + const onTimelineChange = useCallback( + (ev: MatrixEvent) => { + const userId = room?.client.getUserId(); + if ( + ev.getType() === EventType.RTCDecline && + userId !== undefined && + ev.getSender() === userId && + ev.relationEventId === notificationEvent.getId() + ) { + dismiss(); + } + }, + [dismiss, notificationEvent, room?.client], + ); + + // Dismiss if another of our devices joins. + const onParticipantChange = useCallback( + (participants: Map>) => { + if (Array.from(participants.keys()).some((p) => p.userId == room?.client.getUserId())) { + dismiss(); + } + }, + [dismiss, room?.client], + ); + + // Dismiss on timeout. + useEffect(() => { + const lifetime = notificationContent.lifetime ?? MAX_RING_TIME_MS; + const timeout = setTimeout(dismiss, getNotificationEventSendTs(notificationEvent) + lifetime - Date.now()); + return () => clearTimeout(timeout); + }, [dismiss, notificationEvent, notificationContent.lifetime]); + + const [videoToggle, setVideoToggle] = useState(true); + const isVoice = notificationContent["m.call.intent"] === "audio"; + + const viewCall = useCallback( + (skipLobby: boolean) => { + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room?.roomId, + view_call: true, + skipLobby, + voiceOnly: isVoice || !videoToggle, + metricsTrigger: undefined, + }); + }, + [room, isVoice, videoToggle], + ); + + // Dismiss (stop the ring, remove the toast) up front so accepting is instant + // and reliable even if the ViewRoom dispatch below no-ops (e.g. the room isn't + // loaded yet); the dispatcher watcher would otherwise be the only dismiss path. + const onJoin = useCallback(() => { + dismiss(); + viewCall(true); + }, [dismiss, viewCall]); + const onExpand = useCallback(() => viewCall(false), [viewCall]); + + // Dismiss once the call is being viewed. + useDispatcher( + defaultDispatcher, + useCallback( + (payload: ActionPayload) => { + if (payload.action === Action.ViewRoom && payload.room_id === roomId && payload.view_call) { + dismiss(); + } + }, + [roomId, dismiss], + ), + ); + + // Guard against a double-click sending two RTCDecline events for one ring: the + // toast stays interactive until the async send resolves. + const declining = useRef(false); + const onDecline = useCallback( + async (e: ButtonEvent): Promise => { + e.stopPropagation(); + if (declining.current) return; + declining.current = true; + await room?.client.sendRtcDecline(room.roomId, notificationEvent.getId() ?? ""); + dismiss(); + }, + [room, notificationEvent, dismiss], + ); + + useEventEmitter(CallStore.instance, CallStoreEvent.Call, onCall); + useEventEmitter(call ?? undefined, CallEvent.Participants, onParticipantChange); + useEventEmitter(room, RoomEvent.Timeline, onTimelineChange); + + const otherUserId = DMRoomMap.shared().getUserIdForRoomId(roomId) ?? undefined; + const members = useParticipatingMembers(call); + + return { + room, + isVoice, + isRing, + otherUserId, + members, + otherCallIsOngoing, + videoToggle, + setVideoToggle, + onJoin, + onExpand, + onDecline, + dismiss, + }; +} diff --git a/apps/web/src/toasts/IncomingCallToast.tsx b/apps/web/src/toasts/IncomingCallToast.tsx index 14f253c5224..36d063db94e 100644 --- a/apps/web/src/toasts/IncomingCallToast.tsx +++ b/apps/web/src/toasts/IncomingCallToast.tsx @@ -6,29 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { - type JSX, - type ReactNode, - type ComponentType, - type SVGAttributes, - useCallback, - useEffect, - useRef, - useState, - useId, - useContext, -} from "react"; -import { - type Room, - type MatrixEvent, - type RoomMember, - RoomEvent, - EventType, - MatrixEventEvent, -} from "matrix-js-sdk/src/matrix"; +import React, { type JSX, type ReactNode, type ComponentType, type SVGAttributes, useId } from "react"; import { AvatarStack, Button, Form, Heading, InlineField, Label, ToggleInput, Tooltip } from "@vector-im/compound-web"; -import { logger } from "matrix-js-sdk/src/logger"; -import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { CheckIcon, CloseIcon, @@ -40,51 +20,18 @@ import { AvatarWithDetails } from "@element-hq/web-shared-components"; import { _t } from "../languageHandler"; import RoomAvatar from "../components/views/avatars/RoomAvatar"; -import defaultDispatcher from "../dispatcher/dispatcher"; -import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; -import { Action } from "../dispatcher/actions"; -import ToastStore from "../stores/ToastStore"; -import { useCall, useParticipatingMembers } from "../hooks/useCall"; import AccessibleButton, { type ButtonEvent } from "../components/views/elements/AccessibleButton"; -import { useDispatcher } from "../hooks/useDispatcher"; -import { type ActionPayload } from "../dispatcher/payloads"; -import { type Call, CallEvent } from "../models/Call"; -import { AudioID } from "../LegacyCallHandler"; -import { useEventEmitter, useTypedEventEmitter } from "../hooks/useEventEmitter"; -import { CallStore, CallStoreEvent } from "../stores/CallStore"; -import DMRoomMap from "../utils/DMRoomMap"; +import { useIncomingCallToast } from "../hooks/useIncomingCallToast"; import MemberAvatar from "../components/views/avatars/MemberAvatar"; -import { SDKContext } from "../contexts/SDKContext.ts"; + +// Re-exported for backwards compatibility with existing importers. +export { getNotificationEventSendTs } from "../hooks/useIncomingCallToast"; /** * Get the key for the incoming call toast. A combination of the call ID and room ID. - * @param callId The ID of the call. - * @param roomId The ID of the room. - * @returns The key for the incoming call toast. */ export const getIncomingCallToastKey = (callId: string, roomId: string): string => `call_${callId}_${roomId}`; -/** - * Get the ts when the notification event was sent. - * This can be either the origin_server_ts or a ts the sender of this event claims as - * the time they sent it (sender_ts). - * The origin_server_ts is the fallback if sender_ts seems wrong. - * @param event The RTCNotification event. - * @returns The timestamp to use as the expect start time to apply the `lifetime` to. - */ -export const getNotificationEventSendTs = (event: MatrixEvent): number => { - const content = event.getContent() as Partial; - const sendTs = content.sender_ts; - if (sendTs && Math.abs(sendTs - event.getTs()) >= 15000) { - logger.warn( - "Received RTCNotification event. With large sender_ts origin_server_ts offset -> using origin_server_ts", - ); - return event.getTs(); - } - return sendTs ?? event.getTs(); -}; -const MAX_RING_TIME_MS = 90 * 1000; - interface JoinCallButtonWithCallProps { onClick: (e: ButtonEvent) => void; disabledTooltip: string | undefined; @@ -104,46 +51,7 @@ function JoinCallButtonWithCall({ onClick, disabledTooltip }: JoinCallButtonWith ); - return disabledTooltip === undefined ? ( - button - ) : ( - {button} - ); -} - -interface DeclineCallButtonWithNotificationEventProps { - onDeclined: (e: ButtonEvent) => void; - notificationEvent: MatrixEvent; - room?: Room; -} - -function DeclineCallButtonWithNotificationEvent({ - notificationEvent, - room, - onDeclined, -}: DeclineCallButtonWithNotificationEventProps): JSX.Element { - const [declining, setDeclining] = useState(false); - const onClick = useCallback( - async (e: ButtonEvent) => { - e.stopPropagation(); - setDeclining(true); - await room?.client.sendRtcDecline(room.roomId, notificationEvent.getId() ?? ""); - onDeclined(e); - }, - [notificationEvent, onDeclined, room?.client, room?.roomId], - ); - return ( - - ); + return disabledTooltip === undefined ? button : {button}; } interface Props { @@ -159,139 +67,21 @@ interface Props { } export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.Element { - const sdkContext = useContext(SDKContext); - const roomId = notificationEvent.getRoomId()!; - // Use a partial type so ts still helps us to not miss any type checks. - const notificationContent = notificationEvent.getContent() as Partial; - const room = sdkContext.client?.getRoom(roomId) ?? undefined; - const call = useCall(roomId); - const [connectedCalls, setConnectedCalls] = useState(Array.from(CallStore.instance.connectedCalls)); - useEventEmitter(CallStore.instance, CallStoreEvent.ConnectedCalls, () => { - setConnectedCalls(Array.from(CallStore.instance.connectedCalls)); - }); - const otherCallIsOngoing = connectedCalls.find((call) => call.roomId !== roomId); - const soundHasStarted = useRef(false); - useEffect(() => { - // This section can race, so we use a ref to keep track of whether we have started trying to play. - // This is because `LegacyCallHandler.play` tries to load the sound and then play it asynchonously - // and `LegacyCallHandler.isPlaying` will not be `true` until the sound starts playing. - const isRingToast = notificationContent.notification_type === "ring"; - if (isRingToast && !soundHasStarted.current && !sdkContext.legacyCallHandler.isPlaying(AudioID.Ring)) { - // Start ringing if not already. - soundHasStarted.current = true; - void sdkContext.legacyCallHandler.play(AudioID.Ring); - } - }, [notificationContent.notification_type, soundHasStarted, sdkContext.legacyCallHandler]); - - // Stop ringing on dismiss. - const dismissToast = useCallback((): void => { - ToastStore.sharedInstance().dismissToast(toastKey); - sdkContext.legacyCallHandler.pause(AudioID.Ring); - }, [toastKey, sdkContext.legacyCallHandler]); - - // Dismiss if the notification event or call event is redacted - useTypedEventEmitter(room, MatrixEventEvent.BeforeRedaction, (ev: MatrixEvent) => { - if ([ev.getId(), ev.getRelation()?.event_id].includes(ev.getId())) { - dismissToast(); - } - }); - - // Dismiss if session got ended remotely. - const onCall = useCallback( - (call: Call, callRoomId: string): void => { - const roomId = notificationEvent.getRoomId(); - if (!roomId && roomId !== callRoomId) return; - if (call === null || call.participants.size === 0) { - dismissToast(); - } - }, - [dismissToast, notificationEvent], - ); - - // Dismiss if session got declined remotely. - const onTimelineChange = useCallback( - (ev: MatrixEvent) => { - const userId = room?.client.getUserId(); - if ( - ev.getType() === EventType.RTCDecline && - userId !== undefined && - ev.getSender() === userId && // It is our decline not someone elses - ev.relationEventId === notificationEvent.getId() // The event declines this ringing toast. - ) { - dismissToast(); - } - }, - [dismissToast, notificationEvent, room?.client], - ); - - // Dismiss if another device from this user joins. - const onParticipantChange = useCallback( - (participants: Map>) => { - if (Array.from(participants.keys()).some((p) => p.userId == room?.client.getUserId())) { - dismissToast(); - } - }, - [dismissToast, room?.client], - ); - - // Dismiss on timeout. - useEffect(() => { - const lifetime = notificationContent.lifetime ?? MAX_RING_TIME_MS; - const timeout = setTimeout(dismissToast, getNotificationEventSendTs(notificationEvent) + lifetime - Date.now()); - return () => clearTimeout(timeout); - }); - - // Dismiss on viewing call. - useDispatcher( - defaultDispatcher, - useCallback( - (payload: ActionPayload) => { - if (payload.action === Action.ViewRoom && payload.room_id === roomId && payload.view_call) { - dismissToast(); - } - }, - [roomId, dismissToast], - ), - ); - - const [videoToggle, setVideoToggle] = useState(true); + const { + room, + isVoice, + isRing, + otherUserId, + members, + otherCallIsOngoing, + videoToggle, + setVideoToggle, + onJoin, + onExpand, + onDecline, + } = useIncomingCallToast(notificationEvent, toastKey); const videoToggleId = useId(); - const isVoice = notificationContent["m.call.intent"] === "audio"; - - const viewCall = useCallback( - (skipLobby: boolean) => { - // The toast will be automatically dismissed by the dispatcher callback above - defaultDispatcher.dispatch({ - action: Action.ViewRoom, - room_id: room?.roomId, - view_call: true, - skipLobby, - voiceOnly: isVoice || !videoToggle, - metricsTrigger: undefined, - }); - }, - [room, isVoice, videoToggle], - ); - - const onJoinClick = useCallback(() => viewCall(true), [viewCall]); - const onExpandClick = useCallback(() => viewCall(false), [viewCall]); - - // Dismiss on closing toast. - const onCloseClick = useCallback( - (e: ButtonEvent): void => { - e.stopPropagation(); - dismissToast(); - }, - [dismissToast], - ); - - useEventEmitter(CallStore.instance, CallStoreEvent.Call, onCall); - useEventEmitter(call ?? undefined, CallEvent.Participants, onParticipantChange); - useEventEmitter(room, RoomEvent.Timeline, onTimelineChange); - - const otherUserId = DMRoomMap.shared().getUserIdForRoomId(roomId); - const members = useParticipatingMembers(call); const avatars = (): ReactNode => ( {members.slice(0, 3).map((m) => ( @@ -301,7 +91,7 @@ export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.E ); let detailsInformation: ReactNode; - if (notificationContent.notification_type === "ring") { + if (isRing) { detailsInformation = {otherUserId}; } else if (members.length > 0) { detailsInformation = @@ -338,7 +128,7 @@ export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.E @@ -372,13 +162,19 @@ export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.E )}
- +
From 0646bd1996af5b96aa1efdad8a9a97e0ef08b1f6 Mon Sep 17 00:00:00 2001 From: Frank Klaassen <639906+syphernl@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:14:24 +0200 Subject: [PATCH 2/2] Add standalone test suite for useIncomingCallToast, drop unneeded TSX Addresses review feedback on #34199: the extracted hook had no test coverage of its own (only exercised indirectly via IncomingCallToast), and did not need JSX so .tsx was unnecessary. Signed-off-by: Frank Klaassen <639906+syphernl@users.noreply.github.com> --- ...gCallToast.tsx => useIncomingCallToast.ts} | 0 .../hooks/useIncomingCallToast-test.tsx | 338 ++++++++++++++++++ 2 files changed, 338 insertions(+) rename apps/web/src/hooks/{useIncomingCallToast.tsx => useIncomingCallToast.ts} (100%) create mode 100644 apps/web/test/unit-tests/hooks/useIncomingCallToast-test.tsx diff --git a/apps/web/src/hooks/useIncomingCallToast.tsx b/apps/web/src/hooks/useIncomingCallToast.ts similarity index 100% rename from apps/web/src/hooks/useIncomingCallToast.tsx rename to apps/web/src/hooks/useIncomingCallToast.ts diff --git a/apps/web/test/unit-tests/hooks/useIncomingCallToast-test.tsx b/apps/web/test/unit-tests/hooks/useIncomingCallToast-test.tsx new file mode 100644 index 00000000000..a5b8e97259a --- /dev/null +++ b/apps/web/test/unit-tests/hooks/useIncomingCallToast-test.tsx @@ -0,0 +1,338 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { act, renderHook, waitFor } from "jest-matrix-react"; +import { mocked, type Mocked } from "jest-mock"; +import { + Room, + RoomStateEvent, + type MatrixEvent, + MatrixEventEvent, + type MatrixClient, + EventType, + RoomEvent, + type IRoomTimelineData, + type ISendEventResponse, + type IContent, +} from "matrix-js-sdk/src/matrix"; +import { Widget } from "matrix-widget-api"; +import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; +import { randomUUID } from "node:crypto"; + +import { + useMockedCalls, + MockedCall, + stubClient, + mkRoomMember, + setupAsyncStoreWithClient, + resetAsyncStoreWithClient, + mkEvent, + clientAndSDKContextRenderOptions, +} from "../../test-utils"; +import defaultDispatcher from "../../../src/dispatcher/dispatcher"; +import { Action } from "../../../src/dispatcher/actions"; +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import { CallStore } from "../../../src/stores/CallStore"; +import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore"; +import DMRoomMap from "../../../src/utils/DMRoomMap"; +import ToastStore from "../../../src/stores/ToastStore"; +import { getNotificationEventSendTs, useIncomingCallToast } from "../../../src/hooks/useIncomingCallToast"; +import { AudioID } from "../../../src/LegacyCallHandler"; +import { CallEvent } from "../../../src/models/Call"; +import { type WidgetMessaging } from "../../../src/stores/widgets/WidgetMessaging"; +import { TestSDKContext } from "../TestSDKContext.ts"; + +function makeNotificationEvent(room: Room, content: IContent = {}): MatrixEvent { + const ts = Date.now(); + const notificationContent = { + "notification_type": "notification", + "m.relation": { rel_type: "m.reference", event_id: "$memberEventId" }, + "m.mentions": { user_ids: [], room: true }, + "lifetime": 3000, + "sender_ts": ts, + ...content, + } as unknown as IRTCNotificationContent; + return mkEvent({ + type: EventType.RTCNotification, + user: "@userId:matrix.org", + content: notificationContent, + room: room.roomId, + ts, + id: "$notificationEventId", + event: true, + }); +} + +describe("useIncomingCallToast", () => { + useMockedCalls(); + + let client: Mocked; + let sdkContext: TestSDKContext; + let room: Room; + + let call: MockedCall; + let widget: Widget; + const dmRoomMap = { + getUserIdForRoomId: jest.fn(), + } as unknown as DMRoomMap; + const toastStore = { + dismissToast: jest.fn(), + } as unknown as Mocked; + + beforeEach(async () => { + stubClient(); + client = mocked(MatrixClientPeg.safeGet()); + sdkContext = new TestSDKContext(); + sdkContext._client = client; + + const audio = document.createElement("audio"); + audio.id = AudioID.Ring; + document.body.appendChild(audio); + + room = new Room("!1:example.org", client, "@alice:example.org"); + client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null)); + client.getRooms.mockReturnValue([room]); + client.reEmitter.reEmit(room, [RoomStateEvent.Events]); + MockedCall.create(room, "1"); + + await Promise.all( + [CallStore.instance, WidgetMessagingStore.instance].map((store) => + setupAsyncStoreWithClient(store, client), + ), + ); + + const maybeCall = CallStore.instance.getCall(room.roomId); + if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call"); + call = maybeCall; + + widget = new Widget(call.widget); + WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { + stop: () => {}, + } as unknown as WidgetMessaging); + + jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); + jest.spyOn(ToastStore, "sharedInstance").mockReturnValue(toastStore); + toastStore.dismissToast.mockReset(); + }); + + afterEach(async () => { + call.destroy(); + WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); + await Promise.all([CallStore.instance, WidgetMessagingStore.instance].map(resetAsyncStoreWithClient)); + jest.restoreAllMocks(); + }); + + const renderHookForToast = (notificationEvent: MatrixEvent = makeNotificationEvent(room)) => { + const toastKey = randomUUID(); + return renderHook( + () => useIncomingCallToast(notificationEvent, toastKey), + clientAndSDKContextRenderOptions(client, sdkContext), + ); + }; + + it("starts ringing for a ring notification", () => { + const notificationEvent = makeNotificationEvent(room, { notification_type: "ring" }); + const playMock = jest.spyOn(sdkContext.legacyCallHandler, "play"); + renderHookForToast(notificationEvent); + expect(playMock).toHaveBeenCalledWith(AudioID.Ring); + }); + + it("does not ring for a plain notification", () => { + const playMock = jest.spyOn(sdkContext.legacyCallHandler, "play"); + renderHookForToast(); + expect(playMock).not.toHaveBeenCalled(); + }); + + it("stops ringing on unmount", () => { + const pauseMock = jest.spyOn(sdkContext.legacyCallHandler, "pause"); + const notificationEvent = makeNotificationEvent(room, { notification_type: "ring" }); + const { unmount } = renderHookForToast(notificationEvent); + unmount(); + expect(pauseMock).toHaveBeenCalledWith(AudioID.Ring); + }); + + it("dismisses and dispatches on join, skipping the lobby", async () => { + const { result } = renderHookForToast(); + + const dispatcherSpy = jest.fn(); + const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + + act(() => { + result.current.onJoin(); + }); + + expect(toastStore.dismissToast).toHaveBeenCalled(); + await waitFor(() => + expect(dispatcherSpy).toHaveBeenCalledWith( + expect.objectContaining({ action: Action.ViewRoom, room_id: room.roomId, skipLobby: true }), + ), + ); + + defaultDispatcher.unregister(dispatcherRef); + }); + + it("dispatches without dismissing on expand, showing the lobby", async () => { + const { result } = renderHookForToast(); + + const dispatcherSpy = jest.fn(); + const dispatcherRef = defaultDispatcher.register(dispatcherSpy); + + act(() => { + result.current.onExpand(); + }); + + expect(toastStore.dismissToast).not.toHaveBeenCalled(); + await waitFor(() => + expect(dispatcherSpy).toHaveBeenCalledWith( + expect.objectContaining({ action: Action.ViewRoom, room_id: room.roomId, skipLobby: false }), + ), + ); + + defaultDispatcher.unregister(dispatcherRef); + }); + + it("dismisses once the call lobby is viewed", async () => { + renderHookForToast(); + + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + }); + + await waitFor(() => expect(toastStore.dismissToast).toHaveBeenCalled()); + }); + + it("dismisses when the call event is redacted", async () => { + renderHookForToast(); + + const event = room.currentState.getStateEvents(MockedCall.EVENT_TYPE, "1")!; + act(() => { + room.emit(MatrixEventEvent.BeforeRedaction, event, {} as unknown as MatrixEvent); + }); + + await waitFor(() => expect(toastStore.dismissToast).toHaveBeenCalled()); + }); + + it("dismisses when the matrixRTC session has ended", async () => { + renderHookForToast(); + + act(() => { + call.destroy(); + }); + + await waitFor(() => expect(toastStore.dismissToast).toHaveBeenCalled()); + }); + + it("dismisses when a decline event for this notification was received", async () => { + const notificationEvent = makeNotificationEvent(room); + renderHookForToast(notificationEvent); + + act(() => { + room.emit( + RoomEvent.Timeline, + mkEvent({ + user: "@userId:matrix.org", + type: EventType.RTCDecline, + content: { "m.relates_to": { event_id: notificationEvent.getId()!, rel_type: "m.reference" } }, + event: true, + }), + room, + undefined, + false, + {} as unknown as IRoomTimelineData, + ); + }); + + await waitFor(() => expect(toastStore.dismissToast).toHaveBeenCalled()); + }); + + it("does not dismiss for a decline event from another user", async () => { + const notificationEvent = makeNotificationEvent(room); + renderHookForToast(notificationEvent); + + act(() => { + room.emit( + RoomEvent.Timeline, + mkEvent({ + user: "@userIdNotMe:matrix.org", + type: EventType.RTCDecline, + content: { "m.relates_to": { event_id: notificationEvent.getId()!, rel_type: "m.reference" } }, + event: true, + }), + room, + undefined, + false, + {} as unknown as IRoomTimelineData, + ); + }); + + expect(toastStore.dismissToast).not.toHaveBeenCalled(); + }); + + it("dismisses when another of our devices joins the call", async () => { + renderHookForToast(); + + act(() => { + call.emit( + CallEvent.Participants, + new Map([[mkRoomMember(room.roomId, "@userId:matrix.org"), new Set(["a"])]]), + new Map(), + ); + }); + + await waitFor(() => expect(toastStore.dismissToast).toHaveBeenCalled()); + }); + + it("sends a decline event and only dismisses after it resolves", async () => { + const { result } = renderHookForToast(); + + const { promise, resolve } = Promise.withResolvers(); + client.sendRtcDecline.mockImplementation(() => promise); + + let onDeclinePromise!: Promise; + act(() => { + onDeclinePromise = result.current.onDecline({ stopPropagation: () => {} } as any); + }); + + expect(toastStore.dismissToast).not.toHaveBeenCalled(); + expect(client.sendRtcDecline).toHaveBeenCalledWith("!1:example.org", "$notificationEventId"); + + resolve({ event_id: "$declineEventId" }); + await act(() => onDeclinePromise); + + expect(toastStore.dismissToast).toHaveBeenCalled(); + }); + + it("getNotificationEventSendTs returns the correct ts", () => { + const notificationEvent = makeNotificationEvent(room); + const eventOriginServerTs = mkEvent({ + user: "@userId:matrix.org", + type: EventType.RTCNotification, + content: { + "m.relates_to": { event_id: notificationEvent.getId()!, rel_type: "m.reference" }, + "sender_ts": 222_000, + }, + event: true, + ts: 1111, + }); + + const eventSendTs = mkEvent({ + user: "@userId:matrix.org", + type: EventType.RTCNotification, + content: { + "m.relates_to": { event_id: notificationEvent.getId()!, rel_type: "m.reference" }, + "sender_ts": 2222, + }, + event: true, + ts: 1111, + }); + + expect(getNotificationEventSendTs(eventOriginServerTs)).toBe(1111); + expect(getNotificationEventSendTs(eventSendTs)).toBe(2222); + }); +});