/* Copyright 2025 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ import { ConnectionState } from "livekit-client"; import { EventType, MatrixEvent, type IEvent, type IRoomTimelineData, } from "matrix-js-sdk"; import { type ICallNotifyContent, type IRTCNotificationContent, MatrixRTCSessionEvent, } from "matrix-js-sdk/lib/matrixrtc"; import { BehaviorSubject } from "rxjs"; import { describe, it } from "vitest"; import { E2eeType } from "../../e2ee/e2eeType"; import { withTestScheduler } from "../../utils/test"; import { alice, aliceParticipant, aliceRtcMember, local, localRtcMember, } from "../../utils/test-fixtures"; import { createCallNotificationLifecycle$, type Props as CallNotificationLifecycleProps, } from "./CallNotificationLifecycle"; import { withCallViewModel } from "./CallViewModel.test"; import { trackEpoch } from "../ObservableScope"; const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent; function mockRingEvent( eventId: string, lifetimeMs: number | undefined, sender = local.userId, ): { event_id: string } & IRTCNotificationContent { return { event_id: eventId, ...(lifetimeMs === undefined ? {} : { lifetime: lifetimeMs }), notification_type: "ring", sender, } as unknown as { event_id: string } & IRTCNotificationContent; } describe("waitForCallPickup$", () => { it("unknown -> ringing -> timeout when notified and nobody joins", () => { withTestScheduler(({ scope, expectObservable, behavior, hot }) => { // No one ever joins (only local user) const props: CallNotificationLifecycleProps = { scope, memberships$: scope.behavior( behavior("a", { a: [] }).pipe(trackEpoch()), ), sentCallNotification$: hot("10ms a", { a: [mockRingEvent("$notif1", 30), mockLegacyRingEvent], }), receivedDecline$: hot(""), options: { waitForCallPickup: true, autoLeaveWhenOthersLeft: false, }, localUser: localRtcMember, }; const lifecycle = createCallNotificationLifecycle$(props); expectObservable(lifecycle.callPickupState$).toBe("a 9ms b 29ms c", { a: "unknown", b: "ringing", c: "timeout", }); }); }); it("ringing -> success if someone joins before timeout is reached", () => { withTestScheduler(({ scope, hot, behavior, expectObservable }) => { // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) const props: CallNotificationLifecycleProps = { scope, memberships$: scope.behavior( behavior("a 19ms b", { a: [localRtcMember], b: [localRtcMember, aliceRtcMember], }).pipe(trackEpoch()), ), sentCallNotification$: hot("5ms a", { a: [mockRingEvent("$notif2", 100), mockLegacyRingEvent], }), receivedDecline$: hot(""), options: { waitForCallPickup: true, autoLeaveWhenOthersLeft: false, }, localUser: localRtcMember, }; const lifecycle = createCallNotificationLifecycle$(props); expectObservable(lifecycle.callPickupState$).toBe("a 4ms b 14ms c", { a: "unknown", b: "ringing", c: "success", }); }); }); it("success when someone joins before we notify", () => { withTestScheduler(({ scope, hot, behavior, expectObservable }) => { // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) const props: CallNotificationLifecycleProps = { scope, memberships$: scope.behavior( behavior("a 9ms b", { a: [localRtcMember], b: [localRtcMember, aliceRtcMember], }).pipe(trackEpoch()), ), sentCallNotification$: hot("20ms a", { a: [mockRingEvent("$notif2", 50), mockLegacyRingEvent], }), receivedDecline$: hot(""), options: { waitForCallPickup: true, autoLeaveWhenOthersLeft: false, }, localUser: localRtcMember, }; const lifecycle = createCallNotificationLifecycle$(props); expectObservable(lifecycle.callPickupState$).toBe("a 9ms b", { a: "unknown", b: "success", }); }); }); it("notify without lifetime -> immediate timeout", () => { withTestScheduler(({ scope, hot, behavior, expectObservable }) => { // Someone joins at 20ms (both LiveKit participant and MatrixRTC member) const props: CallNotificationLifecycleProps = { scope, memberships$: scope.behavior( behavior("a", { a: [localRtcMember], }).pipe(trackEpoch()), ), sentCallNotification$: hot("10ms a", { a: [mockRingEvent("$notif2", undefined), mockLegacyRingEvent], }), receivedDecline$: hot(""), options: { waitForCallPickup: true, autoLeaveWhenOthersLeft: false, }, localUser: localRtcMember, }; const lifecycle = createCallNotificationLifecycle$(props); expectObservable(lifecycle.callPickupState$).toBe("a 9ms b", { a: "unknown", b: "timeout", }); }); }); it("stays null when waitForCallPickup=false", () => { withTestScheduler(({ behavior, schedule, expectObservable }) => { withCallViewModel( { remoteParticipants$: behavior("a--b", { a: [], b: [aliceParticipant], }), rtcMembers$: behavior("a--b", { a: [localRtcMember], b: [localRtcMember, aliceRtcMember], }), }, (vm, rtcSession) => { schedule(" 5ms r", { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, mockRingEvent("$notif5", 30), mockLegacyRingEvent, ); }, }); expectObservable(vm.callPickupState$).toBe("(n)", { n: null, }); }, { waitForCallPickup: false, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); }); }); it("decline before timeout window ends -> decline", () => { withTestScheduler(({ schedule, expectObservable }) => { withCallViewModel( {}, (vm, rtcSession) => { // Notify at 10ms with 50ms lifetime, decline at 40ms with matching id schedule(" 10ms r 29ms d", { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, mockRingEvent("$decl1", 50), mockLegacyRingEvent, ); }, d: () => { // Emit decline timeline event with id matching the notification rtcSession.room.emit( MatrixRoomEvent.Timeline, new MatrixEvent({ type: EventType.RTCDecline, content: { "m.relates_to": { rel_type: "m.reference", event_id: "$decl1", }, }, }), rtcSession.room, undefined, false, {} as IRoomTimelineData, ); }, }); expectObservable(vm.callPickupState$).toBe("a 9ms b 29ms e", { a: "unknown", b: "ringing", e: "decline", }); }, { waitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); }); }); it("decline after timeout window ends -> stays timeout", () => { withTestScheduler(({ schedule, expectObservable }) => { withCallViewModel( {}, (vm, rtcSession) => { // Notify at 10ms with 20ms lifetime (timeout at 30ms), decline at 40ms schedule(" 10ms r 20ms t 10ms d", { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, mockRingEvent("$decl2", 20), mockLegacyRingEvent, ); }, t: () => {}, d: () => { rtcSession.room.emit( MatrixRoomEvent.Timeline, new MatrixEvent({ event_id: "$decl2", type: "m.rtc.decline", }), rtcSession.room, undefined, false, {} as IRoomTimelineData, ); }, }); expectObservable(vm.callPickupState$).toBe("a 9ms b 19ms c", { a: "unknown", b: "ringing", c: "timeout", }); }, { waitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); }); }); function testStaysRinging(declineEvent: Partial): void { withTestScheduler(({ schedule, expectObservable }) => { withCallViewModel( {}, (vm, rtcSession) => { // Notify at 10ms with id A, decline arrives at 20ms with id B schedule(" 10ms r 10ms d", { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, mockRingEvent("$right", 50), mockLegacyRingEvent, ); }, d: () => { rtcSession.room.emit( MatrixRoomEvent.Timeline, new MatrixEvent(declineEvent), rtcSession.room, undefined, false, {} as IRoomTimelineData, ); }, }); // We assert up to 21ms to see the ringing at 10ms and no change at 20ms expectObservable(vm.callPickupState$, "21ms !").toBe("a 9ms b", { a: "unknown", b: "ringing", }); }, { waitForCallPickup: true, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, }, ); }); } it("decline with wrong id is ignored (stays ringing)", () => { testStaysRinging({ event_id: "$wrong", type: "m.rtc.decline", sender: local.userId, }); }); it("decline with sender being the local user is ignored (stays ringing)", () => { testStaysRinging({ event_id: "$right", type: "m.rtc.decline", sender: alice.userId, }); }); });