Merge pull request #4034 from element-hq/ringing-intent

Improve internal model of ringing, expose ringing intent to call UI
This commit is contained in:
Robin
2026-06-18 12:00:34 +02:00
committed by GitHub
11 changed files with 464 additions and 559 deletions

View File

@@ -272,7 +272,7 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
expect(leaveRTCSession).toHaveBeenCalledOnce(); expect(leaveRTCSession).toHaveBeenCalledOnce();
}); });
test("Should close widget when all other left and have time to play a sound", async () => { test("Should close widget when all other left and play a sound", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
let widgetClosedCalled = false; let widgetClosedCalled = false;
const { promise: widgetClosedPromise, resolve: widgetClosedResolver } = const { promise: widgetClosedPromise, resolve: widgetClosedResolver } =
@@ -310,8 +310,6 @@ test("Should close widget when all other left and have time to play a sound", as
expect(widgetClosedCalled).toBeFalsy(); expect(widgetClosedCalled).toBeFalsy();
resolvePlaySound.resolve(); resolvePlaySound.resolve();
// Expect the leave sound to be played but silent (volumeOverwrite = 0)
// The allOthersLeft effect should already play a leave sound for the last user in the call.
expect(playSound).toHaveBeenCalledWith("left", 0); expect(playSound).toHaveBeenCalledWith("left", 0);
await widgetClosedPromise; await widgetClosedPromise;
await flushPromises(); await flushPromises();
@@ -319,37 +317,6 @@ test("Should close widget when all other left and have time to play a sound", as
expect(widgetStopMock).toHaveBeenCalledOnce(); expect(widgetStopMock).toHaveBeenCalledOnce();
}, 80000); }, 80000);
test("Should close widget when all other left", async () => {
const user = userEvent.setup();
const widgetClosedCalled = Promise.withResolvers<void>();
const widgetSendMock = vi.fn().mockImplementation((action: string) => {
if (action === ElementWidgetActions.Close) {
widgetClosedCalled.resolve();
}
});
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
const widget = {
api: {
setAlwaysOnScreen: vi.fn().mockResolvedValue(true),
transport: {
send: widgetSendMock,
reply: vi.fn().mockResolvedValue(undefined),
stop: widgetStopMock,
} as unknown as ITransport,
} as Partial<WidgetHelpers["api"]>,
lazyActions: new LazyEventEmitter(),
};
const { getByText } = createGroupCallView(widget as WidgetHelpers);
const leaveButton = getByText("SimulateOtherLeft");
await user.click(leaveButton);
await flushPromises();
await widgetClosedCalled.promise;
await flushPromises();
expect(widgetStopMock).toHaveBeenCalledOnce();
});
test("Should not close widget when auto leave due to error", async () => { test("Should not close widget when auto leave due to error", async () => {
const user = userEvent.setup(); const user = userEvent.setup();

View File

@@ -266,7 +266,7 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(), () => void toggleRaisedHand(),
); );
const ringing = useBehavior(vm.ringing$); const ringingIntent = useBehavior(vm.ringingIntent$);
const audioParticipants = useBehavior(vm.livekitRoomItems$); const audioParticipants = useBehavior(vm.livekitRoomItems$);
const participantCount = useBehavior(vm.participantCount$); const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$); const reconnecting = useBehavior(vm.reconnecting$);
@@ -289,7 +289,7 @@ export const InCallView: FC<InCallViewProps> = ({
// While ringing, loop the ringtone // While ringing, loop the ringtone
useEffect((): void | (() => void) => { useEffect((): void | (() => void) => {
const audio = latestPickupPhaseAudio.current; const audio = latestPickupPhaseAudio.current;
if (ringing && audio) { if (ringingIntent !== null && audio) {
const endSound = audio.playSoundLooping( const endSound = audio.playSoundLooping(
"waiting", "waiting",
audio.soundDuration["waiting"] ?? 1, audio.soundDuration["waiting"] ?? 1,
@@ -300,7 +300,7 @@ export const InCallView: FC<InCallViewProps> = ({
}); });
}; };
} }
}, [ringing, latestPickupPhaseAudio]); }, [ringingIntent, latestPickupPhaseAudio]);
// iOS Safari doesn't reliably fire `click` on plain <div>s, so we listen // iOS Safari doesn't reliably fire `click` on plain <div>s, so we listen
// for `pointerup` instead. Scrolls end in `pointercancel`, not `pointerup`, // for `pointerup` instead. Scrolls end in `pointercancel`, not `pointerup`,

View File

@@ -5,17 +5,19 @@ 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 { describe, it } from "vitest"; import { test } from "vitest";
import { import {
EventType, EventType,
type IEvent,
type IRoomTimelineData, type IRoomTimelineData,
MatrixEvent, MatrixEvent,
type Room, type Room,
} from "matrix-js-sdk"; } from "matrix-js-sdk";
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import { map, mergeMap, NEVER, type Observable, startWith } from "rxjs";
import { withTestScheduler } from "../../utils/test"; import { withTestScheduler } from "../../utils/test";
import { import {
alice,
aliceRtcMember, aliceRtcMember,
local, local,
localRtcMember, localRtcMember,
@@ -23,9 +25,10 @@ import {
import { import {
type CallNotificationWrapper, type CallNotificationWrapper,
createCallNotificationLifecycle$, createCallNotificationLifecycle$,
type Props as CallNotificationLifecycleProps, type RingAttempt,
} from "./CallNotificationLifecycle"; } from "./CallNotificationLifecycle";
import { trackEpoch } from "../ObservableScope"; import { Epoch, trackEpoch } from "../ObservableScope";
import { constant } from "../Behavior";
function mockRingEvent( function mockRingEvent(
eventId: string, eventId: string,
@@ -40,182 +43,153 @@ function mockRingEvent(
} as unknown as CallNotificationWrapper; } as unknown as CallNotificationWrapper;
} }
describe("waitForCallPickup$", () => { const defaultProps = {
it("unknown -> ringing -> timeout when notified and nobody joins", () => { memberships$: constant(new Epoch([])),
withTestScheduler(({ scope, expectObservable, behavior, hot }) => { matrixRoomMembers$: constant(new Map([[alice.userId, alice]])),
// No one ever joins (only local user) receivedDecline$: NEVER,
const props: CallNotificationLifecycleProps = { options: {
scope, waitForCallPickup: true,
memberships$: scope.behavior( autoLeaveWhenOthersLeft: false,
behavior("a", { a: [] }).pipe(trackEpoch()), },
localUser: localRtcMember,
};
function summarizeRingAttempts$(
ringAttempts$: Observable<RingAttempt>,
): Observable<
| { intent: RTCCallIntent; recipient: string }
| { outcome: "accept" | "decline" | "timeout" }
> {
return ringAttempts$.pipe(
mergeMap(({ intent, recipient, outcome$ }) =>
outcome$.pipe(
map((outcome) => ({ outcome })),
startWith({ intent, recipient }),
), ),
sentCallNotification$: hot("10ms a", { ),
);
}
test("no ring attempt when waitForCallPickup=false", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30), a: mockRingEvent("$notif1", 30),
}), }),
receivedDecline$: hot(""), options: { ...defaultProps.options, waitForCallPickup: false },
options: { });
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false, expectObservable(ringAttempts$).toBe("");
});
});
test("no ring attempt when notification type is not ring", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
sentCallNotification$: hot("-a", {
a: {
...mockRingEvent("$notif1", 30),
notification_type: "notification",
}, },
localUser: localRtcMember, }),
}; });
const lifecycle = createCallNotificationLifecycle$(props); expectObservable(ringAttempts$).toBe("");
});
});
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b 29ms c", { test("no ring attempt if lifetime is missing", () => {
a: "unknown", withTestScheduler(({ scope, expectObservable, hot }) => {
b: "ringing", const { ringAttempts$ } = createCallNotificationLifecycle$({
c: "timeout", scope,
...defaultProps,
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", undefined),
}),
});
expectObservable(ringAttempts$).toBe("");
});
});
test("ring attempt times out after nobody joins", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
// No one ever joins (only local user)
memberships$: constant(new Epoch([])),
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30),
}),
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "timeout" },
}); });
}); });
}); });
it("ringing -> success if someone joins before timeout is reached", () => { test("ring attempt is accepted once recipient joins", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => { withTestScheduler(({ scope, expectObservable, hot, behavior }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member) const { ringAttempts$ } = createCallNotificationLifecycle$({
const props: CallNotificationLifecycleProps = {
scope, scope,
...defaultProps,
memberships$: scope.behavior( memberships$: scope.behavior(
behavior("a 19ms b", { behavior("a-b", { a: [], b: [aliceRtcMember] }).pipe(trackEpoch()),
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}).pipe(trackEpoch()),
), ),
sentCallNotification$: hot("5ms a", { sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif2", 100), a: mockRingEvent("$notif1", 30),
}), }),
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",
}); });
});
}); expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
it("success when someone joins before we notify", () => { a: { intent: "audio", recipient: alice.userId },
withTestScheduler(({ scope, hot, behavior, expectObservable }) => { A: { outcome: "accept" },
// 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),
}),
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),
}),
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", () => { test("ring attempt is immediately accepted if recipient is already joined", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => { withTestScheduler(({ scope, expectObservable, hot }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member) const { ringAttempts$ } = createCallNotificationLifecycle$({
const validProps: CallNotificationLifecycleProps = {
scope, scope,
memberships$: scope.behavior( ...defaultProps,
behavior("a--b", { memberships$: constant(new Epoch([aliceRtcMember])),
a: [localRtcMember], sentCallNotification$: hot("-a", {
b: [localRtcMember, aliceRtcMember], a: mockRingEvent("$notif1", 30),
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: mockRingEvent("$notif5", 30),
}), }),
receivedDecline$: hot(""),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const propsDeactivated = {
...validProps,
options: {
...validProps.options,
waitForCallPickup: false,
},
};
const lifecycle = createCallNotificationLifecycle$(propsDeactivated);
expectObservable(lifecycle.callPickupState$).toBe("n", {
n: null,
}); });
const lifecycleReference = createCallNotificationLifecycle$(validProps);
expectObservable(lifecycleReference.callPickupState$).toBe("u--s", { expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-(aA)", {
u: "unknown", a: { intent: "audio", recipient: alice.userId },
s: "success", A: { outcome: "accept" },
}); });
}); });
}); });
it("decline before timeout window ends -> decline", () => { test("ring attempt can be declined", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => { withTestScheduler(({ scope, expectObservable, hot }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member) const { ringAttempts$ } = createCallNotificationLifecycle$({
const props: CallNotificationLifecycleProps = {
scope, scope,
memberships$: scope.behavior( ...defaultProps,
behavior("a", { sentCallNotification$: hot("-a", {
a: [localRtcMember], a: mockRingEvent("$notif1", 30),
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: mockRingEvent("$decl1", 50),
}), }),
receivedDecline$: hot("40ms d", { receivedDecline$: hot("--d", {
d: [ d: [
new MatrixEvent({ new MatrixEvent({
type: EventType.RTCDecline, type: EventType.RTCDecline,
sender: alice.userId,
content: { content: {
"m.relates_to": { "m.relates_to": {
rel_type: "m.reference", rel_type: "m.reference",
event_id: "$decl1", event_id: "$notif1",
}, },
}, },
}), }),
@@ -225,41 +199,32 @@ describe("waitForCallPickup$", () => {
{} as IRoomTimelineData, {} as IRoomTimelineData,
], ],
}), }),
options: { });
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false, expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
}, a: { intent: "audio", recipient: alice.userId },
localUser: localRtcMember, A: { outcome: "decline" },
};
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b 29ms e", {
a: "unknown",
b: "ringing",
e: "decline",
}); });
}); });
}); });
it("decline after timeout window ends -> stays timeout", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => { test("ring attempt times out if recipient declines too late", () => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member) withTestScheduler(({ scope, expectObservable, hot }) => {
const props: CallNotificationLifecycleProps = { const { ringAttempts$ } = createCallNotificationLifecycle$({
scope, scope,
memberships$: scope.behavior( ...defaultProps,
behavior("a", { sentCallNotification$: hot("-a", {
a: [localRtcMember], a: mockRingEvent("$notif1", 30),
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: mockRingEvent("$decl", 20),
}), }),
receivedDecline$: hot("40ms d", { receivedDecline$: hot("100ms d", {
d: [ d: [
new MatrixEvent({ new MatrixEvent({
type: EventType.RTCDecline, type: EventType.RTCDecline,
sender: alice.userId,
content: { content: {
"m.relates_to": { "m.relates_to": {
rel_type: "m.reference", rel_type: "m.reference",
event_id: "$decl", event_id: "$notif1",
}, },
}, },
}), }),
@@ -269,82 +234,81 @@ describe("waitForCallPickup$", () => {
{} as IRoomTimelineData, {} as IRoomTimelineData,
], ],
}), }),
options: { });
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false, expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
}, a: { intent: "audio", recipient: alice.userId },
localUser: localRtcMember, A: { outcome: "timeout" },
};
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$, "50ms !").toBe(
"a 9ms b 19ms e",
{
a: "unknown",
b: "ringing",
e: "timeout",
},
);
}); });
}); });
// });
function testStaysRinging(
declineEvent: Partial<IEvent>, test("decline event relating to wrong event is ignored (times out)", () => {
expectDecline: boolean, withTestScheduler(({ scope, expectObservable, hot }) => {
): void { const { ringAttempts$ } = createCallNotificationLifecycle$({
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope, scope,
memberships$: scope.behavior( ...defaultProps,
behavior("a", { sentCallNotification$: hot("-a", {
a: [localRtcMember], a: mockRingEvent("$notif1", 30),
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: mockRingEvent("$right", 50),
}), }),
receivedDecline$: hot("20ms d", { receivedDecline$: hot("--d", {
d: [ d: [
new MatrixEvent(declineEvent), new MatrixEvent({
type: EventType.RTCDecline,
sender: alice.userId,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$other", // <---- WRONG
},
},
}),
{} as Room, {} as Room,
undefined, undefined,
false, false,
{} as IRoomTimelineData, {} as IRoomTimelineData,
], ],
}), }),
options: { });
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false, expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
}, a: { intent: "audio", recipient: alice.userId },
localUser: localRtcMember, A: { outcome: "timeout" },
};
const lifecycle = createCallNotificationLifecycle$(props);
const marbles = expectDecline ? "a 9ms b 9ms d" : "a 9ms b";
expectObservable(lifecycle.callPickupState$, "21ms !").toBe(marbles, {
a: "unknown",
b: "ringing",
d: "decline",
}); });
}); });
} });
const reference = (refId?: string, sender?: string): Partial<IEvent> => ({
event_id: "$decline", test("decline event from wrong sender is ignored (times out)", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30),
}),
receivedDecline$: hot("--d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline, type: EventType.RTCDecline,
sender: sender ?? "@other:example.org", sender: local.userId, // <---- WRONG
content: { content: {
"m.relates_to": { "m.relates_to": {
rel_type: "m.reference", rel_type: "m.reference",
event_id: refId ?? "$right", event_id: "$notif1",
}, },
}, },
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
}),
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "timeout" },
}); });
it("decline reference works", () => {
testStaysRinging(reference(), true);
});
it("decline with wrong id is ignored (stays ringing)", () => {
testStaysRinging(reference("$wrong"), false);
});
it("decline with wrong id is ignored (stays ringing)", () => {
testStaysRinging(reference(undefined, local.userId), false);
}); });
}); });

