mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-08 20:09:19 +00:00
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:
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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,311 +43,272 @@ 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,
|
||||||
sentCallNotification$: hot("10ms a", {
|
};
|
||||||
a: mockRingEvent("$notif1", 30),
|
|
||||||
}),
|
|
||||||
receivedDecline$: hot(""),
|
|
||||||
options: {
|
|
||||||
waitForCallPickup: true,
|
|
||||||
autoLeaveWhenOthersLeft: false,
|
|
||||||
},
|
|
||||||
localUser: localRtcMember,
|
|
||||||
};
|
|
||||||
|
|
||||||
const lifecycle = createCallNotificationLifecycle$(props);
|
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 }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b 29ms c", {
|
test("no ring attempt when waitForCallPickup=false", () => {
|
||||||
a: "unknown",
|
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||||
b: "ringing",
|
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||||
c: "timeout",
|
scope,
|
||||||
});
|
...defaultProps,
|
||||||
|
sentCallNotification$: hot("-a", {
|
||||||
|
a: mockRingEvent("$notif1", 30),
|
||||||
|
}),
|
||||||
|
options: { ...defaultProps.options, waitForCallPickup: false },
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it("ringing -> success if someone joins before timeout is reached", () => {
|
expectObservable(ringAttempts$).toBe("");
|
||||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
});
|
||||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
});
|
||||||
const props: CallNotificationLifecycleProps = {
|
|
||||||
scope,
|
test("no ring attempt when notification type is not ring", () => {
|
||||||
memberships$: scope.behavior(
|
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||||
behavior("a 19ms b", {
|
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||||
a: [localRtcMember],
|
scope,
|
||||||
b: [localRtcMember, aliceRtcMember],
|
...defaultProps,
|
||||||
}).pipe(trackEpoch()),
|
sentCallNotification$: hot("-a", {
|
||||||
),
|
a: {
|
||||||
sentCallNotification$: hot("5ms a", {
|
...mockRingEvent("$notif1", 30),
|
||||||
a: mockRingEvent("$notif2", 100),
|
notification_type: "notification",
|
||||||
}),
|
},
|
||||||
receivedDecline$: hot(""),
|
}),
|
||||||
options: {
|
});
|
||||||
waitForCallPickup: true,
|
|
||||||
autoLeaveWhenOthersLeft: false,
|
expectObservable(ringAttempts$).toBe("");
|
||||||
},
|
});
|
||||||
localUser: localRtcMember,
|
});
|
||||||
};
|
|
||||||
const lifecycle = createCallNotificationLifecycle$(props);
|
test("no ring attempt if lifetime is missing", () => {
|
||||||
expectObservable(lifecycle.callPickupState$).toBe("a 4ms b 14ms c", {
|
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||||
a: "unknown",
|
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||||
b: "ringing",
|
scope,
|
||||||
c: "success",
|
...defaultProps,
|
||||||
});
|
sentCallNotification$: hot("-a", {
|
||||||
});
|
a: mockRingEvent("$notif1", undefined),
|
||||||
});
|
}),
|
||||||
it("success when someone joins before we notify", () => {
|
});
|
||||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
|
||||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
expectObservable(ringAttempts$).toBe("");
|
||||||
const props: CallNotificationLifecycleProps = {
|
});
|
||||||
scope,
|
});
|
||||||
memberships$: scope.behavior(
|
|
||||||
behavior("a 9ms b", {
|
test("ring attempt times out after nobody joins", () => {
|
||||||
a: [localRtcMember],
|
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||||
b: [localRtcMember, aliceRtcMember],
|
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||||
}).pipe(trackEpoch()),
|
scope,
|
||||||
),
|
...defaultProps,
|
||||||
sentCallNotification$: hot("20ms a", {
|
// No one ever joins (only local user)
|
||||||
a: mockRingEvent("$notif2", 50),
|
memberships$: constant(new Epoch([])),
|
||||||
}),
|
sentCallNotification$: hot("-a", {
|
||||||
receivedDecline$: hot(""),
|
a: mockRingEvent("$notif1", 30),
|
||||||
options: {
|
}),
|
||||||
waitForCallPickup: true,
|
});
|
||||||
autoLeaveWhenOthersLeft: false,
|
|
||||||
},
|
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||||
localUser: localRtcMember,
|
a: { intent: "audio", recipient: alice.userId },
|
||||||
};
|
A: { outcome: "timeout" },
|
||||||
const lifecycle = createCallNotificationLifecycle$(props);
|
});
|
||||||
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b", {
|
});
|
||||||
a: "unknown",
|
});
|
||||||
b: "success",
|
|
||||||
});
|
test("ring attempt is accepted once recipient joins", () => {
|
||||||
});
|
withTestScheduler(({ scope, expectObservable, hot, behavior }) => {
|
||||||
});
|
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||||
it("notify without lifetime -> immediate timeout", () => {
|
scope,
|
||||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
...defaultProps,
|
||||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
memberships$: scope.behavior(
|
||||||
const props: CallNotificationLifecycleProps = {
|
behavior("a-b", { a: [], b: [aliceRtcMember] }).pipe(trackEpoch()),
|
||||||
scope,
|
),
|
||||||
memberships$: scope.behavior(
|
sentCallNotification$: hot("-a", {
|
||||||
behavior("a", {
|
a: mockRingEvent("$notif1", 30),
|
||||||
a: [localRtcMember],
|
}),
|
||||||
}).pipe(trackEpoch()),
|
});
|
||||||
),
|
|
||||||
sentCallNotification$: hot("10ms a", {
|
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
|
||||||
a: mockRingEvent("$notif2", undefined),
|
a: { intent: "audio", recipient: alice.userId },
|
||||||
}),
|
A: { outcome: "accept" },
|
||||||
receivedDecline$: hot(""),
|
});
|
||||||
options: {
|
});
|
||||||
waitForCallPickup: true,
|
});
|
||||||
autoLeaveWhenOthersLeft: false,
|
|
||||||
},
|
test("ring attempt is immediately accepted if recipient is already joined", () => {
|
||||||
localUser: localRtcMember,
|
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||||
};
|
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||||
const lifecycle = createCallNotificationLifecycle$(props);
|
scope,
|
||||||
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b", {
|
...defaultProps,
|
||||||
a: "unknown",
|
memberships$: constant(new Epoch([aliceRtcMember])),
|
||||||
b: "timeout",
|
sentCallNotification$: hot("-a", {
|
||||||
});
|
a: mockRingEvent("$notif1", 30),
|
||||||
});
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stays null when waitForCallPickup=false", () => {
|
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-(aA)", {
|
||||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
a: { intent: "audio", recipient: alice.userId },
|
||||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
A: { outcome: "accept" },
|
||||||
const validProps: CallNotificationLifecycleProps = {
|
});
|
||||||
scope,
|
});
|
||||||
memberships$: scope.behavior(
|
});
|
||||||
behavior("a--b", {
|
|
||||||
a: [localRtcMember],
|
test("ring attempt can be declined", () => {
|
||||||
b: [localRtcMember, aliceRtcMember],
|
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||||
}).pipe(trackEpoch()),
|
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||||
),
|
scope,
|
||||||
sentCallNotification$: hot("10ms a", {
|
...defaultProps,
|
||||||
a: mockRingEvent("$notif5", 30),
|
sentCallNotification$: hot("-a", {
|
||||||
}),
|
a: mockRingEvent("$notif1", 30),
|
||||||
receivedDecline$: hot(""),
|
}),
|
||||||
options: {
|
receivedDecline$: hot("--d", {
|
||||||
waitForCallPickup: true,
|
d: [
|
||||||
autoLeaveWhenOthersLeft: false,
|
new MatrixEvent({
|
||||||
},
|
type: EventType.RTCDecline,
|
||||||
localUser: localRtcMember,
|
sender: alice.userId,
|
||||||
};
|
content: {
|
||||||
const propsDeactivated = {
|
"m.relates_to": {
|
||||||
...validProps,
|
rel_type: "m.reference",
|
||||||
options: {
|
event_id: "$notif1",
|
||||||
...validProps.options,
|
},
|
||||||
waitForCallPickup: false,
|
},
|
||||||
},
|
}),
|
||||||
};
|
{} as Room,
|
||||||
const lifecycle = createCallNotificationLifecycle$(propsDeactivated);
|
undefined,
|
||||||
expectObservable(lifecycle.callPickupState$).toBe("n", {
|
false,
|
||||||
n: null,
|
{} as IRoomTimelineData,
|
||||||
});
|
],
|
||||||
const lifecycleReference = createCallNotificationLifecycle$(validProps);
|
}),
|
||||||
expectObservable(lifecycleReference.callPickupState$).toBe("u--s", {
|
});
|
||||||
u: "unknown",
|
|
||||||
s: "success",
|
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
|
||||||
});
|
a: { intent: "audio", recipient: alice.userId },
|
||||||
});
|
A: { outcome: "decline" },
|
||||||
});
|
});
|
||||||
|
});
|
||||||
it("decline before timeout window ends -> decline", () => {
|
});
|
||||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
|
||||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
test("ring attempt times out if recipient declines too late", () => {
|
||||||
const props: CallNotificationLifecycleProps = {
|
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||||
scope,
|
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||||
memberships$: scope.behavior(
|
scope,
|
||||||
behavior("a", {
|
...defaultProps,
|
||||||
a: [localRtcMember],
|
sentCallNotification$: hot("-a", {
|
||||||
}).pipe(trackEpoch()),
|
a: mockRingEvent("$notif1", 30),
|
||||||
),
|
}),
|
||||||
sentCallNotification$: hot("10ms a", {
|
receivedDecline$: hot("100ms d", {
|
||||||
a: mockRingEvent("$decl1", 50),
|
d: [
|
||||||
}),
|
new MatrixEvent({
|
||||||
receivedDecline$: hot("40ms d", {
|
type: EventType.RTCDecline,
|
||||||
d: [
|
sender: alice.userId,
|
||||||
new MatrixEvent({
|
content: {
|
||||||
type: EventType.RTCDecline,
|
"m.relates_to": {
|
||||||
content: {
|
rel_type: "m.reference",
|
||||||
"m.relates_to": {
|
event_id: "$notif1",
|
||||||
rel_type: "m.reference",
|
},
|
||||||
event_id: "$decl1",
|
},
|
||||||
},
|
}),
|
||||||
},
|
{} as Room,
|
||||||
}),
|
undefined,
|
||||||
{} as Room,
|
false,
|
||||||
undefined,
|
{} as IRoomTimelineData,
|
||||||
false,
|
],
|
||||||
{} as IRoomTimelineData,
|
}),
|
||||||
],
|
});
|
||||||
}),
|
|
||||||
options: {
|
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||||
waitForCallPickup: true,
|
a: { intent: "audio", recipient: alice.userId },
|
||||||
autoLeaveWhenOthersLeft: false,
|
A: { outcome: "timeout" },
|
||||||
},
|
});
|
||||||
localUser: localRtcMember,
|
});
|
||||||
};
|
});
|
||||||
const lifecycle = createCallNotificationLifecycle$(props);
|
|
||||||
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b 29ms e", {
|
test("decline event relating to wrong event is ignored (times out)", () => {
|
||||||
a: "unknown",
|
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||||
b: "ringing",
|
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||||
e: "decline",
|
scope,
|
||||||
});
|
...defaultProps,
|
||||||
});
|
sentCallNotification$: hot("-a", {
|
||||||
});
|
a: mockRingEvent("$notif1", 30),
|
||||||
it("decline after timeout window ends -> stays timeout", () => {
|
}),
|
||||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
receivedDecline$: hot("--d", {
|
||||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
d: [
|
||||||
const props: CallNotificationLifecycleProps = {
|
new MatrixEvent({
|
||||||
scope,
|
type: EventType.RTCDecline,
|
||||||
memberships$: scope.behavior(
|
sender: alice.userId,
|
||||||
behavior("a", {
|
content: {
|
||||||
a: [localRtcMember],
|
"m.relates_to": {
|
||||||
}).pipe(trackEpoch()),
|
rel_type: "m.reference",
|
||||||
),
|
event_id: "$other", // <---- WRONG
|
||||||
sentCallNotification$: hot("10ms a", {
|
},
|
||||||
a: mockRingEvent("$decl", 20),
|
},
|
||||||
}),
|
}),
|
||||||
receivedDecline$: hot("40ms d", {
|
{} as Room,
|
||||||
d: [
|
undefined,
|
||||||
new MatrixEvent({
|
false,
|
||||||
type: EventType.RTCDecline,
|
{} as IRoomTimelineData,
|
||||||
content: {
|
],
|
||||||
"m.relates_to": {
|
}),
|
||||||
rel_type: "m.reference",
|
});
|
||||||
event_id: "$decl",
|
|
||||||
},
|
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||||
},
|
a: { intent: "audio", recipient: alice.userId },
|
||||||
}),
|
A: { outcome: "timeout" },
|
||||||
{} as Room,
|
});
|
||||||
undefined,
|
});
|
||||||
false,
|
});
|
||||||
{} as IRoomTimelineData,
|
|
||||||
],
|
test("decline event from wrong sender is ignored (times out)", () => {
|
||||||
}),
|
withTestScheduler(({ scope, expectObservable, hot }) => {
|
||||||
options: {
|
const { ringAttempts$ } = createCallNotificationLifecycle$({
|
||||||
waitForCallPickup: true,
|
scope,
|
||||||
autoLeaveWhenOthersLeft: false,
|
...defaultProps,
|
||||||
},
|
sentCallNotification$: hot("-a", {
|
||||||
localUser: localRtcMember,
|
a: mockRingEvent("$notif1", 30),
|
||||||
};
|
}),
|
||||||
const lifecycle = createCallNotificationLifecycle$(props);
|
receivedDecline$: hot("--d", {
|
||||||
expectObservable(lifecycle.callPickupState$, "50ms !").toBe(
|
d: [
|
||||||
"a 9ms b 19ms e",
|
new MatrixEvent({
|
||||||
{
|
type: EventType.RTCDecline,
|
||||||
a: "unknown",
|
sender: local.userId, // <---- WRONG
|
||||||
b: "ringing",
|
content: {
|
||||||
e: "timeout",
|
"m.relates_to": {
|
||||||
},
|
rel_type: "m.reference",
|
||||||
);
|
event_id: "$notif1",
|
||||||
});
|
},
|
||||||
});
|
},
|
||||||
//
|
}),
|
||||||
function testStaysRinging(
|
{} as Room,
|
||||||
declineEvent: Partial<IEvent>,
|
undefined,
|
||||||
expectDecline: boolean,
|
false,
|
||||||
): void {
|
{} as IRoomTimelineData,
|
||||||
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
|
],
|
||||||
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
|
}),
|
||||||
const props: CallNotificationLifecycleProps = {
|
});
|
||||||
scope,
|
|
||||||
memberships$: scope.behavior(
|
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
|
||||||
behavior("a", {
|
a: { intent: "audio", recipient: alice.userId },
|
||||||
a: [localRtcMember],
|
A: { outcome: "timeout" },
|
||||||
}).pipe(trackEpoch()),
|
});
|
||||||
),
|
|
||||||
sentCallNotification$: hot("10ms a", {
|
|
||||||
a: mockRingEvent("$right", 50),
|
|
||||||
}),
|
|
||||||
receivedDecline$: hot("20ms d", {
|
|
||||||
d: [
|
|
||||||
new MatrixEvent(declineEvent),
|
|
||||||
{} as Room,
|
|
||||||
undefined,
|
|
||||||
false,
|
|
||||||
{} as IRoomTimelineData,
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
options: {
|
|
||||||
waitForCallPickup: true,
|
|
||||||
autoLeaveWhenOthersLeft: false,
|
|
||||||
},
|
|
||||||
localUser: localRtcMember,
|
|
||||||
};
|
|
||||||
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",
|
|
||||||
type: EventType.RTCDecline,
|
|
||||||
sender: sender ?? "@other:example.org",
|
|
||||||
content: {
|
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: "m.reference",
|
|
||||||
event_id: refId ?? "$right",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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$ };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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(
|
||||||
generateItems(
|
switchMap(({ intent, recipient, outcome$ }) =>
|
||||||
"CallViewModel ringingMedia$",
|
outcome$.pipe(
|
||||||
function* ([userMedia, roomMembers, callPickupState]) {
|
startWith("ringing" as const),
|
||||||
if (
|
generateItems(
|
||||||
callPickupState === "ringing" ||
|
"CallViewModel ringingMedia$",
|
||||||
callPickupState === "timeout" ||
|
function* (pickupState) {
|
||||||
callPickupState === "decline"
|
if (pickupState !== "accept")
|
||||||
) {
|
yield { keys: [intent, recipient], data: pickupState };
|
||||||
// TODO: Respect io.element.functional_members
|
},
|
||||||
for (const member of roomMembers.values()) {
|
(scope, pickupState$, intent, userId) =>
|
||||||
if (!userMedia.some((vm) => vm.userId === member.userId))
|
createRingingMedia({
|
||||||
yield {
|
id: `ringing:${userId}`,
|
||||||
keys: [member.userId],
|
userId,
|
||||||
data: callPickupState,
|
displayName$: scope.behavior(
|
||||||
};
|
matrixRoomMembers$.pipe(
|
||||||
}
|
map(
|
||||||
}
|
(members) =>
|
||||||
},
|
members.get(userId)?.rawDisplayName || userId,
|
||||||
(scope, pickupState$, userId) =>
|
),
|
||||||
createRingingMedia({
|
),
|
||||||
id: `ringing:${userId}`,
|
),
|
||||||
userId,
|
mxcAvatarUrl$:
|
||||||
displayName$: scope.behavior(
|
matrixMemberMetadataStore.createAvatarUrlBehavior$(
|
||||||
matrixRoomMembers$.pipe(
|
scope,
|
||||||
map((members) => members.get(userId)?.rawDisplayName || userId),
|
userId,
|
||||||
),
|
),
|
||||||
),
|
pickupState$,
|
||||||
mxcAvatarUrl$: matrixMemberMetadataStore.createAvatarUrlBehavior$(
|
intent,
|
||||||
scope,
|
}),
|
||||||
userId,
|
),
|
||||||
),
|
map(([media]) => media ?? null),
|
||||||
pickupState$,
|
),
|
||||||
muteStates,
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
distinctUntilChanged(shallowEquals),
|
startWith(null),
|
||||||
tap((ringingMedia) => {
|
|
||||||
if (ringingMedia.length > 1)
|
|
||||||
// Warn that UI may do something unexpected in this case
|
|
||||||
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(),
|
||||||
|
|||||||
@@ -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$,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user