Merge pull request #3467 from element-hq/toger5/call-pickup-state-decline-event

View model for decline logic
This commit is contained in:
Timo
2025-09-09 15:23:39 +02:00
committed by GitHub
5 changed files with 273 additions and 84 deletions

View File

@@ -158,7 +158,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
};
}, [livekitRoom]);
const { autoLeaveWhenOthersLeft } = useUrlParams();
const { autoLeaveWhenOthersLeft, sendNotificationType, waitForCallPickup } =
useUrlParams();
useEffect(() => {
if (livekitRoom !== undefined) {
@@ -171,6 +172,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
{
encryptionSystem: props.e2eeSystem,
autoLeaveWhenOthersLeft,
waitForCallPickup:
waitForCallPickup && sendNotificationType === "ring",
},
connStateObservable$,
reactionsReader.raisedHands$,
@@ -190,6 +193,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.e2eeSystem,
connStateObservable$,
autoLeaveWhenOthersLeft,
sendNotificationType,
waitForCallPickup,
]);
if (livekitRoom === undefined || vm === null) return null;

View File

@@ -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<Layout>): Observable<LayoutSummary> {
);
}
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<RemoteParticipant[]>;
rtcMembers$: Behavior<Partial<CallMembership>[]>;
@@ -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<IEvent>): 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", () => {

View File

@@ -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<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
>
).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);

View File

@@ -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<T> {
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<T>(): EmitterMock<T> {
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),
};
}

View File

@@ -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