From e30142a43b0af48a3cc1f9c641048aa77e9b23b9 Mon Sep 17 00:00:00 2001 From: Timo K Date: Mon, 25 Aug 2025 17:49:23 +0200 Subject: [PATCH 1/7] Add decline logic and tests Signed-off-by: Timo K --- src/state/CallViewModel.test.ts | 174 ++++++++++++++++++++++++++++++-- src/state/CallViewModel.ts | 64 +++++++++--- src/utils/test.ts | 33 +++--- 3 files changed, 229 insertions(+), 42 deletions(-) diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index b774700c..475ccd47 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -18,7 +18,14 @@ import { of, switchMap, } from "rxjs"; -import { ClientEvent, SyncState, type MatrixClient } from "matrix-js-sdk"; +import { + ClientEvent, + SyncState, + type MatrixClient, + RoomEvent as MatrixRoomEvent, + MatrixEvent, + type IRoomTimelineData, +} from "matrix-js-sdk"; import { ConnectionState, type LocalParticipant, @@ -1249,10 +1256,12 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 30 } as unknown as { + { event_id: "$notif1", lifetime: 30 } as unknown as { event_id: string; } & IRTCNotificationContent, - {} as unknown as { event_id: string } & ICallNotifyContent, + { event_id: "$notif1" } as unknown as { + event_id: string; + } & ICallNotifyContent, ); }, }); @@ -1300,10 +1309,10 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 100 } as unknown as { + { event_id: "$notif2", lifetime: 100 } as unknown as { event_id: string; } & IRTCNotificationContent, - {} as unknown as { + { event_id: "$notif2" } as unknown as { event_id: string; } & ICallNotifyContent, ); @@ -1351,10 +1360,10 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 50 } as unknown as { + { event_id: "$notif3", lifetime: 50 } as unknown as { event_id: string; } & IRTCNotificationContent, - {} as unknown as { + { event_id: "$notif3" } as unknown as { event_id: string; } & ICallNotifyContent, ); @@ -1388,10 +1397,10 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 0 } as unknown as { + { event_id: "$notif4", lifetime: 0 } as unknown as { event_id: string; } & IRTCNotificationContent, // no lifetime - {} as unknown as { + { event_id: "$notif4" } as unknown as { event_id: string; } & ICallNotifyContent, ); @@ -1437,10 +1446,10 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 30 } as unknown as { + { event_id: "$notif5", lifetime: 30 } as unknown as { event_id: string; } & IRTCNotificationContent, - {} as unknown as { + { event_id: "$notif5" } as unknown as { event_id: string; } & ICallNotifyContent, ); @@ -1457,6 +1466,149 @@ describe("shouldWaitForCallPickup$", () => { ); }); }); + + test("decline before timeout window ends -> decline", () => { + withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withCallViewModel( + { + remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), + rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), + connectionState$: of(ConnectionState.Connected), + }, + (vm, rtcSession) => { + // Notify at 10ms with 50ms lifetime, decline at 40ms with matching id + schedule(" 10ms r 29ms d", { + r: () => { + rtcSession.emit( + MatrixRTCSessionEvent.DidSendCallNotification, + { event_id: "$decl1", lifetime: 50 } as unknown as { + event_id: string; + } & IRTCNotificationContent, + { event_id: "$decl1" } as unknown as { + event_id: string; + } & ICallNotifyContent, + ); + }, + d: () => { + // Emit decline timeline event with id matching the notification + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent({ event_id: "$decl1", type: "m.rtc.decline" }), + rtcSession.room, + undefined, + false, + {} as IRoomTimelineData, + ); + }, + }); + expectObservable(vm.callPickupState$).toBe("a 9ms b 29ms e", { + a: "unknown", + b: "ringing", + e: "decline", + }); + }, + { + shouldWaitForCallPickup: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("decline after timeout window ends -> stays timeout", () => { + withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withCallViewModel( + { + remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), + rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), + connectionState$: of(ConnectionState.Connected), + }, + (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, + { event_id: "$decl2", lifetime: 20 } as unknown as { + event_id: string; + } & IRTCNotificationContent, + { event_id: "$decl2" } as unknown as { + event_id: string; + } & ICallNotifyContent, + ); + }, + 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", + }); + }, + { + shouldWaitForCallPickup: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); + + test("decline with wrong id is ignored (stays ringing)", () => { + withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withCallViewModel( + { + remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), + rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), + connectionState$: of(ConnectionState.Connected), + }, + (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, + { event_id: "$right", lifetime: 50 } as unknown as { + event_id: string; + } & IRTCNotificationContent, + { event_id: "$right" } as unknown as { + event_id: string; + } & ICallNotifyContent, + ); + }, + d: () => { + rtcSession.room.emit( + MatrixRoomEvent.Timeline, + new MatrixEvent({ event_id: "$wrong", type: "m.rtc.decline" }), + 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", + }); + }, + { + shouldWaitForCallPickup: true, + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + }, + ); + }); + }); }); test("audio output changes when toggling earpiece mode", () => { diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 709d27fa..d6fa80f5 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -19,6 +19,8 @@ import { } from "livekit-client"; import { ClientEvent, + EventTimelineSetHandlerMap, + RoomEvent, RoomStateEvent, SyncState, type Room as MatrixRoom, @@ -57,6 +59,7 @@ import { type IRTCNotificationContent, type MatrixRTCSession, MatrixRTCSessionEvent, + MatrixRTCSessionEventHandlerMap, MembershipManagerEvent, Status, } from "matrix-js-sdk/lib/matrixrtc"; @@ -935,26 +938,35 @@ export class CallViewModel extends ViewModel { * "ringing": The notification event was sent. * "ringEnded": The notification events lifetime has timed out -> ringing stopped on all receiving clients. */ - private readonly notificationEventIsRingingOthers$: Observable< - "unknown" | "ringing" | "ringEnded" | null - > = fromEvent<[IRTCNotificationContent, ICallNotifyContent]>( - this.matrixRTCSession, - MatrixRTCSessionEvent.DidSendCallNotification, - ).pipe( + private readonly rtcNotificationEventState$: Observable< + { state: "unknown" | "ringEnded" } | { state: "ringing"; event_id: string } + > = fromEvent< + Parameters< + MatrixRTCSessionEventHandlerMap[MatrixRTCSessionEvent.DidSendCallNotification] + > + >(this.matrixRTCSession, MatrixRTCSessionEvent.DidSendCallNotification).pipe( switchMap(([notificationEvent]) => { // event.lifetime is expected to be in ms const lifetime = notificationEvent?.lifetime ?? 0; if (lifetime > 0) { // Emit true immediately, then false after lifetime ms return concat( - of<"ringing" | null>("ringing"), - timer(lifetime).pipe(map((): "ringEnded" | null => "ringEnded")), + of({ + state: "ringing", + event_id: notificationEvent.event_id, + } as { + state: "ringing"; + event_id: string; + }), + timer(lifetime).pipe( + map(() => ({ state: "ringEnded" }) as { state: "ringEnded" }), + ), ); } - // If no lifetime, just emit true once - return of(null); + // If no lifetime, the notify event is basically invalid and we just stay in unknown state. + return of({ state: "unknown" } as { state: "unknown" }); }), - startWith("unknown" as "unknown" | null), + startWith({ state: "unknown" } as { state: "unknown" }), ); /** @@ -980,22 +992,37 @@ export class CallViewModel extends ViewModel { * - null: EC is configured to never show any waiting for answer state. */ public readonly callPickupState$: Behavior< - "unknown" | "ringing" | "timeout" | "success" | null + "unknown" | "ringing" | "timeout" | "success" | "decline" | null > = this.scope.behavior( combineLatest([ - this.notificationEventIsRingingOthers$, + this.rtcNotificationEventState$, this.someoneElseJoined$, + fromEvent>( + this.matrixRoom, + RoomEvent.Timeline, + ).pipe( + map(([event]) => { + // TODO use correct decline event type enum. + if (event.getType() === "m.rtc.decline") return event; + else return null; + }), + startWith(null), + ), ]).pipe( - map(([isRingingOthers, someoneJoined]) => { + map(([notificationEventState, someoneJoined, declineEvent]) => { // Never enter waiting for answer state if the app is not configured with waitingForAnswer. if (!this.options.shouldWaitForCallPickup) return null; // As soon as someone joins, we can consider the call "wait for answer" successful if (someoneJoined) return "success"; - switch (isRingingOthers) { + switch (notificationEventState?.state) { case "unknown": return "unknown"; case "ringing": + // Check if the decline event corresponds to the current notification event + if (declineEvent?.getId() === notificationEventState.event_id) { + return "decline"; + } return "ringing"; case "ringEnded": return "timeout"; @@ -1003,6 +1030,13 @@ export class CallViewModel extends ViewModel { return "timeout"; } }), + // Once we reach a terminal state, keep it + scan((prev, next) => { + if (prev === "decline" || prev === "timeout" || prev === "success") { + return prev; + } + return next; + }), distinctUntilChanged(), ), ); diff --git a/src/utils/test.ts b/src/utils/test.ts index 3e47f4f6..cd8b2d1c 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -38,6 +38,7 @@ import { type RoomAndToDeviceEventsHandlerMap, } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; import { type TrackReference } from "@livekit/components-core"; +import EventEmitter from "events"; import { LocalUserMediaViewModel, @@ -143,27 +144,27 @@ export function withTestScheduler( scope.end(); } + interface EmitterMock { - on: () => T; - off: () => T; - addListener: () => T; - removeListener: () => T; + on: (...args: unknown[]) => T; + off: (...args: unknown[]) => T; + addListener: (...args: unknown[]) => T; + removeListener: (...args: unknown[]) => T; + emit: (event: string | symbol, ...args: unknown[]) => boolean; } export function mockEmitter(): EmitterMock { + const ee = new EventEmitter(); return { - on(): T { - return this as T; - }, - off(): T { - return this as T; - }, - addListener(): T { - return this as T; - }, - removeListener(): T { - return this as T; - }, + on: ee.on.bind(ee) as unknown as (...args: unknown[]) => T, + off: ee.off.bind(ee) as unknown as (...args: unknown[]) => T, + addListener: ee.addListener.bind(ee) as unknown as ( + ...args: unknown[] + ) => T, + removeListener: ee.removeListener.bind(ee) as unknown as ( + ...args: unknown[] + ) => T, + emit: ee.emit.bind(ee), }; } From a91c71a8e4c7c0b6a5660c81212299296b6d61a8 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 26 Aug 2025 18:28:53 +0200 Subject: [PATCH 2/7] types Signed-off-by: Timo K --- src/state/CallViewModel.test.ts | 13 +++++++------ src/state/CallViewModel.ts | 2 -- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 475ccd47..559a5226 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -1300,8 +1300,6 @@ describe("shouldWaitForCallPickup$", () => { remoteParticipants$: remote$, rtcMembers$: rtc$, connectionState$: of(ConnectionState.Connected), - speaking: new Map(), - mediaDevices: mockMediaDevices({}), }, (vm, rtcSession) => { // Notify at 5ms so we enter ringing, then success at 20ms @@ -1309,12 +1307,15 @@ describe("shouldWaitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { event_id: "$notif2", lifetime: 100 } as unknown as { + { + event_id: "$notif2", + lifetime: 100, + } as unknown as IRTCNotificationContent & { event_id: string; - } & IRTCNotificationContent, - { event_id: "$notif2" } as unknown as { + }, + { event_id: "$notif2" } as unknown as ICallNotifyContent & { event_id: string; - } & ICallNotifyContent, + }, ); }, }); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index d6fa80f5..1771a589 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -55,8 +55,6 @@ import { import { logger } from "matrix-js-sdk/lib/logger"; import { type CallMembership, - type ICallNotifyContent, - type IRTCNotificationContent, type MatrixRTCSession, MatrixRTCSessionEvent, MatrixRTCSessionEventHandlerMap, From fe65c1f4dae422010510835ca36f3a937e5f648e Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 26 Aug 2025 19:07:52 +0200 Subject: [PATCH 3/7] fix decline event type Signed-off-by: Timo K --- src/state/CallViewModel.ts | 4 ++-- src/utils/test.ts | 1 - yarn.lock | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 1771a589..d292ef5a 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -20,6 +20,7 @@ import { import { ClientEvent, EventTimelineSetHandlerMap, + EventType, RoomEvent, RoomStateEvent, SyncState, @@ -1000,8 +1001,7 @@ export class CallViewModel extends ViewModel { RoomEvent.Timeline, ).pipe( map(([event]) => { - // TODO use correct decline event type enum. - if (event.getType() === "m.rtc.decline") return event; + if (event.getType() === EventType.RTCDecline) return event; else return null; }), startWith(null), diff --git a/src/utils/test.ts b/src/utils/test.ts index cd8b2d1c..31c6068a 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -144,7 +144,6 @@ export function withTestScheduler( scope.end(); } - interface EmitterMock { on: (...args: unknown[]) => T; off: (...args: unknown[]) => T; diff --git a/yarn.lock b/yarn.lock index 723e3486..fa71cce0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10280,7 +10280,7 @@ __metadata: "matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop": version: 37.13.0 - resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=c4c7f945141e142e6f846b243c33c4af97a9a44b" + resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=2f1d654f14be8dd03896e9e76f12017b6f9eec1c" dependencies: "@babel/runtime": "npm:^7.12.5" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0" @@ -10296,7 +10296,7 @@ __metadata: sdp-transform: "npm:^2.14.1" unhomoglyph: "npm:^1.0.6" uuid: "npm:11" - checksum: 10c0/caa4b8a6d924ac36a21773dc2c8be6cb6b658a9feaabccdb24426719c563ac2cfe4778abb86f0889854ae36fc7ba02a6ed39acdbc0b73fdc31ce9a9789e7f36a + checksum: 10c0/ecd019c677c272c5598617dcde407dbe4b1b11460863b2a577e33f3fd8732c9d9073ec0221b471ec1eb24e2839eec20728db7f92c9348be83126547286e50805 languageName: node linkType: hard From 7724cbf9be4f2a5de93a15f76f63d062c3895f9c Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 26 Aug 2025 19:21:27 +0200 Subject: [PATCH 4/7] fix tests Signed-off-by: Timo K --- src/state/CallViewModel.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index d292ef5a..96d8740b 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -19,7 +19,7 @@ import { } from "livekit-client"; import { ClientEvent, - EventTimelineSetHandlerMap, + type EventTimelineSetHandlerMap, EventType, RoomEvent, RoomStateEvent, @@ -58,7 +58,7 @@ import { type CallMembership, type MatrixRTCSession, MatrixRTCSessionEvent, - MatrixRTCSessionEventHandlerMap, + type MatrixRTCSessionEventHandlerMap, MembershipManagerEvent, Status, } from "matrix-js-sdk/lib/matrixrtc"; @@ -962,8 +962,8 @@ export class CallViewModel extends ViewModel { ), ); } - // If no lifetime, the notify event is basically invalid and we just stay in unknown state. - return of({ state: "unknown" } as { state: "unknown" }); + // If no lifetime, the notify event is basically invalid and we enter ringEnded immediately. + return of({ state: "ringEnded" } as { state: "ringEnded" }); }), startWith({ state: "unknown" } as { state: "unknown" }), ); From 2541f810fa8a66c5e68393e380cd8398b1d72677 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 5 Sep 2025 14:36:27 +0200 Subject: [PATCH 5/7] Ensure that non-ringing notifications lead to a null pickup state --- src/room/InCallView.tsx | 7 ++++++- src/state/CallViewModel.ts | 10 ++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 034be16d..e87072d3 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -158,7 +158,8 @@ export const ActiveCall: FC = (props) => { }; }, [livekitRoom]); - const { autoLeaveWhenOthersLeft } = useUrlParams(); + const { autoLeaveWhenOthersLeft, sendNotificationType, waitForCallPickup } = + useUrlParams(); useEffect(() => { if (livekitRoom !== undefined) { @@ -171,6 +172,8 @@ export const ActiveCall: FC = (props) => { { encryptionSystem: props.e2eeSystem, autoLeaveWhenOthersLeft, + waitForCallPickup: + waitForCallPickup && sendNotificationType === "ring", }, connStateObservable$, reactionsReader.raisedHands$, @@ -190,6 +193,8 @@ export const ActiveCall: FC = (props) => { props.e2eeSystem, connStateObservable$, autoLeaveWhenOthersLeft, + sendNotificationType, + waitForCallPickup, ]); if (livekitRoom === undefined || vm === null) return null; diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 5b946f09..5dbb0b9c 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -907,15 +907,17 @@ export class CallViewModel extends ViewModel { > > ).pipe( + filter( + ([notificationEvent]) => notificationEvent.notification_type === "ring", + ), map(([notificationEvent]) => { - // event.lifetime is expected to be in ms - const lifetime = notificationEvent?.lifetime ?? 0; + const lifetimeMs = notificationEvent?.lifetime ?? 0; return concat( - lifetime === 0 + lifetimeMs === 0 ? // If no lifetime, skip the ring state EMPTY : // Ring until lifetime ms have passed - timer(lifetime).pipe( + timer(lifetimeMs).pipe( ignoreElements(), startWith("ringing" as const), ), From 1193a22658ad6370833179c2eb4b2304449f9b71 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 5 Sep 2025 14:48:36 +0200 Subject: [PATCH 6/7] Fix tests --- src/state/CallViewModel.test.ts | 102 +++++++++++--------------------- 1 file changed, 35 insertions(+), 67 deletions(-) diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index b7c2bd32..938109cb 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -245,6 +245,21 @@ function summarizeLayout$(l$: Observable): Observable { ); } +function mockRingEvent( + eventId: string, + lifetimeMs: number | undefined, +): { event_id: string } & IRTCNotificationContent { + return { + event_id: eventId, + ...(lifetimeMs === undefined ? {} : { lifetime: lifetimeMs }), + notification_type: "ring", + } as { event_id: string } & IRTCNotificationContent; +} + +// The app doesn't really care about the content of these legacy events, we just +// need a value to fill in for them when emitting notifications +const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent; + interface CallViewModelInputs { remoteParticipants$: Behavior; rtcMembers$: Behavior[]>; @@ -1213,12 +1228,8 @@ describe("waitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { event_id: "$notif1", lifetime: 30 } as unknown as { - event_id: string; - } & IRTCNotificationContent, - { event_id: "$notif1" } as unknown as { - event_id: string; - } & ICallNotifyContent, + mockRingEvent("$notif1", 30), + mockLegacyRingEvent, ); }, }); @@ -1257,15 +1268,8 @@ describe("waitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { - event_id: "$notif2", - lifetime: 100, - } as unknown as IRTCNotificationContent & { - event_id: string; - }, - { event_id: "$notif2" } as unknown as ICallNotifyContent & { - event_id: string; - }, + mockRingEvent("$notif2", 100), + mockLegacyRingEvent, ); }, }); @@ -1303,12 +1307,8 @@ describe("waitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { event_id: "$notif3", lifetime: 50 } as unknown as { - event_id: string; - } & IRTCNotificationContent, - { event_id: "$notif3" } as unknown as { - event_id: string; - } & ICallNotifyContent, + mockRingEvent("$notif3", 50), + mockLegacyRingEvent, ); }, }); @@ -1334,12 +1334,8 @@ describe("waitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { event_id: "$notif4" } as unknown as { - event_id: string; - } & IRTCNotificationContent, // no lifetime - { event_id: "$notif4" } as unknown as { - event_id: string; - } & ICallNotifyContent, + mockRingEvent("$notif4", undefined), + mockLegacyRingEvent, ); }, }); @@ -1374,12 +1370,8 @@ describe("waitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { event_id: "$notif5", lifetime: 30 } as unknown as { - event_id: string; - } & IRTCNotificationContent, - { event_id: "$notif5" } as unknown as { - event_id: string; - } & ICallNotifyContent, + mockRingEvent("$notif5", 30), + mockLegacyRingEvent, ); }, }); @@ -1396,25 +1388,17 @@ describe("waitForCallPickup$", () => { }); test("decline before timeout window ends -> decline", () => { - withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withTestScheduler(({ schedule, expectObservable }) => { withCallViewModel( - { - remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), - rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), - connectionState$: of(ConnectionState.Connected), - }, + {}, (vm, rtcSession) => { // Notify at 10ms with 50ms lifetime, decline at 40ms with matching id schedule(" 10ms r 29ms d", { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { event_id: "$decl1", lifetime: 50 } as unknown as { - event_id: string; - } & IRTCNotificationContent, - { event_id: "$decl1" } as unknown as { - event_id: string; - } & ICallNotifyContent, + mockRingEvent("$decl1", 50), + mockLegacyRingEvent, ); }, d: () => { @@ -1454,23 +1438,15 @@ describe("waitForCallPickup$", () => { test("decline after timeout window ends -> stays timeout", () => { withTestScheduler(({ hot, schedule, expectObservable, scope }) => { withCallViewModel( - { - remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), - rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), - connectionState$: of(ConnectionState.Connected), - }, + {}, (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, - { event_id: "$decl2", lifetime: 20 } as unknown as { - event_id: string; - } & IRTCNotificationContent, - { event_id: "$decl2" } as unknown as { - event_id: string; - } & ICallNotifyContent, + mockRingEvent("$decl2", 20), + mockLegacyRingEvent, ); }, t: () => {}, @@ -1502,23 +1478,15 @@ describe("waitForCallPickup$", () => { test("decline with wrong id is ignored (stays ringing)", () => { withTestScheduler(({ hot, schedule, expectObservable, scope }) => { withCallViewModel( - { - remoteParticipants$: scope.behavior(hot("a", { a: [] }), []), - rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []), - connectionState$: of(ConnectionState.Connected), - }, + {}, (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, - { event_id: "$right", lifetime: 50 } as unknown as { - event_id: string; - } & IRTCNotificationContent, - { event_id: "$right" } as unknown as { - event_id: string; - } & ICallNotifyContent, + mockRingEvent("$right", 50), + mockLegacyRingEvent, ); }, d: () => { From 1e32b355ce2640990abae4a87b5f3c100295d011 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 5 Sep 2025 21:22:32 +0200 Subject: [PATCH 7/7] Ignore decline events from the local user --- src/state/CallViewModel.test.ts | 29 ++++++++++++++++++++++++----- src/state/CallViewModel.ts | 3 ++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index 938109cb..4b5e603f 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -26,6 +26,7 @@ import { MatrixEvent, type IRoomTimelineData, EventType, + type IEvent, } from "matrix-js-sdk"; import { ConnectionState, @@ -248,12 +249,14 @@ function summarizeLayout$(l$: Observable): Observable { 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", - } as { event_id: string } & IRTCNotificationContent; + sender, + } as unknown as { event_id: string } & IRTCNotificationContent; } // The app doesn't really care about the content of these legacy events, we just @@ -1436,7 +1439,7 @@ describe("waitForCallPickup$", () => { }); test("decline after timeout window ends -> stays timeout", () => { - withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + withTestScheduler(({ schedule, expectObservable }) => { withCallViewModel( {}, (vm, rtcSession) => { @@ -1475,8 +1478,8 @@ describe("waitForCallPickup$", () => { }); }); - test("decline with wrong id is ignored (stays ringing)", () => { - withTestScheduler(({ hot, schedule, expectObservable, scope }) => { + function testStaysRinging(declineEvent: Partial): void { + withTestScheduler(({ schedule, expectObservable }) => { withCallViewModel( {}, (vm, rtcSession) => { @@ -1492,7 +1495,7 @@ describe("waitForCallPickup$", () => { d: () => { rtcSession.room.emit( MatrixRoomEvent.Timeline, - new MatrixEvent({ event_id: "$wrong", type: "m.rtc.decline" }), + new MatrixEvent(declineEvent), rtcSession.room, undefined, false, @@ -1512,6 +1515,22 @@ describe("waitForCallPickup$", () => { }, ); }); + } + + test("decline with wrong id is ignored (stays ringing)", () => { + testStaysRinging({ + event_id: "$wrong", + type: "m.rtc.decline", + sender: local.userId, + }); + }); + + test("decline with sender being the local user is ignored (stays ringing)", () => { + testStaysRinging({ + event_id: "$right", + type: "m.rtc.decline", + sender: alice.userId, + }); }); }); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 5dbb0b9c..3802d6dd 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -936,7 +936,8 @@ export class CallViewModel extends ViewModel { ([event]) => event.getType() === EventType.RTCDecline && event.getRelation()?.rel_type === "m.reference" && - event.getRelation()?.event_id === notificationEvent.event_id, + event.getRelation()?.event_id === notificationEvent.event_id && + event.getSender() !== this.userId, ), ), ),