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.test.ts b/src/state/CallViewModel.test.ts index c30624ae..4b5e603f 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -18,7 +18,16 @@ 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, + EventType, + type IEvent, +} from "matrix-js-sdk"; import { ConnectionState, type LocalParticipant, @@ -237,6 +246,23 @@ 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", + sender, + } as unknown 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[]>; @@ -1205,10 +1231,8 @@ describe("waitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 30 } as unknown as { - event_id: string; - } & IRTCNotificationContent, - {} as unknown as { event_id: string } & ICallNotifyContent, + mockRingEvent("$notif1", 30), + mockLegacyRingEvent, ); }, }); @@ -1247,12 +1271,8 @@ describe("waitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 100 } as unknown as { - event_id: string; - } & IRTCNotificationContent, - {} as unknown as { - event_id: string; - } & ICallNotifyContent, + mockRingEvent("$notif2", 100), + mockLegacyRingEvent, ); }, }); @@ -1290,12 +1310,8 @@ describe("waitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 50 } as unknown as { - event_id: string; - } & IRTCNotificationContent, - {} as unknown as { - event_id: string; - } & ICallNotifyContent, + mockRingEvent("$notif3", 50), + mockLegacyRingEvent, ); }, }); @@ -1321,12 +1337,8 @@ describe("waitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - {} as unknown as { - event_id: string; - } & IRTCNotificationContent, // no lifetime - {} as unknown as { - event_id: string; - } & ICallNotifyContent, + mockRingEvent("$notif4", undefined), + mockLegacyRingEvent, ); }, }); @@ -1361,12 +1373,8 @@ describe("waitForCallPickup$", () => { r: () => { rtcSession.emit( MatrixRTCSessionEvent.DidSendCallNotification, - { lifetime: 30 } as unknown as { - event_id: string; - } & IRTCNotificationContent, - {} as unknown as { - event_id: string; - } & ICallNotifyContent, + mockRingEvent("$notif5", 30), + mockLegacyRingEvent, ); }, }); @@ -1381,6 +1389,149 @@ describe("waitForCallPickup$", () => { ); }); }); + + test("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 }, + }, + ); + }); + }); + + test("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 }, + }, + ); + }); + } + + 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, + }); + }); }); test("audio output changes when toggling earpiece mode", () => { diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 9112954f..3802d6dd 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -19,6 +19,9 @@ import { } from "livekit-client"; import { ClientEvent, + type EventTimelineSetHandlerMap, + EventType, + RoomEvent, RoomStateEvent, SyncState, type Room as MatrixRoom, @@ -33,6 +36,7 @@ import { combineLatest, concat, distinctUntilChanged, + endWith, filter, forkJoin, fromEvent, @@ -58,9 +62,9 @@ import { import { logger } from "matrix-js-sdk/lib/logger"; import { type CallMembership, - type IRTCNotificationContent, type MatrixRTCSession, MatrixRTCSessionEvent, + type MatrixRTCSessionEventHandlerMap, MembershipManagerEvent, Status, } from "matrix-js-sdk/lib/matrixrtc"; @@ -887,17 +891,60 @@ export class CallViewModel extends ViewModel { : NEVER; /** - * Emits whenever the RTC session tells us that it intends to ring for a given - * duration. + * Whenever the RTC session tells us that it intends to ring the remote + * participant's devices, this emits an Observable tracking the current state of + * that ringing process. */ - private readonly beginRingingForMs$ = ( + private readonly ring$: Observable< + Observable<"ringing" | "timeout" | "decline"> + > = ( fromEvent( this.matrixRTCSession, MatrixRTCSessionEvent.DidSendCallNotification, - ) as Observable<[IRTCNotificationContent]> - ) - // event.lifetime is expected to be in ms - .pipe(map(([notificationEvent]) => notificationEvent?.lifetime ?? 0)); + ) as Observable< + Parameters< + MatrixRTCSessionEventHandlerMap[MatrixRTCSessionEvent.DidSendCallNotification] + > + > + ).pipe( + filter( + ([notificationEvent]) => notificationEvent.notification_type === "ring", + ), + map(([notificationEvent]) => { + const lifetimeMs = notificationEvent?.lifetime ?? 0; + return concat( + lifetimeMs === 0 + ? // If no lifetime, skip the ring state + EMPTY + : // Ring until lifetime ms have passed + timer(lifetimeMs).pipe( + ignoreElements(), + startWith("ringing" as const), + ), + // The notification lifetime has timed out, meaning ringing has likely + // stopped on all receiving clients. + of("timeout" as const), + NEVER, + ).pipe( + takeUntil( + ( + fromEvent(this.matrixRoom, RoomEvent.Timeline) as Observable< + Parameters + > + ).pipe( + filter( + ([event]) => + event.getType() === EventType.RTCDecline && + event.getRelation()?.rel_type === "m.reference" && + event.getRelation()?.event_id === notificationEvent.event_id && + event.getSender() !== this.userId, + ), + ), + ), + endWith("decline" as const), + ); + }), + ); /** * Whether some Matrix user other than ourself is joined to the call. @@ -917,35 +964,21 @@ export class CallViewModel extends ViewModel { * - null: EC is configured to never show any waiting for answer state. */ public readonly callPickupState$ = this.options.waitForCallPickup - ? this.scope.behavior<"unknown" | "ringing" | "timeout" | "success">( - concat( - concat( - // We don't know if the RTC session decides to send a notify event - // yet. It will only be known once we sent our own membership and - // know we were the first one to join. - of("unknown" as const), - // Once we get the signal to begin ringing: - this.beginRingingForMs$.pipe( - take(1), - switchMap((lifetime) => - lifetime === 0 - ? // If no lifetime, skip the ring state - EMPTY - : // Ring until lifetime ms have passed - timer(lifetime).pipe( - ignoreElements(), - startWith("ringing" as const), - ), - ), - ), - // The notification lifetime has timed out, meaning ringing has - // likely stopped on all receiving clients. - of("timeout" as const), - NEVER, - ).pipe( - takeUntil(this.someoneElseJoined$.pipe(filter((joined) => joined))), + ? this.scope.behavior< + "unknown" | "ringing" | "timeout" | "decline" | "success" + >( + this.someoneElseJoined$.pipe( + switchMap((someoneElseJoined) => + someoneElseJoined + ? of("success" as const) + : // Show the ringing state of the most recent ringing attempt. + this.ring$.pipe(switchAll()), ), - of("success" as const), + // The state starts as 'unknown' because we don't know if the RTC + // session will actually send a notify event yet. It will only be + // known once we send our own membership and see that we were the + // first one to join. + startWith("unknown" as const), ), ) : constant(null); diff --git a/src/utils/test.ts b/src/utils/test.ts index 3e47f4f6..31c6068a 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, @@ -144,26 +145,25 @@ export function withTestScheduler( } 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), }; } diff --git a/yarn.lock b/yarn.lock index 94cb867d..13e9770b 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