View File

@@ -10,24 +10,22 @@ import {
type IRTCNotificationContent, type IRTCNotificationContent,
type MatrixRTCSession, type MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
type RTCCallIntent,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { import {
combineLatest,
concat,
endWith,
filter, filter,
fromEvent, fromEvent,
ignoreElements,
map, map,
merge, merge,
NEVER, NEVER,
type Observable, type Observable,
of, of,
pairwise, pairwise,
startWith,
switchMap, switchMap,
takeUntil,
timer, timer,
EMPTY,
race,
take,
} from "rxjs"; } from "rxjs";
import { import {
type EventTimelineSetHandlerMap, type EventTimelineSetHandlerMap,
@@ -35,18 +33,28 @@ import {
type Room as MatrixRoom, type Room as MatrixRoom,
RoomEvent, RoomEvent,
} from "matrix-js-sdk"; } from "matrix-js-sdk";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../Behavior"; import { type Behavior } from "../Behavior";
import { type Epoch, mapEpoch, type ObservableScope } from "../ObservableScope"; import { type Epoch, type ObservableScope } from "../ObservableScope";
import { type RoomMemberMap } from "./remoteMembers/MatrixMemberMetadata";
const logger = rootLogger.getChild("[CallNotificationLifecycle]");
export type AutoLeaveReason = "allOthersLeft" | "timeout" | "decline"; export type AutoLeaveReason = "allOthersLeft" | "timeout" | "decline";
export type CallPickupState =
| "unknown" export interface RingAttempt {
| "ringing" intent: RTCCallIntent;
| "timeout" /**
| "decline" * The user ID of the recipient being rung.
| "success" */
| null; recipient: string;
/**
* The eventual outcome of the ringing attempt. (Emits a single value.)
*/
// TODO: Include a callback for attempting ringing again in case of a timeout
outcome$: Observable<"accept" | "decline" | "timeout">;
}
export type CallNotificationWrapper = { export type CallNotificationWrapper = {
event_id: string; event_id: string;
@@ -76,6 +84,7 @@ export function createReceivedDecline$(
export interface Props { export interface Props {
scope: ObservableScope; scope: ObservableScope;
memberships$: Behavior<Epoch<CallMembership[]>>; memberships$: Behavior<Epoch<CallMembership[]>>;
matrixRoomMembers$: Behavior<RoomMemberMap>;
sentCallNotification$: Observable<CallNotificationWrapper | null>; sentCallNotification$: Observable<CallNotificationWrapper | null>;
receivedDecline$: Observable< receivedDecline$: Observable<
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]> Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
@@ -84,34 +93,81 @@ export interface Props {
localUser: { deviceId: string; userId: string }; localUser: { deviceId: string; userId: string };
} }
/**
* @returns two observables:
* `callPickupState$` The current call pickup state of the call.
* - "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.
* - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening).
* - "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. No audiovisual feedback.
* - null: EC is configured to never show any waiting for answer state.
*
* `autoLeave$` An observable that emits (null) when the call should be automatically left.
* - if options.autoLeaveWhenOthersLeft is set to true it emits when all others left.
* - if options.waitForCallPickup is set to true it emits if noone picked up the ring or if the ring got declined.
* - if options.autoLeaveWhenOthersLeft && options.waitForCallPickup is false it will never emit.
*
*/
export function createCallNotificationLifecycle$({ export function createCallNotificationLifecycle$({
scope, scope,
memberships$, memberships$,
matrixRoomMembers$,
sentCallNotification$, sentCallNotification$,
receivedDecline$, receivedDecline$,
options, options,
localUser, localUser,
}: Props): { }: Props): {
callPickupState$: Behavior<CallPickupState>; /**
* An observable of attempts to ring the remote participant's devices.
*/
ringAttempts$: Observable<RingAttempt>;
/**
* An observable that emits when the call should be automatically left.
* - if options.autoLeaveWhenOthersLeft is set to true it emits when all others left.
* - if options.waitForCallPickup is set to true it emits if noone picked up the ring or if the ring got declined.
* - if options.autoLeaveWhenOthersLeft && options.waitForCallPickup is false it will never emit.
*/
autoLeave$: Observable<AutoLeaveReason>; autoLeave$: Observable<AutoLeaveReason>;
} { } {
let ringAttempts$: Observable<RingAttempt> = NEVER;
if (options.waitForCallPickup)
ringAttempts$ = sentCallNotification$.pipe(
filter(
(
notificationEvent: CallNotificationWrapper | null,
): notificationEvent is CallNotificationWrapper =>
// only care about new events (legacy do not have decline pattern)
notificationEvent?.notification_type === "ring" &&
notificationEvent.lifetime > 0,
),
switchMap((notificationEvent) => {
// We assume that there is only one other user in the room when ringing
// TODO: Respect io.element.functional_members
const recipient = [...matrixRoomMembers$.value.keys()].find(
(userId) => userId !== localUser.userId,
);
if (recipient === undefined) {
logger.warn("No recipient for notification event; not ringing.");
return EMPTY;
}
// Ringing times out after lifetime ms have passed
const timeout$ = timer(notificationEvent.lifetime).pipe(
map(() => "timeout" as const),
);
// Call is accepted when the recipient joins
const accept$ = memberships$.pipe(
filter((ms) => ms.value.some((m) => m.userId === recipient)),
map(() => "accept" as const),
);
// Call is declined when we receive a decline event
const decline$ = receivedDecline$.pipe(
filter(
([event]) =>
event.getRelation()?.rel_type === "m.reference" &&
event.getRelation()?.event_id === notificationEvent.event_id &&
event.getSender() === recipient,
),
map(() => "decline" as const),
);
return of({
intent: notificationEvent["m.call.intent"] ?? "audio",
recipient,
outcome$: race(timeout$, accept$, decline$).pipe(
take(1),
scope.share,
),
});
}),
scope.share,
);
const allOthersLeft$ = memberships$.pipe( const allOthersLeft$ = memberships$.pipe(
pairwise(), pairwise(),
filter( filter(
@@ -122,87 +178,18 @@ export function createCallNotificationLifecycle$({
map(() => {}), map(() => {}),
); );
/**
* Whether some Matrix user other than ourself is joined to the call.
*/
const someoneElseJoined$ = memberships$.pipe(
mapEpoch((ms) => ms.some((m) => m.userId !== localUser.userId)),
) as Behavior<Epoch<boolean>>;
/**
* The state of the current ringing attempt, if the RTC session is indeed
* ringing the remote participant's devices. Otherwise `null`.
*/
const remoteRingState$: Behavior<"ringing" | "timeout" | "decline" | null> =
scope.behavior(
sentCallNotification$.pipe(
filter(
(notificationEventArgs: CallNotificationWrapper | null) =>
// only care about new events (legacy do not have decline pattern)
notificationEventArgs?.notification_type === "ring",
),
map((e) => e as CallNotificationWrapper),
switchMap((notificationEvent) => {
const lifetimeMs = notificationEvent?.lifetime ?? 0;
return concat(
lifetimeMs === 0
? // If no lifetime, skip the ring state
of(null)
: // 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),
// This makes sure we will not drop into the `endWith("decline" as const)` state
NEVER,
).pipe(
takeUntil(
receivedDecline$.pipe(
filter(
([event]) =>
event.getRelation()?.rel_type === "m.reference" &&
event.getRelation()?.event_id ===
notificationEvent.event_id &&
event.getSender() !== localUser.userId &&
callPickupState$.value !== "timeout",
),
),
),
endWith("decline" as const),
);
}),
),
null,
);
const callPickupState$ = scope.behavior(
options.waitForCallPickup === true
? combineLatest(
[someoneElseJoined$, remoteRingState$],
(someoneElseJoined, ring) => {
if (someoneElseJoined.value === true) {
return "success" as const;
}
// Show the ringing state of the most recent ringing attempt.
// as long as we have not yet sent an RTC notification event or noone else joined,
// ring will be null -> callPickupState$ = unknown.
return ring ?? ("unknown" as const);
},
)
: NEVER,
null,
);
const autoLeave$ = merge( const autoLeave$ = merge(
options.autoLeaveWhenOthersLeft === true options.autoLeaveWhenOthersLeft === true
? allOthersLeft$.pipe(map(() => "allOthersLeft" as const)) ? allOthersLeft$.pipe(map(() => "allOthersLeft" as const))
: NEVER, : NEVER,
callPickupState$.pipe( ringAttempts$.pipe(
filter((state) => state === "timeout" || state === "decline"), switchMap(({ outcome$ }) =>
outcome$.pipe(
filter((outcome) => outcome === "timeout" || outcome === "decline"),
),
),
), ),
); );
return { autoLeave$, callPickupState$ };
return { ringAttempts$, autoLeave$ };
} }

View File

@@ -1421,7 +1421,10 @@ describe.each([
}); });
// Should ring for 30ms and then time out // Should ring for 30ms and then time out
expectObservable(vm.ringing$).toBe("(ny) 26ms n", yesNo); expectObservable(vm.ringingIntent$).toBe("(ab) 26ms a", {
a: null,
b: "audio",
});
// Layout should show placeholder media for the participant we're // Layout should show placeholder media for the participant we're
// ringing the entire time (even once timed out) // ringing the entire time (even once timed out)
expectObservable(summarizeLayout$(vm.layout$)).toBe("a", { expectObservable(summarizeLayout$(vm.layout$)).toBe("a", {
@@ -1460,7 +1463,10 @@ describe.each([
}); });
// Should ring until Alice joins // Should ring until Alice joins
expectObservable(vm.ringing$).toBe("(ny) 17ms n", yesNo); expectObservable(vm.ringingIntent$).toBe("(ab) 17ms a", {
a: null,
b: "audio",
});
// Layout should show placeholder media for the participant we're // Layout should show placeholder media for the participant we're
// ringing the entire time // ringing the entire time
expectObservable(summarizeLayout$(vm.layout$)).toBe("a 20ms b", { expectObservable(summarizeLayout$(vm.layout$)).toBe("a 20ms b", {

View File

@@ -29,7 +29,6 @@ import {
pairwise, pairwise,
race, race,
scan, scan,
skipWhile,
startWith, startWith,
Subject, Subject,
switchAll, switchAll,
@@ -39,10 +38,13 @@ import {
tap, tap,
throttleTime, throttleTime,
timer, timer,
takeUntil,
concat,
} from "rxjs"; } from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { import {
MembershipManagerEvent, MembershipManagerEvent,
type RTCCallIntent,
type LivekitTransportConfig, type LivekitTransportConfig,
type MatrixRTCSession, type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
@@ -230,9 +232,9 @@ export interface CallViewModel {
// lifecycle // lifecycle
autoLeave$: Observable<AutoLeaveReason>; autoLeave$: Observable<AutoLeaveReason>;
/** /**
* Whether we are ringing a call recipient. * Whether we are ringing a call recipient. Contains the ringing intent if so.
*/ */
ringing$: Behavior<boolean>; ringingIntent$: Behavior<RTCCallIntent | null>;
/** Observable that emits when the user should leave the call (hangup pressed, widget action, error). /** Observable that emits when the user should leave the call (hangup pressed, widget action, error).
* THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is * THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is
* - by ending the scope * - by ending the scope
@@ -610,20 +612,6 @@ export function createCallViewModel$(
), ),
); );
// ------------------------------------------------------------------------
// callLifecycle
// TODO if we are in "unknown" state we need a loading rendering (or empty screen)
// Otherwise it looks like we already connected and only than the ringing starts which is weird.
const { callPickupState$, autoLeave$ } = createCallNotificationLifecycle$({
scope: scope,
memberships$: memberships$,
sentCallNotification$: createSentCallNotification$(scope, matrixRTCSession),
receivedDecline$: createReceivedDecline$(matrixRoom),
options: options,
localUser: { userId: userId, deviceId: deviceId },
});
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
// matrixMemberMetadataStore // matrixMemberMetadataStore
@@ -634,6 +622,21 @@ export function createCallViewModel$(
matrixRoomMembers$, matrixRoomMembers$,
); );
// ------------------------------------------------------------------------
// callLifecycle
// TODO if we are in "unknown" state we need a loading rendering (or empty screen)
// Otherwise it looks like we already connected and only than the ringing starts which is weird.
const { ringAttempts$, autoLeave$ } = createCallNotificationLifecycle$({
scope,
memberships$,
matrixRoomMembers$,
sentCallNotification$: createSentCallNotification$(scope, matrixRTCSession),
receivedDecline$: createReceivedDecline$(matrixRoom),
options,
localUser: { userId, deviceId },
});
const allConnections$ = scope.behavior( const allConnections$ = scope.behavior(
connectionManager.connectionManagerData$.pipe(map((d) => d.value)), connectionManager.connectionManagerData$.pipe(map((d) => d.value)),
); );
@@ -784,51 +787,42 @@ export function createCallViewModel$(
), ),
); );
const ringingMedia$ = scope.behavior<RingingMediaViewModel[]>( const ringingMedia$ = scope.behavior<RingingMediaViewModel | null>(
combineLatest([userMedia$, matrixRoomMembers$, callPickupState$]).pipe( ringAttempts$.pipe(
switchMap(({ intent, recipient, outcome$ }) =>
outcome$.pipe(
startWith("ringing" as const),
generateItems( generateItems(
"CallViewModel ringingMedia$", "CallViewModel ringingMedia$",
function* ([userMedia, roomMembers, callPickupState]) { function* (pickupState) {
if ( if (pickupState !== "accept")
callPickupState === "ringing" || yield { keys: [intent, recipient], data: pickupState };
callPickupState === "timeout" ||
callPickupState === "decline"
) {
// TODO: Respect io.element.functional_members
for (const member of roomMembers.values()) {
if (!userMedia.some((vm) => vm.userId === member.userId))
yield {
keys: [member.userId],
data: callPickupState,
};
}
}
}, },
(scope, pickupState$, userId) => (scope, pickupState$, intent, userId) =>
createRingingMedia({ createRingingMedia({
id: `ringing:${userId}`, id: `ringing:${userId}`,
userId, userId,
displayName$: scope.behavior( displayName$: scope.behavior(
matrixRoomMembers$.pipe( matrixRoomMembers$.pipe(
map((members) => members.get(userId)?.rawDisplayName || userId), map(
(members) =>
members.get(userId)?.rawDisplayName || userId,
), ),
), ),
mxcAvatarUrl$: matrixMemberMetadataStore.createAvatarUrlBehavior$( ),
mxcAvatarUrl$:
matrixMemberMetadataStore.createAvatarUrlBehavior$(
scope, scope,
userId, userId,
), ),
pickupState$, pickupState$,
muteStates, intent,
}), }),
), ),
distinctUntilChanged(shallowEquals), map(([media]) => media ?? null),
tap((ringingMedia) => { ),
if (ringingMedia.length > 1) ),
// Warn that UI may do something unexpected in this case startWith(null),
logger.warn(
`Ringing more than one participant is not supported (ringing ${ringingMedia.map((vm) => vm.userId).join(", ")})`,
);
}),
), ),
); );
@@ -870,11 +864,7 @@ export function createCallViewModel$(
matrixLivekitMembers$.pipe(map((ms) => ms.value.length)), matrixLivekitMembers$.pipe(map((ms) => ms.value.length)),
); );
const leaveSoundEffect$ = combineLatest([callPickupState$, userMedia$]).pipe( const leaveSoundEffect$ = userMedia$.pipe(
// Until the call is successful, do not play a leave sound.
// If callPickupState$ is null, then we always play the sound as it will not conflict with a decline sound.
skipWhile(([c]) => c !== null && c !== "success"),
map(([, userMedia]) => userMedia),
pairwise(), pairwise(),
filter( filter(
([prev, current]) => ([prev, current]) =>
@@ -883,6 +873,9 @@ export function createCallViewModel$(
), ),
map(() => {}), map(() => {}),
throttleTime(THROTTLE_SOUND_EFFECT_MS), throttleTime(THROTTLE_SOUND_EFFECT_MS),
// Avoid doubling up on any auto-leave sounds (e.g. the decline sound),
// which are handled elsewhere
takeUntil(autoLeave$),
); );
const userHangup$ = new Subject<void>(); const userHangup$ = new Subject<void>();
@@ -987,8 +980,8 @@ export function createCallViewModel$(
}>( }>(
ringingMedia$.pipe( ringingMedia$.pipe(
switchMap((ringingMedia) => { switchMap((ringingMedia) => {
if (ringingMedia.length > 0) if (ringingMedia !== null)
return of({ spotlight: ringingMedia, pip$: localUserMediaForPip$ }); return of({ spotlight: [ringingMedia], pip$: localUserMediaForPip$ });
return screenShares$.pipe( return screenShares$.pipe(
switchMap((screenShares) => { switchMap((screenShares) => {
@@ -1144,14 +1137,10 @@ export function createCallViewModel$(
// show ringing media instead // show ringing media instead
if (userMedia.length === 1) if (userMedia.length === 1)
return ringingMedia$.pipe( return ringingMedia$.pipe(
map((ringingMedia) => { map(
return ringingMedia.length === 1 (ringingMedia) =>
? { ringingMedia && { local, remote: ringingMedia },
local, ),
remote: ringingMedia[0],
}
: null;
}),
); );
} }
} }
@@ -1728,8 +1717,14 @@ export function createCallViewModel$(
return { return {
autoLeave$: autoLeave$, autoLeave$: autoLeave$,
ringing$: scope.behavior( ringingIntent$: scope.behavior(
callPickupState$.pipe(map((state) => state === "ringing")), ringAttempts$.pipe(
switchMap(({ intent, outcome$ }) =>
// Hold the intent as the value until the ring attempt completes
concat(of(intent), NEVER.pipe(takeUntil(outcome$)), of(null)),
),
startWith<RTCCallIntent | null>(null),
),
), ),
leave$: leave$, leave$: leave$,
hangup: (): void => userHangup$.next(), hangup: (): void => userHangup$.next(),

View File

@@ -5,8 +5,9 @@ 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 { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import { type Behavior } from "../Behavior"; import { type Behavior } from "../Behavior";
import { type MuteStates } from "../MuteStates";
import { import {
type BaseMediaInputs, type BaseMediaInputs,
type BaseMediaViewModel, type BaseMediaViewModel,
@@ -20,32 +21,23 @@ import {
export interface RingingMediaViewModel extends BaseMediaViewModel { export interface RingingMediaViewModel extends BaseMediaViewModel {
type: "ringing"; type: "ringing";
pickupState$: Behavior<"ringing" | "timeout" | "decline">; pickupState$: Behavior<"ringing" | "timeout" | "decline">;
/** intent: RTCCallIntent;
* Whether this media would be expected to have video, were it not simply a
* placeholder.
*/
videoEnabled$: Behavior<boolean>;
} }
export interface RingingMediaInputs extends BaseMediaInputs { export interface RingingMediaInputs extends BaseMediaInputs {
pickupState$: Behavior<"ringing" | "timeout" | "decline">; pickupState$: Behavior<"ringing" | "timeout" | "decline">;
/** intent: RTCCallIntent;
* The local user's own mute states.
*/
muteStates: MuteStates;
} }
export function createRingingMedia({ export function createRingingMedia({
pickupState$, pickupState$,
muteStates, intent,
...inputs ...inputs
}: RingingMediaInputs): RingingMediaViewModel { }: RingingMediaInputs): RingingMediaViewModel {
return { return {
...createBaseMedia(inputs), ...createBaseMedia(inputs),
type: "ringing", type: "ringing",
pickupState$, pickupState$,
// If our own video is enabled, then this is a video call and we would intent,
// expect remote media to have video as well
videoEnabled$: muteStates.video.enabled$,
}; };
} }

View File

@@ -26,7 +26,6 @@ import {
createRingingMedia, createRingingMedia,
type RingingMediaViewModel, type RingingMediaViewModel,
} from "../state/media/RingingMediaViewModel"; } from "../state/media/RingingMediaViewModel";
import { type MuteStates } from "../state/MuteStates";
global.IntersectionObserver = class MockIntersectionObserver { global.IntersectionObserver = class MockIntersectionObserver {
public observe(): void {} public observe(): void {}
@@ -93,10 +92,8 @@ test("GridTile displays ringing media", async () => {
>("ringing"); >("ringing");
const vm = createRingingMedia({ const vm = createRingingMedia({
pickupState$, pickupState$,
muteStates: {
video: { enabled$: constant(false) },
} as unknown as MuteStates,
id: "test", id: "test",
intent: "audio",
userId: "@alice:example.org", userId: "@alice:example.org",
displayName$: constant("Alice"), displayName$: constant("Alice"),
mxcAvatarUrl$: constant(undefined), mxcAvatarUrl$: constant(undefined),

View File

@@ -77,7 +77,6 @@ const RingingMediaTile: FC<RingingMediaTileProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const pickupState = useBehavior(vm.pickupState$); const pickupState = useBehavior(vm.pickupState$);
const videoEnabled = useBehavior(vm.videoEnabled$);
return ( return (
<MediaView <MediaView
@@ -89,11 +88,12 @@ const RingingMediaTile: FC<RingingMediaTileProps> = ({
pickupState === "ringing" pickupState === "ringing"
? { ? {
text: t("video_tile.calling"), text: t("video_tile.calling"),
Icon: videoEnabled ? VideoCallSolidIcon : VoiceCallSolidIcon, Icon:
vm.intent === "video" ? VideoCallSolidIcon : VoiceCallSolidIcon,
} }
: { text: t("video_tile.call_ended"), Icon: EndCallIcon } : { text: t("video_tile.call_ended"), Icon: EndCallIcon }
} }
videoEnabled={videoEnabled} videoEnabled={false}
videoFit="cover" videoFit="cover"
mirror={false} mirror={false}
{...props} {...props}

View File

@@ -28,7 +28,6 @@ import {
createRingingMedia, createRingingMedia,
type RingingMediaViewModel, type RingingMediaViewModel,
} from "../state/media/RingingMediaViewModel"; } from "../state/media/RingingMediaViewModel";
import { type MuteStates } from "../state/MuteStates";
global.IntersectionObserver = class MockIntersectionObserver { global.IntersectionObserver = class MockIntersectionObserver {
public observe(): void {} public observe(): void {}
@@ -156,10 +155,8 @@ test("SpotlightTile displays ringing media", async () => {
>("ringing"); >("ringing");
const vm = createRingingMedia({ const vm = createRingingMedia({
pickupState$, pickupState$,
muteStates: {
video: { enabled$: constant(false) },
} as unknown as MuteStates,
id: "test", id: "test",
intent: "audio",
userId: "@alice:example.org", userId: "@alice:example.org",
displayName$: constant("Alice"), displayName$: constant("Alice"),
mxcAvatarUrl$: constant(undefined), mxcAvatarUrl$: constant(undefined),

View File

@@ -212,7 +212,6 @@ const SpotlightRingingMediaItem: FC<SpotlightRingingMediaItemProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const pickupState = useBehavior(vm.pickupState$); const pickupState = useBehavior(vm.pickupState$);
const videoEnabled = useBehavior(vm.videoEnabled$);
return ( return (
<MediaView <MediaView
@@ -222,7 +221,8 @@ const SpotlightRingingMediaItem: FC<SpotlightRingingMediaItemProps> = ({
pickupState === "ringing" pickupState === "ringing"
? { ? {
text: t("video_tile.calling"), text: t("video_tile.calling"),
Icon: videoEnabled ? VideoCallSolidIcon : VoiceCallSolidIcon, Icon:
vm.intent === "video" ? VideoCallSolidIcon : VoiceCallSolidIcon,
} }
: { text: t("video_tile.call_ended"), Icon: EndCallIcon } : { text: t("video_tile.call_ended"), Icon: EndCallIcon }
} }