Merge pull request #3467 from element-hq/toger5/call-pickup-state-decline-event
View model for decline logic
This commit is contained in:
@@ -158,7 +158,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
};
|
||||
}, [livekitRoom]);
|
||||
|
||||
const { autoLeaveWhenOthersLeft } = useUrlParams();
|
||||
const { autoLeaveWhenOthersLeft, sendNotificationType, waitForCallPickup } =
|
||||
useUrlParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (livekitRoom !== undefined) {
|
||||
@@ -171,6 +172,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
{
|
||||
encryptionSystem: props.e2eeSystem,
|
||||
autoLeaveWhenOthersLeft,
|
||||
waitForCallPickup:
|
||||
waitForCallPickup && sendNotificationType === "ring",
|
||||
},
|
||||
connStateObservable$,
|
||||
reactionsReader.raisedHands$,
|
||||
@@ -190,6 +193,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
props.e2eeSystem,
|
||||
connStateObservable$,
|
||||
autoLeaveWhenOthersLeft,
|
||||
sendNotificationType,
|
||||
waitForCallPickup,
|
||||
]);
|
||||
|
||||
if (livekitRoom === undefined || vm === null) return null;
|
||||
|
||||
@@ -18,7 +18,16 @@ import {
|
||||
of,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { ClientEvent, SyncState, type MatrixClient } from "matrix-js-sdk";
|
||||
import {
|
||||
ClientEvent,
|
||||
SyncState,
|
||||
type MatrixClient,
|
||||
RoomEvent as MatrixRoomEvent,
|
||||
MatrixEvent,
|
||||
type IRoomTimelineData,
|
||||
EventType,
|
||||
type IEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import {
|
||||
ConnectionState,
|
||||
type LocalParticipant,
|
||||
@@ -237,6 +246,23 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
||||
);
|
||||
}
|
||||
|
||||
function mockRingEvent(
|
||||
eventId: string,
|
||||
lifetimeMs: number | undefined,
|
||||
sender = local.userId,
|
||||
): { event_id: string } & IRTCNotificationContent {
|
||||
return {
|
||||
event_id: eventId,
|
||||
...(lifetimeMs === undefined ? {} : { lifetime: lifetimeMs }),
|
||||
notification_type: "ring",
|
||||
sender,
|
||||
} as unknown as { event_id: string } & IRTCNotificationContent;
|
||||
}
|
||||
|
||||
// The app doesn't really care about the content of these legacy events, we just
|
||||
// need a value to fill in for them when emitting notifications
|
||||
const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent;
|
||||
|
||||
interface CallViewModelInputs {
|
||||
remoteParticipants$: Behavior<RemoteParticipant[]>;
|
||||
rtcMembers$: Behavior<Partial<CallMembership>[]>;
|
||||
@@ -1205,10 +1231,8 @@ describe("waitForCallPickup$", () => {
|
||||
r: () => {
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
{ lifetime: 30 } as unknown as {
|
||||
event_id: string;
|
||||
} & IRTCNotificationContent,
|
||||
{} as unknown as { event_id: string } & ICallNotifyContent,
|
||||
mockRingEvent("$notif1", 30),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1247,12 +1271,8 @@ describe("waitForCallPickup$", () => {
|
||||
r: () => {
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
{ lifetime: 100 } as unknown as {
|
||||
event_id: string;
|
||||
} & IRTCNotificationContent,
|
||||
{} as unknown as {
|
||||
event_id: string;
|
||||
} & ICallNotifyContent,
|
||||
mockRingEvent("$notif2", 100),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1290,12 +1310,8 @@ describe("waitForCallPickup$", () => {
|
||||
r: () => {
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
{ lifetime: 50 } as unknown as {
|
||||
event_id: string;
|
||||
} & IRTCNotificationContent,
|
||||
{} as unknown as {
|
||||
event_id: string;
|
||||
} & ICallNotifyContent,
|
||||
mockRingEvent("$notif3", 50),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1321,12 +1337,8 @@ describe("waitForCallPickup$", () => {
|
||||
r: () => {
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
{} as unknown as {
|
||||
event_id: string;
|
||||
} & IRTCNotificationContent, // no lifetime
|
||||
{} as unknown as {
|
||||
event_id: string;
|
||||
} & ICallNotifyContent,
|
||||
mockRingEvent("$notif4", undefined),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1361,12 +1373,8 @@ describe("waitForCallPickup$", () => {
|
||||
r: () => {
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
{ lifetime: 30 } as unknown as {
|
||||
event_id: string;
|
||||
} & IRTCNotificationContent,
|
||||
{} as unknown as {
|
||||
event_id: string;
|
||||
} & ICallNotifyContent,
|
||||
mockRingEvent("$notif5", 30),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1381,6 +1389,149 @@ describe("waitForCallPickup$", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("decline before timeout window ends -> decline", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
withCallViewModel(
|
||||
{},
|
||||
(vm, rtcSession) => {
|
||||
// Notify at 10ms with 50ms lifetime, decline at 40ms with matching id
|
||||
schedule(" 10ms r 29ms d", {
|
||||
r: () => {
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
mockRingEvent("$decl1", 50),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
d: () => {
|
||||
// Emit decline timeline event with id matching the notification
|
||||
rtcSession.room.emit(
|
||||
MatrixRoomEvent.Timeline,
|
||||
new MatrixEvent({
|
||||
type: EventType.RTCDecline,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: "$decl1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
rtcSession.room,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
);
|
||||
},
|
||||
});
|
||||
expectObservable(vm.callPickupState$).toBe("a 9ms b 29ms e", {
|
||||
a: "unknown",
|
||||
b: "ringing",
|
||||
e: "decline",
|
||||
});
|
||||
},
|
||||
{
|
||||
waitForCallPickup: true,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("decline after timeout window ends -> stays timeout", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
withCallViewModel(
|
||||
{},
|
||||
(vm, rtcSession) => {
|
||||
// Notify at 10ms with 20ms lifetime (timeout at 30ms), decline at 40ms
|
||||
schedule(" 10ms r 20ms t 10ms d", {
|
||||
r: () => {
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
mockRingEvent("$decl2", 20),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
t: () => {},
|
||||
d: () => {
|
||||
rtcSession.room.emit(
|
||||
MatrixRoomEvent.Timeline,
|
||||
new MatrixEvent({ event_id: "$decl2", type: "m.rtc.decline" }),
|
||||
rtcSession.room,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
);
|
||||
},
|
||||
});
|
||||
expectObservable(vm.callPickupState$).toBe("a 9ms b 19ms c", {
|
||||
a: "unknown",
|
||||
b: "ringing",
|
||||
c: "timeout",
|
||||
});
|
||||
},
|
||||
{
|
||||
waitForCallPickup: true,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function testStaysRinging(declineEvent: Partial<IEvent>): void {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
withCallViewModel(
|
||||
{},
|
||||
(vm, rtcSession) => {
|
||||
// Notify at 10ms with id A, decline arrives at 20ms with id B
|
||||
schedule(" 10ms r 10ms d", {
|
||||
r: () => {
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
mockRingEvent("$right", 50),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
d: () => {
|
||||
rtcSession.room.emit(
|
||||
MatrixRoomEvent.Timeline,
|
||||
new MatrixEvent(declineEvent),
|
||||
rtcSession.room,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
);
|
||||
},
|
||||
});
|
||||
// We assert up to 21ms to see the ringing at 10ms and no change at 20ms
|
||||
expectObservable(vm.callPickupState$, "21ms !").toBe("a 9ms b", {
|
||||
a: "unknown",
|
||||
b: "ringing",
|
||||
});
|
||||
},
|
||||
{
|
||||
waitForCallPickup: true,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("decline with wrong id is ignored (stays ringing)", () => {
|
||||
testStaysRinging({
|
||||
event_id: "$wrong",
|
||||
type: "m.rtc.decline",
|
||||
sender: local.userId,
|
||||
});
|
||||
});
|
||||
|
||||
test("decline with sender being the local user is ignored (stays ringing)", () => {
|
||||
testStaysRinging({
|
||||
event_id: "$right",
|
||||
type: "m.rtc.decline",
|
||||
sender: alice.userId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("audio output changes when toggling earpiece mode", () => {
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
} from "livekit-client";
|
||||
import {
|
||||
ClientEvent,
|
||||
type EventTimelineSetHandlerMap,
|
||||
EventType,
|
||||
RoomEvent,
|
||||
RoomStateEvent,
|
||||
SyncState,
|
||||
type Room as MatrixRoom,
|
||||
@@ -33,6 +36,7 @@ import {
|
||||
combineLatest,
|
||||
concat,
|
||||
distinctUntilChanged,
|
||||
endWith,
|
||||
filter,
|
||||
forkJoin,
|
||||
fromEvent,
|
||||
@@ -58,9 +62,9 @@ import {
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type CallMembership,
|
||||
type IRTCNotificationContent,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
type MatrixRTCSessionEventHandlerMap,
|
||||
MembershipManagerEvent,
|
||||
Status,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
@@ -887,17 +891,60 @@ export class CallViewModel extends ViewModel {
|
||||
: NEVER;
|
||||
|
||||
/**
|
||||
* Emits whenever the RTC session tells us that it intends to ring for a given
|
||||
* duration.
|
||||
* Whenever the RTC session tells us that it intends to ring the remote
|
||||
* participant's devices, this emits an Observable tracking the current state of
|
||||
* that ringing process.
|
||||
*/
|
||||
private readonly beginRingingForMs$ = (
|
||||
private readonly ring$: Observable<
|
||||
Observable<"ringing" | "timeout" | "decline">
|
||||
> = (
|
||||
fromEvent(
|
||||
this.matrixRTCSession,
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
) as Observable<[IRTCNotificationContent]>
|
||||
)
|
||||
// event.lifetime is expected to be in ms
|
||||
.pipe(map(([notificationEvent]) => notificationEvent?.lifetime ?? 0));
|
||||
) as Observable<
|
||||
Parameters<
|
||||
MatrixRTCSessionEventHandlerMap[MatrixRTCSessionEvent.DidSendCallNotification]
|
||||
>
|
||||
>
|
||||
).pipe(
|
||||
filter(
|
||||
([notificationEvent]) => notificationEvent.notification_type === "ring",
|
||||
),
|
||||
map(([notificationEvent]) => {
|
||||
const lifetimeMs = notificationEvent?.lifetime ?? 0;
|
||||
return concat(
|
||||
lifetimeMs === 0
|
||||
? // If no lifetime, skip the ring state
|
||||
EMPTY
|
||||
: // Ring until lifetime ms have passed
|
||||
timer(lifetimeMs).pipe(
|
||||
ignoreElements(),
|
||||
startWith("ringing" as const),
|
||||
),
|
||||
// The notification lifetime has timed out, meaning ringing has likely
|
||||
// stopped on all receiving clients.
|
||||
of("timeout" as const),
|
||||
NEVER,
|
||||
).pipe(
|
||||
takeUntil(
|
||||
(
|
||||
fromEvent(this.matrixRoom, RoomEvent.Timeline) as Observable<
|
||||
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
|
||||
>
|
||||
).pipe(
|
||||
filter(
|
||||
([event]) =>
|
||||
event.getType() === EventType.RTCDecline &&
|
||||
event.getRelation()?.rel_type === "m.reference" &&
|
||||
event.getRelation()?.event_id === notificationEvent.event_id &&
|
||||
event.getSender() !== this.userId,
|
||||
),
|
||||
),
|
||||
),
|
||||
endWith("decline" as const),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether some Matrix user other than ourself is joined to the call.
|
||||
@@ -917,35 +964,21 @@ export class CallViewModel extends ViewModel {
|
||||
* - null: EC is configured to never show any waiting for answer state.
|
||||
*/
|
||||
public readonly callPickupState$ = this.options.waitForCallPickup
|
||||
? this.scope.behavior<"unknown" | "ringing" | "timeout" | "success">(
|
||||
concat(
|
||||
concat(
|
||||
// We don't know if the RTC session decides to send a notify event
|
||||
// yet. It will only be known once we sent our own membership and
|
||||
// know we were the first one to join.
|
||||
of("unknown" as const),
|
||||
// Once we get the signal to begin ringing:
|
||||
this.beginRingingForMs$.pipe(
|
||||
take(1),
|
||||
switchMap((lifetime) =>
|
||||
lifetime === 0
|
||||
? // If no lifetime, skip the ring state
|
||||
EMPTY
|
||||
: // Ring until lifetime ms have passed
|
||||
timer(lifetime).pipe(
|
||||
ignoreElements(),
|
||||
startWith("ringing" as const),
|
||||
),
|
||||
),
|
||||
),
|
||||
// The notification lifetime has timed out, meaning ringing has
|
||||
// likely stopped on all receiving clients.
|
||||
of("timeout" as const),
|
||||
NEVER,
|
||||
).pipe(
|
||||
takeUntil(this.someoneElseJoined$.pipe(filter((joined) => joined))),
|
||||
? this.scope.behavior<
|
||||
"unknown" | "ringing" | "timeout" | "decline" | "success"
|
||||
>(
|
||||
this.someoneElseJoined$.pipe(
|
||||
switchMap((someoneElseJoined) =>
|
||||
someoneElseJoined
|
||||
? of("success" as const)
|
||||
: // Show the ringing state of the most recent ringing attempt.
|
||||
this.ring$.pipe(switchAll()),
|
||||
),
|
||||
of("success" as const),
|
||||
// The state starts as 'unknown' because we don't know if the RTC
|
||||
// session will actually send a notify event yet. It will only be
|
||||
// known once we send our own membership and see that we were the
|
||||
// first one to join.
|
||||
startWith("unknown" as const),
|
||||
),
|
||||
)
|
||||
: constant(null);
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
type RoomAndToDeviceEventsHandlerMap,
|
||||
} from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
|
||||
import { type TrackReference } from "@livekit/components-core";
|
||||
import EventEmitter from "events";
|
||||
|
||||
import {
|
||||
LocalUserMediaViewModel,
|
||||
@@ -144,26 +145,25 @@ export function withTestScheduler(
|
||||
}
|
||||
|
||||
interface EmitterMock<T> {
|
||||
on: () => T;
|
||||
off: () => T;
|
||||
addListener: () => T;
|
||||
removeListener: () => T;
|
||||
on: (...args: unknown[]) => T;
|
||||
off: (...args: unknown[]) => T;
|
||||
addListener: (...args: unknown[]) => T;
|
||||
removeListener: (...args: unknown[]) => T;
|
||||
emit: (event: string | symbol, ...args: unknown[]) => boolean;
|
||||
}
|
||||
|
||||
export function mockEmitter<T>(): EmitterMock<T> {
|
||||
const ee = new EventEmitter();
|
||||
return {
|
||||
on(): T {
|
||||
return this as T;
|
||||
},
|
||||
off(): T {
|
||||
return this as T;
|
||||
},
|
||||
addListener(): T {
|
||||
return this as T;
|
||||
},
|
||||
removeListener(): T {
|
||||
return this as T;
|
||||
},
|
||||
on: ee.on.bind(ee) as unknown as (...args: unknown[]) => T,
|
||||
off: ee.off.bind(ee) as unknown as (...args: unknown[]) => T,
|
||||
addListener: ee.addListener.bind(ee) as unknown as (
|
||||
...args: unknown[]
|
||||
) => T,
|
||||
removeListener: ee.removeListener.bind(ee) as unknown as (
|
||||
...args: unknown[]
|
||||
) => T,
|
||||
emit: ee.emit.bind(ee),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10280,7 +10280,7 @@ __metadata:
|
||||
|
||||
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop":
|
||||
version: 37.13.0
|
||||
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=c4c7f945141e142e6f846b243c33c4af97a9a44b"
|
||||
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=2f1d654f14be8dd03896e9e76f12017b6f9eec1c"
|
||||
dependencies:
|
||||
"@babel/runtime": "npm:^7.12.5"
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0"
|
||||
@@ -10296,7 +10296,7 @@ __metadata:
|
||||
sdp-transform: "npm:^2.14.1"
|
||||
unhomoglyph: "npm:^1.0.6"
|
||||
uuid: "npm:11"
|
||||
checksum: 10c0/caa4b8a6d924ac36a21773dc2c8be6cb6b658a9feaabccdb24426719c563ac2cfe4778abb86f0889854ae36fc7ba02a6ed39acdbc0b73fdc31ce9a9789e7f36a
|
||||
checksum: 10c0/ecd019c677c272c5598617dcde407dbe4b1b11460863b2a577e33f3fd8732c9d9073ec0221b471ec1eb24e2839eec20728db7f92c9348be83126547286e50805
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user