mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
Delegate delayed leaves in LocalMember rather than LocalTransport
IMO this is where the delegation calls should have lived all along, since the leave event is part of the membership lifecycle, and we otherwise end up with an awkward hack to ignore transport updates. Doing this now ensures that the client won't send any delegation requests if delegation is unsupported, and prepares the code for a future change in which we use the dedicated delegation endpoint from the CS API.
This commit is contained in:
@@ -493,16 +493,6 @@ export function createCallViewModel$(
|
|||||||
memberships$: memberships$,
|
memberships$: memberships$,
|
||||||
ownMembershipIdentity,
|
ownMembershipIdentity,
|
||||||
client,
|
client,
|
||||||
delayId$: scope.behavior(
|
|
||||||
(
|
|
||||||
fromEvent(
|
|
||||||
matrixRTCSession,
|
|
||||||
MembershipManagerEvent.DelayIdChanged,
|
|
||||||
// The type of reemitted event includes the original emitted as the second arg.
|
|
||||||
) as Observable<[string | undefined, IMembershipManager]>
|
|
||||||
).pipe(map(([delayId]) => delayId ?? null)),
|
|
||||||
matrixRTCSession.delayId ?? null,
|
|
||||||
),
|
|
||||||
roomId: matrixRoom.roomId,
|
roomId: matrixRoom.roomId,
|
||||||
matrixRTCMode,
|
matrixRTCMode,
|
||||||
});
|
});
|
||||||
@@ -583,10 +573,22 @@ export function createCallViewModel$(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
connectionManager,
|
connectionManager,
|
||||||
|
client,
|
||||||
matrixRTCSession,
|
matrixRTCSession,
|
||||||
localTransport$,
|
localTransport,
|
||||||
roomId: matrixRoom.roomId,
|
roomId: matrixRoom.roomId,
|
||||||
baseUrl: client.baseUrl,
|
baseUrl: client.baseUrl,
|
||||||
|
ownMembershipIdentity,
|
||||||
|
delayId$: scope.behavior(
|
||||||
|
(
|
||||||
|
fromEvent(
|
||||||
|
matrixRTCSession,
|
||||||
|
MembershipManagerEvent.DelayIdChanged,
|
||||||
|
// The type of reemitted event includes the original emitted as the second arg.
|
||||||
|
) as Observable<[string | undefined, IMembershipManager]>
|
||||||
|
).pipe(map(([delayId]) => delayId ?? null)),
|
||||||
|
matrixRTCSession.delayId ?? null,
|
||||||
|
),
|
||||||
matrixRTCMode,
|
matrixRTCMode,
|
||||||
logger: logger.getChild(`[${Date.now()}]`),
|
logger: logger.getChild(`[${Date.now()}]`),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
type LocalTransport,
|
type LocalTransport,
|
||||||
type LocalTransportWithSFUConfig,
|
type LocalTransportWithSFUConfig,
|
||||||
} from "./LocalTransport";
|
} from "./LocalTransport";
|
||||||
|
import * as openIDSFU from "../../../livekit/openIDSFU";
|
||||||
|
|
||||||
initializeWidget();
|
initializeWidget();
|
||||||
|
|
||||||
@@ -113,6 +114,17 @@ const delegatedTimings: ResolvedDelayedLeaveTimings = {
|
|||||||
restart_timeout_ms: timings.restart_timeout_ms! * 10,
|
restart_timeout_ms: timings.restart_timeout_ms! * 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockedClient = {
|
||||||
|
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||||
|
getDeviceId: vi.fn().mockReturnValue("AAAA"),
|
||||||
|
getOpenIdToken: vi.fn().mockResolvedValue({
|
||||||
|
access_token: "ACCCESS_TOKEN",
|
||||||
|
token_type: "Bearer",
|
||||||
|
matrix_server_name: "localhost",
|
||||||
|
expires_in: 10000,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
describe("enterRTCSession", () => {
|
describe("enterRTCSession", () => {
|
||||||
const transport: LivekitTransportConfig = {
|
const transport: LivekitTransportConfig = {
|
||||||
livekit_alias: "roomId",
|
livekit_alias: "roomId",
|
||||||
@@ -129,15 +141,7 @@ describe("enterRTCSession", () => {
|
|||||||
const mockedSession = vi.mocked({
|
const mockedSession = vi.mocked({
|
||||||
room: {
|
room: {
|
||||||
roomId: "roomId",
|
roomId: "roomId",
|
||||||
client: {
|
client: mockedClient,
|
||||||
getDomain: vi.fn().mockReturnValue("example.org"),
|
|
||||||
getOpenIdToken: vi.fn().mockResolvedValue({
|
|
||||||
access_token: "ACCCESS_TOKEN",
|
|
||||||
token_type: "Bearer",
|
|
||||||
matrix_server_name: "localhost",
|
|
||||||
expires_in: 10000,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
memberships: [],
|
memberships: [],
|
||||||
joinRTCSession: vi.fn(),
|
joinRTCSession: vi.fn(),
|
||||||
@@ -230,6 +234,9 @@ describe("LocalMembership", () => {
|
|||||||
},
|
},
|
||||||
roomId: "!test-room-id:example.org",
|
roomId: "!test-room-id:example.org",
|
||||||
baseUrl: "https://matrix.example.org",
|
baseUrl: "https://matrix.example.org",
|
||||||
|
ownMembershipIdentity: ownMemberMock,
|
||||||
|
client: mockedClient,
|
||||||
|
delayId$: constant(null),
|
||||||
matrixRTCMode: MATRIX_RTC_MODE,
|
matrixRTCMode: MATRIX_RTC_MODE,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -276,7 +283,7 @@ describe("LocalMembership", () => {
|
|||||||
scope,
|
scope,
|
||||||
...defaultCreateLocalMemberValues,
|
...defaultCreateLocalMemberValues,
|
||||||
connectionManager: mockConnectionManager,
|
connectionManager: mockConnectionManager,
|
||||||
localTransport$: behavior("a", { a: aLocalTransport }),
|
localTransport: aLocalTransport,
|
||||||
});
|
});
|
||||||
|
|
||||||
expectObservable(localMembership.localMemberState$).toBe("ne", {
|
expectObservable(localMembership.localMemberState$).toBe("ne", {
|
||||||
@@ -320,7 +327,7 @@ describe("LocalMembership", () => {
|
|||||||
scope,
|
scope,
|
||||||
...defaultCreateLocalMemberValues,
|
...defaultCreateLocalMemberValues,
|
||||||
connectionManager: mockConnectionManager,
|
connectionManager: mockConnectionManager,
|
||||||
localTransport$: constant(aLocalTransport),
|
localTransport: aLocalTransport,
|
||||||
});
|
});
|
||||||
|
|
||||||
expectObservable(localMembership.localMemberState$).toBe("n-e", {
|
expectObservable(localMembership.localMemberState$).toBe("n-e", {
|
||||||
@@ -337,8 +344,8 @@ describe("LocalMembership", () => {
|
|||||||
const scope = new ObservableScope();
|
const scope = new ObservableScope();
|
||||||
|
|
||||||
const aLocalTransport: LocalTransport = {
|
const aLocalTransport: LocalTransport = {
|
||||||
advertised$: new BehaviorSubject(aTransport),
|
advertised$: constant(aTransport),
|
||||||
active$: new BehaviorSubject(aTransportWithSFUConfig),
|
active$: constant(aTransportWithSFUConfig),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockConnectionManager = {
|
const mockConnectionManager = {
|
||||||
@@ -356,7 +363,7 @@ describe("LocalMembership", () => {
|
|||||||
leaveRoomSession: vi.fn(),
|
leaveRoomSession: vi.fn(),
|
||||||
},
|
},
|
||||||
connectionManager: mockConnectionManager,
|
connectionManager: mockConnectionManager,
|
||||||
localTransport$: new BehaviorSubject(aLocalTransport),
|
localTransport: aLocalTransport,
|
||||||
});
|
});
|
||||||
const expextedLog =
|
const expextedLog =
|
||||||
"'not connected yet' while updating the call intent (this is expected on startup)";
|
"'not connected yet' while updating the call intent (this is expected on startup)";
|
||||||
@@ -417,6 +424,11 @@ describe("LocalMembership", () => {
|
|||||||
livekitRoom: mockLivekitRoom({}),
|
livekitRoom: mockLivekitRoom({}),
|
||||||
} as unknown as Connection;
|
} as unknown as Connection;
|
||||||
|
|
||||||
|
const authCallSpy = vi
|
||||||
|
.spyOn(openIDSFU, "getSFUConfigWithOpenID")
|
||||||
|
.mockImplementation(() => mockedClient.getOpenIdToken());
|
||||||
|
afterEach(() => authCallSpy.mockClear());
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["no", null, timings],
|
["no", null, timings],
|
||||||
[
|
[
|
||||||
@@ -429,14 +441,8 @@ describe("LocalMembership", () => {
|
|||||||
"joins session with %s delegation support",
|
"joins session with %s delegation support",
|
||||||
async (_serviceName, delegationUrl, delayedLeaveTimings) => {
|
async (_serviceName, delegationUrl, delayedLeaveTimings) => {
|
||||||
const scope = testScope();
|
const scope = testScope();
|
||||||
|
|
||||||
const activeTransport$ = constant(aTransportWithSFUConfig);
|
|
||||||
const aLocalTransport: LocalTransport = {
|
|
||||||
advertised$: constant(aTransport),
|
|
||||||
active$: activeTransport$,
|
|
||||||
};
|
|
||||||
const connectionManagerData = new ConnectionManagerData();
|
|
||||||
const joinMatrixRTC = vi.fn();
|
const joinMatrixRTC = vi.fn();
|
||||||
|
const delayId$ = new BehaviorSubject<string | null>(null);
|
||||||
|
|
||||||
if (delegationUrl !== null)
|
if (delegationUrl !== null)
|
||||||
fetchMock.post(delegationUrl, () => ({ status: 401, body: {} }));
|
fetchMock.post(delegationUrl, () => ({ status: 401, body: {} }));
|
||||||
@@ -445,10 +451,16 @@ describe("LocalMembership", () => {
|
|||||||
scope,
|
scope,
|
||||||
...defaultCreateLocalMemberValues,
|
...defaultCreateLocalMemberValues,
|
||||||
connectionManager: {
|
connectionManager: {
|
||||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
connectionManagerData$: constant(
|
||||||
|
new Epoch(new ConnectionManagerData()),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
localTransport$: constant(aLocalTransport),
|
|
||||||
joinMatrixRTC,
|
joinMatrixRTC,
|
||||||
|
localTransport: {
|
||||||
|
advertised$: constant(aTransport),
|
||||||
|
active$: constant(aTransportWithSFUConfig),
|
||||||
|
},
|
||||||
|
delayId$,
|
||||||
});
|
});
|
||||||
|
|
||||||
localMembership.requestJoinAndPublish();
|
localMembership.requestJoinAndPublish();
|
||||||
@@ -459,6 +471,34 @@ describe("LocalMembership", () => {
|
|||||||
aTransport,
|
aTransport,
|
||||||
delayedLeaveTimings,
|
delayedLeaveTimings,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
expect(authCallSpy).not.toHaveBeenCalled();
|
||||||
|
delayId$.next("leave1");
|
||||||
|
await flushPromises();
|
||||||
|
if (delegationUrl === null) {
|
||||||
|
expect(authCallSpy).not.toHaveBeenCalled();
|
||||||
|
} else {
|
||||||
|
// Delegation is supported in this test case, so go on to check that
|
||||||
|
// LocalMember actually performs delegation
|
||||||
|
const expectDelegation = (delayId: string) =>
|
||||||
|
expect(authCallSpy).toHaveBeenLastCalledWith(
|
||||||
|
mockedClient,
|
||||||
|
ownMemberMock,
|
||||||
|
"a",
|
||||||
|
"!test-room-id:example.org",
|
||||||
|
{
|
||||||
|
matrixRTCMode: MATRIX_RTC_MODE,
|
||||||
|
delayEndpointBaseUrl: "https://matrix.example.org",
|
||||||
|
delayId,
|
||||||
|
},
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expectDelegation("leave1");
|
||||||
|
delayId$.next("leave2"); // Can change delegated leaves
|
||||||
|
await flushPromises();
|
||||||
|
expectDelegation("leave2");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -467,7 +507,7 @@ describe("LocalMembership", () => {
|
|||||||
|
|
||||||
const activeTransport$ = new BehaviorSubject(aTransportWithSFUConfig);
|
const activeTransport$ = new BehaviorSubject(aTransportWithSFUConfig);
|
||||||
const aLocalTransport: LocalTransport = {
|
const aLocalTransport: LocalTransport = {
|
||||||
advertised$: new BehaviorSubject(aTransport),
|
advertised$: constant(aTransport),
|
||||||
active$: activeTransport$,
|
active$: activeTransport$,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -505,7 +545,7 @@ describe("LocalMembership", () => {
|
|||||||
connectionManager: {
|
connectionManager: {
|
||||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||||
},
|
},
|
||||||
localTransport$: new BehaviorSubject(aLocalTransport),
|
localTransport: aLocalTransport,
|
||||||
});
|
});
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
activeTransport$.next({
|
activeTransport$.next({
|
||||||
@@ -536,7 +576,7 @@ describe("LocalMembership", () => {
|
|||||||
const publishers: Publisher[] = [];
|
const publishers: Publisher[] = [];
|
||||||
|
|
||||||
const tracks$ = new BehaviorSubject<LocalTrack[]>([]);
|
const tracks$ = new BehaviorSubject<LocalTrack[]>([]);
|
||||||
const publishing$ = new BehaviorSubject<boolean>(false);
|
const publishing$ = constant<boolean>(false);
|
||||||
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
|
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
|
||||||
() => {
|
() => {
|
||||||
const p = {
|
const p = {
|
||||||
@@ -560,8 +600,8 @@ describe("LocalMembership", () => {
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
const aLocalTransport: LocalTransport = {
|
const aLocalTransport: LocalTransport = {
|
||||||
advertised$: new BehaviorSubject(aTransport),
|
advertised$: constant(aTransport),
|
||||||
active$: new BehaviorSubject(aTransportWithSFUConfig),
|
active$: constant(aTransportWithSFUConfig),
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectionManagerData = new ConnectionManagerData();
|
const connectionManagerData = new ConnectionManagerData();
|
||||||
@@ -573,7 +613,7 @@ describe("LocalMembership", () => {
|
|||||||
connectionManager: {
|
connectionManager: {
|
||||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||||
},
|
},
|
||||||
localTransport$: new BehaviorSubject(aLocalTransport),
|
localTransport: aLocalTransport,
|
||||||
});
|
});
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(publisherFactory).toHaveBeenCalledOnce();
|
expect(publisherFactory).toHaveBeenCalledOnce();
|
||||||
@@ -601,7 +641,7 @@ describe("LocalMembership", () => {
|
|||||||
new BehaviorSubject<null | LocalTransportWithSFUConfig>(null);
|
new BehaviorSubject<null | LocalTransportWithSFUConfig>(null);
|
||||||
|
|
||||||
const aLocalTransport: LocalTransport = {
|
const aLocalTransport: LocalTransport = {
|
||||||
advertised$: new BehaviorSubject(aTransport),
|
advertised$: constant(aTransport),
|
||||||
active$: activeTransport$,
|
active$: activeTransport$,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -644,7 +684,7 @@ describe("LocalMembership", () => {
|
|||||||
connectionManager: {
|
connectionManager: {
|
||||||
connectionManagerData$,
|
connectionManagerData$,
|
||||||
},
|
},
|
||||||
localTransport$: new BehaviorSubject(aLocalTransport),
|
localTransport: aLocalTransport,
|
||||||
});
|
});
|
||||||
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
@@ -779,10 +819,10 @@ describe("LocalMembership", () => {
|
|||||||
connectionManager: {
|
connectionManager: {
|
||||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||||
},
|
},
|
||||||
localTransport$: new BehaviorSubject({
|
localTransport: {
|
||||||
advertised$: new BehaviorSubject(aTransport),
|
advertised$: constant(aTransport),
|
||||||
active$: new BehaviorSubject(aTransportWithSFUConfig),
|
active$: constant(aTransportWithSFUConfig),
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
@@ -819,10 +859,10 @@ describe("LocalMembership", () => {
|
|||||||
connectionManager: {
|
connectionManager: {
|
||||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||||
},
|
},
|
||||||
localTransport$: new BehaviorSubject({
|
localTransport: {
|
||||||
advertised$: new BehaviorSubject(aTransport),
|
advertised$: constant(aTransport),
|
||||||
active$: new BehaviorSubject(aTransportWithSFUConfig),
|
active$: constant(aTransportWithSFUConfig),
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
@@ -861,18 +901,19 @@ describe("LocalMembership", () => {
|
|||||||
scope,
|
scope,
|
||||||
...defaultCreateLocalMemberValues,
|
...defaultCreateLocalMemberValues,
|
||||||
homeserverConnected: {
|
homeserverConnected: {
|
||||||
combined$: new BehaviorSubject<
|
combined$: constant<[boolean, HomeserverDisconnectReason | null]>([
|
||||||
[boolean, HomeserverDisconnectReason | null]
|
true,
|
||||||
>([true, null]),
|
null,
|
||||||
|
]),
|
||||||
rtsSession$: constant(RTCMemberStatus.Connected),
|
rtsSession$: constant(RTCMemberStatus.Connected),
|
||||||
},
|
},
|
||||||
connectionManager: {
|
connectionManager: {
|
||||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||||
},
|
},
|
||||||
localTransport$: new BehaviorSubject({
|
localTransport: {
|
||||||
advertised$: new BehaviorSubject(aTransport),
|
advertised$: constant(aTransport),
|
||||||
active$: new BehaviorSubject(aTransportWithSFUConfig),
|
active$: constant(aTransportWithSFUConfig),
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
@@ -913,10 +954,10 @@ describe("LocalMembership", () => {
|
|||||||
connectionManager: {
|
connectionManager: {
|
||||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||||
},
|
},
|
||||||
localTransport$: new BehaviorSubject({
|
localTransport: {
|
||||||
advertised$: new BehaviorSubject(aTransport),
|
advertised$: constant(aTransport),
|
||||||
active$: new BehaviorSubject(aTransportWithSFUConfig),
|
active$: constant(aTransportWithSFUConfig),
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
@@ -982,10 +1023,10 @@ describe("LocalMembership", () => {
|
|||||||
connectionManager: {
|
connectionManager: {
|
||||||
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||||
},
|
},
|
||||||
localTransport$: new BehaviorSubject({
|
localTransport: {
|
||||||
advertised$: new BehaviorSubject(aTransport),
|
advertised$: constant(aTransport),
|
||||||
active$: new BehaviorSubject(aTransportWithSFUConfig),
|
active$: constant(aTransportWithSFUConfig),
|
||||||
}),
|
},
|
||||||
});
|
});
|
||||||
return { scope, localMembership };
|
return { scope, localMembership };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
MediaDeviceFailure,
|
MediaDeviceFailure,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { observeParticipantEvents } from "@livekit/components-core";
|
import { observeParticipantEvents } from "@livekit/components-core";
|
||||||
|
import { type MatrixClient } from "matrix-js-sdk";
|
||||||
import {
|
import {
|
||||||
Status as RTCSessionStatus,
|
Status as RTCSessionStatus,
|
||||||
type LivekitTransport,
|
type LivekitTransport,
|
||||||
@@ -76,6 +77,7 @@ import { type HomeserverConnected } from "./HomeserverConnected.ts";
|
|||||||
import { type LocalTransport } from "./LocalTransport.ts";
|
import { type LocalTransport } from "./LocalTransport.ts";
|
||||||
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
|
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
|
||||||
import { or$ } from "../../../utils/observable.ts";
|
import { or$ } from "../../../utils/observable.ts";
|
||||||
|
import { getSFUConfigWithOpenID } from "../../../livekit/openIDSFU.ts";
|
||||||
|
|
||||||
export enum TransportState {
|
export enum TransportState {
|
||||||
/** Not even a transport is available to the LocalMembership */
|
/** Not even a transport is available to the LocalMembership */
|
||||||
@@ -143,12 +145,16 @@ interface Props {
|
|||||||
) => void;
|
) => void;
|
||||||
homeserverConnected: HomeserverConnected;
|
homeserverConnected: HomeserverConnected;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||||
localTransport: LocalTransport;
|
localTransport: LocalTransport;
|
||||||
|
client: Pick<MatrixClient, "getDeviceId" | "getOpenIdToken">;
|
||||||
matrixRTCSession: Pick<
|
matrixRTCSession: Pick<
|
||||||
MatrixRTCSession,
|
MatrixRTCSession,
|
||||||
"updateCallIntent" | "leaveRoomSession"
|
"updateCallIntent" | "leaveRoomSession"
|
||||||
>;
|
>;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
delayId$: Behavior<string | null>;
|
||||||
|
matrixRTCMode: MatrixRTCMode;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +174,7 @@ interface Props {
|
|||||||
* @param props.muteStates The mute states for video and audio.
|
* @param props.muteStates The mute states for video and audio.
|
||||||
* @param props.matrixRTCSession The matrix RTC session to join.
|
* @param props.matrixRTCSession The matrix RTC session to join.
|
||||||
* @param props.baseUrl Base URL of the homeserver.
|
* @param props.baseUrl Base URL of the homeserver.
|
||||||
|
* @param props.delayId$ ID of the delayed leave event to delegate to the SFU.
|
||||||
* @param props.roomId The room ID used as the call identifier in analytics events.
|
* @param props.roomId The room ID used as the call identifier in analytics events.
|
||||||
* @returns
|
* @returns
|
||||||
* - publisher: The handle to create tracks and publish them to the room.
|
* - publisher: The handle to create tracks and publish them to the room.
|
||||||
@@ -179,15 +186,19 @@ interface Props {
|
|||||||
export const createLocalMembership$ = ({
|
export const createLocalMembership$ = ({
|
||||||
scope,
|
scope,
|
||||||
connectionManager,
|
connectionManager,
|
||||||
localTransport$,
|
localTransport,
|
||||||
homeserverConnected,
|
homeserverConnected,
|
||||||
createPublisherFactory,
|
createPublisherFactory,
|
||||||
joinMatrixRTC,
|
joinMatrixRTC,
|
||||||
logger: parentLogger,
|
logger: parentLogger,
|
||||||
muteStates,
|
muteStates,
|
||||||
|
client,
|
||||||
matrixRTCSession,
|
matrixRTCSession,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
roomId,
|
roomId,
|
||||||
|
ownMembershipIdentity,
|
||||||
|
delayId$,
|
||||||
|
matrixRTCMode,
|
||||||
}: Props): {
|
}: Props): {
|
||||||
/**
|
/**
|
||||||
* This request to start audio and video tracks.
|
* This request to start audio and video tracks.
|
||||||
@@ -711,6 +722,34 @@ export const createLocalMembership$ = ({
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Delegate delayed leaves to the SFU
|
||||||
|
scope.reconcile(
|
||||||
|
scope.behavior(combineLatest([joinParams$, delayId$])),
|
||||||
|
async ([joinParams, delayId]) => {
|
||||||
|
if (joinParams?.delegationSupported && delayId !== null) {
|
||||||
|
try {
|
||||||
|
// This will technically cause the service to issue a new JWT token,
|
||||||
|
// but it's safe to discard. We're only interested in triggering
|
||||||
|
// delegation.
|
||||||
|
await getSFUConfigWithOpenID(
|
||||||
|
client,
|
||||||
|
ownMembershipIdentity,
|
||||||
|
joinParams.transport.livekit_service_url,
|
||||||
|
roomId,
|
||||||
|
{ matrixRTCMode, delayEndpointBaseUrl: baseUrl, delayId },
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// TODO: Surface this to the user as a service interruption?
|
||||||
|
logger.error(
|
||||||
|
`Failed to delegate leave to ${joinParams.transport.livekit_service_url}`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Pause upstream of all local media tracks when we're disconnected from
|
// Pause upstream of all local media tracks when we're disconnected from
|
||||||
// MatrixRTC, because it can be an unpleasant surprise for the app to say
|
// MatrixRTC, because it can be an unpleasant surprise for the app to say
|
||||||
// 'reconnecting' and yet still be transmitting your media to others.
|
// 'reconnecting' and yet still be transmitting your media to others.
|
||||||
|
|||||||
@@ -14,11 +14,8 @@ import {
|
|||||||
type MockedObject,
|
type MockedObject,
|
||||||
vi,
|
vi,
|
||||||
} from "vitest";
|
} from "vitest";
|
||||||
import {
|
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
type CallMembership,
|
import { lastValueFrom } from "rxjs";
|
||||||
type LivekitTransportConfig,
|
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
|
||||||
import { BehaviorSubject, filter, lastValueFrom } from "rxjs";
|
|
||||||
import fetchMock from "fetch-mock";
|
import fetchMock from "fetch-mock";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -27,11 +24,7 @@ import {
|
|||||||
ownMemberMock,
|
ownMemberMock,
|
||||||
testScope,
|
testScope,
|
||||||
} from "../../../utils/test";
|
} from "../../../utils/test";
|
||||||
import {
|
import { createLocalTransport$ } from "./LocalTransport";
|
||||||
createLocalTransport$,
|
|
||||||
JwtEndpointVersion,
|
|
||||||
type LocalTransportWithSFUConfig,
|
|
||||||
} from "./LocalTransport";
|
|
||||||
import { constant } from "../../Behavior";
|
import { constant } from "../../Behavior";
|
||||||
import { Epoch, ObservableScope } from "../../ObservableScope";
|
import { Epoch, ObservableScope } from "../../ObservableScope";
|
||||||
import {
|
import {
|
||||||
@@ -62,14 +55,12 @@ describe("LocalTransport", () => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||||
getDomain: () => "example.org",
|
getDomain: () => "example.org",
|
||||||
baseUrl: "example.org",
|
|
||||||
// These won't be called in this error path but satisfy the type
|
// These won't be called in this error path but satisfy the type
|
||||||
getOpenIdToken: vi.fn(),
|
getOpenIdToken: vi.fn(),
|
||||||
getDeviceId: vi.fn(),
|
getDeviceId: vi.fn(),
|
||||||
},
|
},
|
||||||
ownMembershipIdentity: ownMemberMock,
|
ownMembershipIdentity: ownMemberMock,
|
||||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||||
delayId$: constant("delay_id_mock"),
|
|
||||||
});
|
});
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
@@ -101,7 +92,6 @@ describe("LocalTransport", () => {
|
|||||||
roomId: "!example_room_id",
|
roomId: "!example_room_id",
|
||||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||||
client: {
|
client: {
|
||||||
baseUrl: "https://example.org",
|
|
||||||
getDomain: () => "example.org",
|
getDomain: () => "example.org",
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||||
@@ -110,7 +100,6 @@ describe("LocalTransport", () => {
|
|||||||
},
|
},
|
||||||
ownMembershipIdentity: ownMemberMock,
|
ownMembershipIdentity: ownMemberMock,
|
||||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||||
delayId$: constant("delay_id_mock"),
|
|
||||||
});
|
});
|
||||||
active$.subscribe(
|
active$.subscribe(
|
||||||
(o) => observations.push(o),
|
(o) => observations.push(o),
|
||||||
@@ -148,11 +137,9 @@ describe("LocalTransport", () => {
|
|||||||
getDomain: () => "example.org",
|
getDomain: () => "example.org",
|
||||||
getOpenIdToken: vi.fn(),
|
getOpenIdToken: vi.fn(),
|
||||||
getDeviceId: vi.fn(),
|
getDeviceId: vi.fn(),
|
||||||
baseUrl: "https://example.org",
|
|
||||||
},
|
},
|
||||||
ownMembershipIdentity: ownMemberMock,
|
ownMembershipIdentity: ownMemberMock,
|
||||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||||
delayId$: constant("delay_id_mock"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
openIdResolver.resolve?.({
|
openIdResolver.resolve?.({
|
||||||
@@ -196,10 +183,8 @@ describe("LocalTransport", () => {
|
|||||||
scope: testScope(),
|
scope: testScope(),
|
||||||
roomId: "!example_room_id",
|
roomId: "!example_room_id",
|
||||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||||
delayId$: constant(null),
|
|
||||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||||
client: {
|
client: {
|
||||||
baseUrl: "https://example.org",
|
|
||||||
getDomain: vi.fn().mockReturnValue("example.org"),
|
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
|
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
|
||||||
@@ -308,11 +293,9 @@ describe("LocalTransport", () => {
|
|||||||
ownMembershipIdentity: ownMemberMock,
|
ownMembershipIdentity: ownMemberMock,
|
||||||
roomId: "!example_room_id",
|
roomId: "!example_room_id",
|
||||||
matrixRTCMode: MatrixRTCMode.Compatibility,
|
matrixRTCMode: MatrixRTCMode.Compatibility,
|
||||||
delayId$: constant(null),
|
|
||||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||||
client: {
|
client: {
|
||||||
getDomain: () => "example.org",
|
getDomain: () => "example.org",
|
||||||
baseUrl: "https://example.org",
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||||
// These won't be called in this error path but satisfy the type
|
// These won't be called in this error path but satisfy the type
|
||||||
@@ -330,84 +313,4 @@ describe("LocalTransport", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not update advertised/active transport on delayID changes, but delay Id delegation should be called", async () => {
|
|
||||||
// For simplicity, we'll just use the config livekit
|
|
||||||
customLivekitUrl.setValue("https://lk.example.org");
|
|
||||||
|
|
||||||
const authCallSpy = vi
|
|
||||||
.spyOn(openIDSFU, "getSFUConfigWithOpenID")
|
|
||||||
.mockResolvedValue(openIdResponse);
|
|
||||||
|
|
||||||
const delayId$ = new BehaviorSubject<string | null>(null);
|
|
||||||
|
|
||||||
const { advertised$, active$ } = createLocalTransport$({
|
|
||||||
scope: testScope(),
|
|
||||||
ownMembershipIdentity: ownMemberMock,
|
|
||||||
roomId: "!example_room_id",
|
|
||||||
// We want multi-sdu
|
|
||||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
|
||||||
delayId$: delayId$,
|
|
||||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
|
||||||
client: {
|
|
||||||
getDomain: () => "example.org",
|
|
||||||
baseUrl: "https://example.org",
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
|
||||||
// These won't be called in this error path but satisfy the type
|
|
||||||
getOpenIdToken: vi.fn(),
|
|
||||||
getDeviceId: vi.fn(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const advertisedValues: LivekitTransportConfig[] = [];
|
|
||||||
const activeValues: LocalTransportWithSFUConfig[] = [];
|
|
||||||
advertised$
|
|
||||||
.pipe(filter((v) => v !== null))
|
|
||||||
.subscribe((t) => advertisedValues.push(t));
|
|
||||||
active$
|
|
||||||
.pipe(filter((v) => v !== null))
|
|
||||||
.subscribe((t) => activeValues.push(t));
|
|
||||||
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
// we have now an active and an advertised
|
|
||||||
expect(advertisedValues.length).toEqual(1);
|
|
||||||
expect(activeValues.length).toEqual(1);
|
|
||||||
expect(advertisedValues[0]!.livekit_service_url).toEqual(
|
|
||||||
"https://lk.example.org",
|
|
||||||
);
|
|
||||||
expect(activeValues[0]!.transport.livekit_service_url).toEqual(
|
|
||||||
"https://lk.example.org",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(authCallSpy).toHaveBeenCalledTimes(2);
|
|
||||||
// Now emits 3 new delays id
|
|
||||||
delayId$.next("delay_id_1");
|
|
||||||
await flushPromises();
|
|
||||||
delayId$.next("delay_id_2");
|
|
||||||
await flushPromises();
|
|
||||||
delayId$.next("delay_id_3");
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
// No new emissions should've happened, it is the same transport.
|
|
||||||
expect(advertisedValues.length).toEqual(1);
|
|
||||||
expect(activeValues.length).toEqual(1);
|
|
||||||
|
|
||||||
// Still we should have updated the delayID to auth
|
|
||||||
expect(authCallSpy).toHaveBeenCalledTimes(
|
|
||||||
4 * 2 /* 2 calls for each delayId ?? why */,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(authCallSpy).toHaveBeenLastCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
delayId: "delay_id_3",
|
|
||||||
}),
|
|
||||||
expect.anything(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,14 +10,7 @@ import {
|
|||||||
type LivekitTransportConfig,
|
type LivekitTransportConfig,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { type MatrixClient } from "matrix-js-sdk";
|
import { type MatrixClient } from "matrix-js-sdk";
|
||||||
import {
|
import { distinctUntilChanged, from, map, of, switchMap } from "rxjs";
|
||||||
combineLatest,
|
|
||||||
distinctUntilChanged,
|
|
||||||
from,
|
|
||||||
map,
|
|
||||||
of,
|
|
||||||
switchMap,
|
|
||||||
} from "rxjs";
|
|
||||||
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
|
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
|
|
||||||
@@ -47,15 +40,11 @@ interface Props {
|
|||||||
scope: ObservableScope;
|
scope: ObservableScope;
|
||||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||||
memberships$: Behavior<Epoch<CallMembership[]>>;
|
memberships$: Behavior<Epoch<CallMembership[]>>;
|
||||||
client: Pick<
|
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports"> &
|
||||||
MatrixClient,
|
|
||||||
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
|
|
||||||
> &
|
|
||||||
OpenIDClientParts;
|
OpenIDClientParts;
|
||||||
// Used by the jwt service to create the livekit room and compute the livekit alias.
|
// Used by the jwt service to create the livekit room and compute the livekit alias.
|
||||||
roomId: string;
|
roomId: string;
|
||||||
matrixRTCMode: MatrixRTCMode;
|
matrixRTCMode: MatrixRTCMode;
|
||||||
delayId$: Behavior<string | null>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO livekit_alias-cleanup
|
// TODO livekit_alias-cleanup
|
||||||
@@ -119,7 +108,6 @@ export const createLocalTransport$ = ({
|
|||||||
client,
|
client,
|
||||||
roomId,
|
roomId,
|
||||||
matrixRTCMode,
|
matrixRTCMode,
|
||||||
delayId$,
|
|
||||||
}: Props): LocalTransport => {
|
}: Props): LocalTransport => {
|
||||||
const logger = rootLogger.getChild("[LocalTransport]");
|
const logger = rootLogger.getChild("[LocalTransport]");
|
||||||
|
|
||||||
@@ -134,32 +122,29 @@ export const createLocalTransport$ = ({
|
|||||||
transportDiscovery.discoverPreferredTransport(),
|
transportDiscovery.discoverPreferredTransport(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const preferredConfig$ = customLivekitUrl.value$
|
const preferredConfig$ = customLivekitUrl.value$.pipe(
|
||||||
.pipe(
|
switchMap((customUrl) => {
|
||||||
switchMap((customUrl) => {
|
if (customUrl) {
|
||||||
if (customUrl) {
|
return of({
|
||||||
return of({
|
type: "livekit",
|
||||||
type: "livekit",
|
livekit_service_url: customUrl,
|
||||||
livekit_service_url: customUrl,
|
} as LivekitTransportConfig);
|
||||||
} as LivekitTransportConfig);
|
} else {
|
||||||
} else {
|
return discoveredTransport$;
|
||||||
return discoveredTransport$;
|
}
|
||||||
}
|
}),
|
||||||
}),
|
map((config) => {
|
||||||
)
|
if (!config) {
|
||||||
.pipe(
|
// Bubbled up from the preferredConfig$ observable.
|
||||||
map((config) => {
|
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
|
||||||
if (!config) {
|
}
|
||||||
// Bubbled up from the preferredConfig$ observable.
|
return config;
|
||||||
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
|
}),
|
||||||
}
|
distinctUntilChanged(areLivekitTransportsEqual),
|
||||||
return config;
|
);
|
||||||
}),
|
|
||||||
distinctUntilChanged(areLivekitTransportsEqual),
|
|
||||||
);
|
|
||||||
|
|
||||||
const preferredTransport$ = combineLatest([preferredConfig$, delayId$]).pipe(
|
const preferredTransport$ = preferredConfig$.pipe(
|
||||||
switchMap(async ([transport, delayId]) => {
|
switchMap(async (transport) => {
|
||||||
try {
|
try {
|
||||||
return await doOpenIdAndJWTFromUrl(
|
return await doOpenIdAndJWTFromUrl(
|
||||||
transport,
|
transport,
|
||||||
@@ -167,7 +152,6 @@ export const createLocalTransport$ = ({
|
|||||||
ownMembershipIdentity,
|
ownMembershipIdentity,
|
||||||
roomId,
|
roomId,
|
||||||
client,
|
client,
|
||||||
delayId ?? undefined,
|
|
||||||
logger,
|
logger,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -189,21 +173,7 @@ export const createLocalTransport$ = ({
|
|||||||
),
|
),
|
||||||
null,
|
null,
|
||||||
),
|
),
|
||||||
active$: scope.behavior(
|
active$: scope.behavior(preferredTransport$, null),
|
||||||
preferredTransport$.pipe(
|
|
||||||
// XXX: WORK AROUND due to a reconnection glitch.
|
|
||||||
// To remove when we have a proper way to refresh the delegation event ID without refreshing
|
|
||||||
// the whole credentials.
|
|
||||||
// We deliberately hide any changes to the SFU config because we
|
|
||||||
// do not want the app to reconnect whenever the JWT
|
|
||||||
// token changes due to us delegating a new delayed event. The
|
|
||||||
// initial SFU config for the transport is all the app needs.
|
|
||||||
distinctUntilChanged((prev, next) =>
|
|
||||||
areLivekitTransportsEqual(prev.transport, next.transport),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -219,7 +189,6 @@ export const createLocalTransport$ = ({
|
|||||||
* @param membership The identity of the local member.
|
* @param membership The identity of the local member.
|
||||||
* @param roomId The room ID to use for the JWT.
|
* @param roomId The room ID to use for the JWT.
|
||||||
* @param client The client to use for the OpenID token.
|
* @param client The client to use for the OpenID token.
|
||||||
* @param delayId The delayId to use for the JWT.
|
|
||||||
*
|
*
|
||||||
* @throws FailToGetOpenIdToken, NoMatrix2AuthorizationService
|
* @throws FailToGetOpenIdToken, NoMatrix2AuthorizationService
|
||||||
*/
|
*/
|
||||||
@@ -228,12 +197,7 @@ async function doOpenIdAndJWTFromUrl(
|
|||||||
matrixRTCMode: MatrixRTCMode,
|
matrixRTCMode: MatrixRTCMode,
|
||||||
membership: CallMembershipIdentityParts,
|
membership: CallMembershipIdentityParts,
|
||||||
roomId: string,
|
roomId: string,
|
||||||
client: Pick<
|
client: Pick<MatrixClient, "_unstable_getRTCTransports"> & OpenIDClientParts,
|
||||||
MatrixClient,
|
|
||||||
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
|
|
||||||
> &
|
|
||||||
OpenIDClientParts,
|
|
||||||
delayId?: string,
|
|
||||||
logger?: Logger,
|
logger?: Logger,
|
||||||
): Promise<LocalTransportWithSFUConfig> {
|
): Promise<LocalTransportWithSFUConfig> {
|
||||||
const sfuConfig = await getSFUConfigWithOpenID(
|
const sfuConfig = await getSFUConfigWithOpenID(
|
||||||
@@ -241,11 +205,7 @@ async function doOpenIdAndJWTFromUrl(
|
|||||||
membership,
|
membership,
|
||||||
transport.livekit_service_url,
|
transport.livekit_service_url,
|
||||||
roomId,
|
roomId,
|
||||||
{
|
{ matrixRTCMode },
|
||||||
matrixRTCMode,
|
|
||||||
delayEndpointBaseUrl: client.baseUrl,
|
|
||||||
delayId,
|
|
||||||
},
|
|
||||||
logger,
|
logger,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user