waitForNotificationAnswer

Signed-off-by: Timo K <toger5@hotmail.de>
This commit is contained in:
Timo K
2025-08-25 13:49:01 +02:00
parent 9486ed5d77
commit e475f56af5
6 changed files with 384 additions and 47 deletions

View File

@@ -216,6 +216,17 @@ export interface UrlConfiguration {
* This is one part to make the call matrixRTC session behave like a telephone call. * This is one part to make the call matrixRTC session behave like a telephone call.
*/ */
autoLeaveWhenOthersLeft: boolean; autoLeaveWhenOthersLeft: boolean;
/**
* If the client should show behave like it is awaiting an answer if a notification was sent.
* This is a no-op if not combined with sendNotificationType.
*
* This entails:
* - show ui that it is awaiting an answer
* - play a sound that indicates that it is awaiting an answer
* - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
*/
awaitingAnswer: boolean;
} }
// If you need to add a new flag to this interface, prefer a name that describes // If you need to add a new flag to this interface, prefer a name that describes
@@ -442,6 +453,7 @@ export const getUrlParams = (
"ring", "ring",
"notification", "notification",
]), ]),
awaitingAnswer: parser.getFlag("showAwaitingAnswerFeedback"),
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"), autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
}; };

View File

@@ -453,7 +453,6 @@ export const GroupCallView: FC<Props> = ({
matrixInfo={matrixInfo} matrixInfo={matrixInfo}
rtcSession={rtcSession as MatrixRTCSession} rtcSession={rtcSession as MatrixRTCSession}
matrixRoom={room} matrixRoom={room}
participantCount={participantCount}
onLeave={onLeave} onLeave={onLeave}
header={header} header={header}
muteStates={muteStates} muteStates={muteStates}

View File

@@ -216,7 +216,6 @@ export interface InCallViewProps {
matrixRoom: MatrixRoom; matrixRoom: MatrixRoom;
livekitRoom: LivekitRoom; livekitRoom: LivekitRoom;
muteStates: MuteStates; muteStates: MuteStates;
participantCount: number;
/** Function to call when the user explicitly ends the call */ /** Function to call when the user explicitly ends the call */
onLeave: () => void; onLeave: () => void;
header: HeaderStyle; header: HeaderStyle;
@@ -233,7 +232,6 @@ export const InCallView: FC<InCallViewProps> = ({
matrixRoom, matrixRoom,
livekitRoom, livekitRoom,
muteStates, muteStates,
participantCount,
onLeave, onLeave,
header: headerStyle, header: headerStyle,
connState, connState,
@@ -312,6 +310,7 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(), () => void toggleRaisedHand(),
); );
const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$); const reconnecting = useBehavior(vm.reconnecting$);
const windowMode = useBehavior(vm.windowMode$); const windowMode = useBehavior(vm.windowMode$);
const layout = useBehavior(vm.layout$); const layout = useBehavior(vm.layout$);

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { test, vi, onTestFinished, it } from "vitest"; import { test, vi, onTestFinished, it, describe } from "vitest";
import EventEmitter from "events"; import EventEmitter from "events";
import { import {
BehaviorSubject, BehaviorSubject,
@@ -32,6 +32,9 @@ import {
Status, Status,
type CallMembership, type CallMembership,
type MatrixRTCSession, type MatrixRTCSession,
type IRTCNotificationContent,
type ICallNotifyContent,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { deepCompare } from "matrix-js-sdk/lib/utils"; import { deepCompare } from "matrix-js-sdk/lib/utils";
@@ -1228,6 +1231,215 @@ test("autoLeaveWhenOthersLeft$ doesn't emits when autoLeaveWhenOthersLeft option
}); });
}); });
describe("waitForNotificationAnswer$", () => {
test("unknown -> ringing -> timeout when notified and nobody joins", () => {
withTestScheduler(({ hot, schedule, expectObservable, scope }) => {
// No one ever joins (only local user)
withCallViewModel(
{
remoteParticipants$: scope.behavior(hot("a", { a: [] }), []),
rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []),
connectionState$: of(ConnectionState.Connected),
speaking: new Map(),
mediaDevices: mockMediaDevices({}),
},
(vm, rtcSession) => {
// Fire a call notification at 10ms with lifetime 30ms
schedule(" 10ms r", {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
{ lifetime: 30 } as unknown as IRTCNotificationContent,
{} as unknown as ICallNotifyContent,
);
},
});
expectObservable(vm.waitForNotificationAnswer$).toBe(
"a 9ms b 29ms c",
{ a: "unknown", b: "ringing", c: "timeout" },
);
},
{
waitForNotificationAnswer: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
});
test("ringing -> success if someone joins before timeout", () => {
withTestScheduler(({ hot, schedule, expectObservable, scope }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const remote$ = scope.behavior(
hot("a--b", { a: [], b: [aliceParticipant] }),
[],
);
const rtc$ = scope.behavior(
hot("a--b", {
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}),
[],
);
withCallViewModel(
{
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
schedule(" 5ms r", {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
{ lifetime: 100 } as unknown as IRTCNotificationContent,
{} as unknown as ICallNotifyContent,
);
},
});
expectObservable(vm.waitForNotificationAnswer$).toBe("a 2ms c", {
a: "unknown",
c: "success",
});
},
{
waitForNotificationAnswer: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
});
test("success when someone joins before we notify", () => {
withTestScheduler(({ hot, schedule, expectObservable, scope }) => {
// Join at 10ms, notify later at 20ms (state should stay success)
const remote$ = scope.behavior(
hot("a-b", { a: [], b: [aliceParticipant] }),
[],
);
const rtc$ = scope.behavior(
hot("a-b", {
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}),
[],
);
withCallViewModel(
{
remoteParticipants$: remote$,
rtcMembers$: rtc$,
connectionState$: of(ConnectionState.Connected),
speaking: new Map(),
mediaDevices: mockMediaDevices({}),
},
(vm, rtcSession) => {
schedule(" 20ms r", {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
{ lifetime: 50 } as unknown as IRTCNotificationContent,
{} as unknown as ICallNotifyContent,
);
},
});
expectObservable(vm.waitForNotificationAnswer$).toBe("a 1ms b", {
a: "unknown",
b: "success",
});
},
{
waitForNotificationAnswer: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
});
test("notify without lifetime -> immediate timeout", () => {
withTestScheduler(({ hot, schedule, expectObservable, scope }) => {
withCallViewModel(
{
remoteParticipants$: scope.behavior(hot("a", { a: [] }), []),
rtcMembers$: scope.behavior(hot("a", { a: [localRtcMember] }), []),
connectionState$: of(ConnectionState.Connected),
speaking: new Map(),
mediaDevices: mockMediaDevices({}),
},
(vm, rtcSession) => {
schedule(" 10ms r", {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
{ lifetime: 0 } as unknown as IRTCNotificationContent, // no lifetime
{} as unknown as ICallNotifyContent,
);
},
});
expectObservable(vm.waitForNotificationAnswer$).toBe("a 9ms b", {
a: "unknown",
b: "timeout",
});
},
{
waitForNotificationAnswer: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
});
test("stays null when waitForNotificationAnswer=false", () => {
withTestScheduler(({ hot, schedule, expectObservable, scope }) => {
const remote$ = scope.behavior(
hot("a--b", { a: [], b: [aliceParticipant] }),
[],
);
const rtc$ = scope.behavior(
hot("a--b", {
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}),
[],
);
withCallViewModel(
{
remoteParticipants$: remote$,
rtcMembers$: rtc$,
connectionState$: of(ConnectionState.Connected),
speaking: new Map(),
mediaDevices: mockMediaDevices({}),
},
(vm, rtcSession) => {
schedule(" 5ms r", {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
{ lifetime: 30 } as unknown as IRTCNotificationContent,
{} as unknown as ICallNotifyContent,
);
},
});
expectObservable(vm.waitForNotificationAnswer$).toBe("(n)", {
n: null,
});
},
{
waitForNotificationAnswer: false,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
});
});
test("audio output changes when toggling earpiece mode", () => { test("audio output changes when toggling earpiece mode", () => {
withTestScheduler(({ schedule, expectObservable }) => { withTestScheduler(({ schedule, expectObservable }) => {
getUrlParams.mockReturnValue({ controlledAudioDevices: true }); getUrlParams.mockReturnValue({ controlledAudioDevices: true });

View File

@@ -53,6 +53,8 @@ import {
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { import {
type CallMembership, type CallMembership,
type ICallNotifyContent,
type IRTCNotificationContent,
type MatrixRTCSession, type MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
MembershipManagerEvent, MembershipManagerEvent,
@@ -110,7 +112,13 @@ import { type Behavior } from "./Behavior";
export interface CallViewModelOptions { export interface CallViewModelOptions {
encryptionSystem: EncryptionSystem; encryptionSystem: EncryptionSystem;
autoLeaveWhenOthersLeft?: boolean; autoLeaveWhenOthersLeft?: boolean;
/**
* If the call is started in a way where we want it to behave like a telephone usecase
* If we sent a notification event, we want the ui to show a ringing state
*/
waitForNotificationAnswer?: boolean;
} }
// How long we wait after a focus switch before showing the real participant // How long we wait after a focus switch before showing the real participant
// list again // list again
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000; const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
@@ -427,7 +435,14 @@ export class CallViewModel extends ViewModel {
MembershipManagerEvent.StatusChanged, MembershipManagerEvent.StatusChanged,
).pipe( ).pipe(
startWith(null), startWith(null),
map(() => this.matrixRTCSession.membershipStatus === Status.Connected), map(
() =>
(
this.matrixRTCSession as unknown as {
membershipStatus?: Status;
}
).membershipStatus === Status.Connected,
),
), ),
// Also watch out for warnings that we've likely hit a timeout and our // Also watch out for warnings that we've likely hit a timeout and our
// delayed leave event is being sent (this condition is here because it // delayed leave event is being sent (this condition is here because it
@@ -438,7 +453,11 @@ export class CallViewModel extends ViewModel {
MembershipManagerEvent.ProbablyLeft, MembershipManagerEvent.ProbablyLeft,
).pipe( ).pipe(
startWith(null), startWith(null),
map(() => this.matrixRTCSession.probablyLeft !== true), map(
() =>
(this.matrixRTCSession as unknown as { probablyLeft?: boolean })
.probablyLeft !== true,
),
), ),
), ),
); );
@@ -825,49 +844,68 @@ export class CallViewModel extends ViewModel {
* - Each participant has a corresponding MatrixRTC membership state event * - Each participant has a corresponding MatrixRTC membership state event
* - There can be multiple participants for one matrix user. * - There can be multiple participants for one matrix user.
*/ */
public readonly participantChanges$ = this.userMedia$.pipe( public readonly participantChanges$ = this.scope.behavior(
map((mediaItems) => mediaItems.map((m) => m.id)), this.userMedia$.pipe(
scan<string[], { ids: string[]; joined: string[]; left: string[] }>( map((mediaItems) => mediaItems.map((m) => m.id)),
(prev, ids) => { scan<string[], { ids: string[]; joined: string[]; left: string[] }>(
const left = prev.ids.filter((id) => !ids.includes(id)); (prev, ids) => {
const joined = ids.filter((id) => !prev.ids.includes(id)); const left = prev.ids.filter((id) => !ids.includes(id));
return { ids, joined, left }; const joined = ids.filter((id) => !prev.ids.includes(id));
}, return { ids, joined, left };
{ ids: [], joined: [], left: [] }, },
{ ids: [], joined: [], left: [] },
),
), ),
); );
/**
* The number of participants currently in the call.
*
* - Each participant has one livekit connection
* - Each participant has a corresponding MatrixRTC membership state event
* - There can be multiple participants for one matrix user.
*/
public readonly participantCount$ = this.scope.behavior(
this.participantChanges$.pipe(map(({ ids }) => ids.length)),
);
/** /**
* This observable tracks the matrix users that are currently in the call. * This observable tracks the matrix users that are currently in the call.
* There can be just one matrix user with multiple participants (see also participantChanges$) * There can be just one matrix user with multiple participants (see also participantChanges$)
*/ */
public readonly matrixUserChanges$ = this.userMedia$.pipe( public readonly matrixUserChanges$ = this.scope.behavior(
map( this.userMedia$.pipe(
(mediaItems) => map(
new Set( (mediaItems) =>
mediaItems new Set(
.map((m) => m.vm.member?.userId) mediaItems
.filter((id) => id !== undefined), .map((m) => m.vm.member?.userId)
), .filter((id) => id !== undefined),
), ),
scan< ),
Set<string>, scan<
{ Set<string>,
userIds: Set<string>; {
joinedUserIds: Set<string>; userIds: Set<string>;
leftUserIds: Set<string>; joinedUserIds: Set<string>;
} leftUserIds: Set<string>;
>( }
(prevState, userIds) => { >(
const left = new Set( (prevState, userIds) => {
[...prevState.userIds].filter((id) => !userIds.has(id)), const left = new Set(
); [...prevState.userIds].filter((id) => !userIds.has(id)),
const joined = new Set( );
[...userIds].filter((id) => !prevState.userIds.has(id)), const joined = new Set(
); [...userIds].filter((id) => !prevState.userIds.has(id)),
return { userIds: userIds, joinedUserIds: joined, leftUserIds: left }; );
}, return { userIds: userIds, joinedUserIds: joined, leftUserIds: left };
{ userIds: new Set(), joinedUserIds: new Set(), leftUserIds: new Set() }, },
{
userIds: new Set(),
joinedUserIds: new Set(),
leftUserIds: new Set(),
},
),
), ),
); );
@@ -891,6 +929,84 @@ export class CallViewModel extends ViewModel {
map(() => {}), map(() => {}),
); );
/**
* "unknown": 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.
* "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(
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")),
);
}
// If no lifetime, just emit true once
return of(null);
}),
startWith("unknown" as "unknown" | null),
);
/**
* If some other matrix user has joined the call. It can start with true if there are already multiple matrix users.
*/
private readonly someoneElseJoined$ = this.matrixUserChanges$.pipe(
scan(
(someoneJoined, { joinedUserIds }) =>
someoneJoined || [...joinedUserIds].some((id) => id !== this.userId),
false,
),
startWith(this.matrixUserChanges$.value.userIds.size > 1),
);
/**
* The current waiting for answer state of the call.
* - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening).
* - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership.
* Then we can conclude if we were the first one to join or not.
* - "timeout": No-one picked up in the defined time this call should be ringing on others devices.
* The call failed. If desired this can be used as a trigger to exit the call.
* - "success": Someone else joined. The call is in a normal state. Stop audiovisual feedback.
* - null: EC is configured to never show any waiting for answer state.
*/
public readonly waitForNotificationAnswer$: Behavior<
"unknown" | "ringing" | "timeout" | "success" | null
> = this.scope.behavior(
combineLatest([
this.notificationEventIsRingingOthers$,
this.someoneElseJoined$,
]).pipe(
map(([isRingingOthers, someoneJoined]) => {
// Never enter waiting for answer state if the app is not configured with waitingForAnswer.
if (!this.options.waitForNotificationAnswer) return null;
// As soon as someone joins, we can consider the call "wait for answer" successful
if (someoneJoined) return "success";
switch (isRingingOthers) {
case "unknown":
return "unknown";
case "ringing":
return "ringing";
case "ringEnded":
return "timeout";
default:
return "timeout";
}
}),
distinctUntilChanged(),
),
);
/** /**
* List of MediaItems that we want to display, that are of type ScreenShare * List of MediaItems that we want to display, that are of type ScreenShare
*/ */

View File

@@ -10278,9 +10278,9 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop": "matrix-js-sdk@portal:/Users/timo/Projects/matrix-js-sdk::locator=element-call%40workspace%3A.":
version: 37.13.0 version: 0.0.0-use.local
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=3a33c658bbcb8ce8791ec066db899f2571f5c52f" resolution: "matrix-js-sdk@portal:/Users/timo/Projects/matrix-js-sdk::locator=element-call%40workspace%3A."
dependencies: dependencies:
"@babel/runtime": "npm:^7.12.5" "@babel/runtime": "npm:^7.12.5"
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0" "@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0"
@@ -10296,9 +10296,8 @@ __metadata:
sdp-transform: "npm:^2.14.1" sdp-transform: "npm:^2.14.1"
unhomoglyph: "npm:^1.0.6" unhomoglyph: "npm:^1.0.6"
uuid: "npm:11" uuid: "npm:11"
checksum: 10c0/1db0d39cfbe4f1c69c8acda0ea7580a4819fc47a7d4bff057382e33e72d9a610f8c03043a6c00bc647dfdc2815aa643c69d25022fb759342a92b77e1841524f1
languageName: node languageName: node
linkType: hard linkType: soft
"matrix-widget-api@npm:^1.10.0, matrix-widget-api@npm:^1.13.0": "matrix-widget-api@npm:^1.10.0, matrix-widget-api@npm:^1.13.0":
version: 1.13.1 version: 1.13.1