Merge branch 'livekit' into toger5/pseudonomous-identities
This commit is contained in:
@@ -12,11 +12,12 @@ import {
|
|||||||
MatrixRTCSessionEvent,
|
MatrixRTCSessionEvent,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
computeLivekitParticipantIdentity,
|
computeLivekitParticipantIdentity$,
|
||||||
livekitIdentityInput,
|
livekitIdentityInput,
|
||||||
} from "../state/CallViewModel/remoteMembers/MatrixLivekitMembers";
|
} from "../state/CallViewModel/remoteMembers/LivekitParticipantIdentity";
|
||||||
|
|
||||||
export class MatrixKeyProvider extends BaseKeyProvider {
|
export class MatrixKeyProvider extends BaseKeyProvider {
|
||||||
private rtcSession?: MatrixRTCSession;
|
private rtcSession?: MatrixRTCSession;
|
||||||
@@ -69,7 +70,7 @@ export class MatrixKeyProvider extends BaseKeyProvider {
|
|||||||
"deriveBits",
|
"deriveBits",
|
||||||
"deriveKey",
|
"deriveKey",
|
||||||
]),
|
]),
|
||||||
computeLivekitParticipantIdentity(membership, kind),
|
firstValueFrom(computeLivekitParticipantIdentity$(membership, kind)),
|
||||||
]).then(
|
]).then(
|
||||||
([keyMaterial, livekitParticipantId]) => {
|
([keyMaterial, livekitParticipantId]) => {
|
||||||
this.onSetEncryptionKey(
|
this.onSetEncryptionKey(
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ import { ReactionsOverlay } from "./ReactionsOverlay";
|
|||||||
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
||||||
import {
|
import {
|
||||||
debugTileLayout as debugTileLayoutSetting,
|
debugTileLayout as debugTileLayoutSetting,
|
||||||
|
matrixRTCMode as matrixRTCModeSetting,
|
||||||
useSetting,
|
useSetting,
|
||||||
} from "../settings/settings";
|
} from "../settings/settings";
|
||||||
import { ReactionsReader } from "../reactions/ReactionsReader";
|
import { ReactionsReader } from "../reactions/ReactionsReader";
|
||||||
@@ -144,6 +145,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
encryptionSystem: props.e2eeSystem,
|
encryptionSystem: props.e2eeSystem,
|
||||||
autoLeaveWhenOthersLeft,
|
autoLeaveWhenOthersLeft,
|
||||||
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
||||||
|
matrixRTCMode$: matrixRTCModeSetting.value$,
|
||||||
},
|
},
|
||||||
reactionsReader.raisedHands$,
|
reactionsReader.raisedHands$,
|
||||||
reactionsReader.reactions$,
|
reactionsReader.reactions$,
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ import {
|
|||||||
import { MediaDevices } from "../MediaDevices.ts";
|
import { MediaDevices } from "../MediaDevices.ts";
|
||||||
import { getValue } from "../../utils/observable.ts";
|
import { getValue } from "../../utils/observable.ts";
|
||||||
import { type Behavior, constant } from "../Behavior.ts";
|
import { type Behavior, constant } from "../Behavior.ts";
|
||||||
import { withCallViewModel } from "./CallViewModelTestUtils.ts";
|
import { withCallViewModel as withCallViewModelInMode } from "./CallViewModelTestUtils.ts";
|
||||||
|
import { MatrixRTCMode } from "../../settings/settings.ts";
|
||||||
|
|
||||||
vi.mock("rxjs", async (importOriginal) => ({
|
vi.mock("rxjs", async (importOriginal) => ({
|
||||||
...(await importOriginal()),
|
...(await importOriginal()),
|
||||||
@@ -241,7 +242,13 @@ function mockRingEvent(
|
|||||||
// need a value to fill in for them when emitting notifications
|
// need a value to fill in for them when emitting notifications
|
||||||
const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent;
|
const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent;
|
||||||
|
|
||||||
describe("CallViewModel", () => {
|
describe.each([
|
||||||
|
[MatrixRTCMode.Legacy],
|
||||||
|
[MatrixRTCMode.Compatibil],
|
||||||
|
[MatrixRTCMode.Matrix_2_0],
|
||||||
|
])("CallViewModel (%s mode)", (mode) => {
|
||||||
|
const withCallViewModel = withCallViewModelInMode(mode);
|
||||||
|
|
||||||
test("participants are retained during a focus switch", () => {
|
test("participants are retained during a focus switch", () => {
|
||||||
withTestScheduler(({ behavior, expectObservable }) => {
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
// Participants disappear on frame 2 and come back on frame 3
|
// Participants disappear on frame 2 and come back on frame 3
|
||||||
|
|||||||
@@ -53,11 +53,15 @@ import {
|
|||||||
ScreenShareViewModel,
|
ScreenShareViewModel,
|
||||||
type UserMediaViewModel,
|
type UserMediaViewModel,
|
||||||
} from "../MediaViewModel";
|
} from "../MediaViewModel";
|
||||||
import { accumulate, generateItems, pauseWhen } from "../../utils/observable";
|
import {
|
||||||
|
accumulate,
|
||||||
|
filterBehavior,
|
||||||
|
generateItems,
|
||||||
|
pauseWhen,
|
||||||
|
} from "../../utils/observable";
|
||||||
import {
|
import {
|
||||||
duplicateTiles,
|
duplicateTiles,
|
||||||
MatrixRTCMode,
|
MatrixRTCMode,
|
||||||
matrixRTCMode,
|
|
||||||
playReactionsSound,
|
playReactionsSound,
|
||||||
showReactions,
|
showReactions,
|
||||||
} from "../../settings/settings";
|
} from "../../settings/settings";
|
||||||
@@ -111,7 +115,8 @@ import { ECConnectionFactory } from "./remoteMembers/ConnectionFactory.ts";
|
|||||||
import { createConnectionManager$ } from "./remoteMembers/ConnectionManager.ts";
|
import { createConnectionManager$ } from "./remoteMembers/ConnectionManager.ts";
|
||||||
import {
|
import {
|
||||||
createMatrixLivekitMembers$,
|
createMatrixLivekitMembers$,
|
||||||
type MatrixLivekitMember,
|
type TaggedParticipant,
|
||||||
|
type LocalMatrixLivekitMember,
|
||||||
} from "./remoteMembers/MatrixLivekitMembers.ts";
|
} from "./remoteMembers/MatrixLivekitMembers.ts";
|
||||||
import {
|
import {
|
||||||
type AutoLeaveReason,
|
type AutoLeaveReason,
|
||||||
@@ -150,6 +155,8 @@ export interface CallViewModelOptions {
|
|||||||
connectionState$?: Behavior<ConnectionState>;
|
connectionState$?: Behavior<ConnectionState>;
|
||||||
/** Optional behavior overriding the computed window size, mainly for testing purposes. */
|
/** Optional behavior overriding the computed window size, mainly for testing purposes. */
|
||||||
windowSize$?: Behavior<{ width: number; height: number }>;
|
windowSize$?: Behavior<{ width: number; height: number }>;
|
||||||
|
/** The version & compatibility mode of MatrixRTC that we should use. */
|
||||||
|
matrixRTCMode$: Behavior<MatrixRTCMode>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do not play any sounds if the participant count has exceeded this
|
// Do not play any sounds if the participant count has exceeded this
|
||||||
@@ -406,7 +413,7 @@ export function createCallViewModel$(
|
|||||||
client,
|
client,
|
||||||
roomId: matrixRoom.roomId,
|
roomId: matrixRoom.roomId,
|
||||||
useOldestMember$: scope.behavior(
|
useOldestMember$: scope.behavior(
|
||||||
matrixRTCMode.value$.pipe(map((v) => v === MatrixRTCMode.Legacy)),
|
options.matrixRTCMode$.pipe(map((v) => v === MatrixRTCMode.Legacy)),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -458,7 +465,7 @@ export function createCallViewModel$(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const connectOptions$ = scope.behavior(
|
const connectOptions$ = scope.behavior(
|
||||||
matrixRTCMode.value$.pipe(
|
options.matrixRTCMode$.pipe(
|
||||||
map((mode) => ({
|
map((mode) => ({
|
||||||
encryptMedia: livekitKeyProvider !== undefined,
|
encryptMedia: livekitKeyProvider !== undefined,
|
||||||
// TODO. This might need to get called again on each change of matrixRTCMode...
|
// TODO. This might need to get called again on each change of matrixRTCMode...
|
||||||
@@ -512,22 +519,21 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const localMatrixLivekitMemberUninitialized = {
|
const localMatrixLivekitMember$: Behavior<LocalMatrixLivekitMember | null> =
|
||||||
membership$: localRtcMembership$,
|
|
||||||
participant$: localMembership.participant$,
|
|
||||||
connection$: localMembership.connection$,
|
|
||||||
userId: userId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const localMatrixLivekitMember$: Behavior<MatrixLivekitMember | null> =
|
|
||||||
scope.behavior(
|
scope.behavior(
|
||||||
localRtcMembership$.pipe(
|
localRtcMembership$.pipe(
|
||||||
switchMap((membership) => {
|
filterBehavior((membership) => membership !== null),
|
||||||
if (!membership) return of(null);
|
map((membership$) => {
|
||||||
return of(
|
if (membership$ === null) return null;
|
||||||
// casting is save here since we know that localRtcMembership$ is !== null since we reached this case.
|
return {
|
||||||
localMatrixLivekitMemberUninitialized as MatrixLivekitMember,
|
membership$,
|
||||||
);
|
participant: {
|
||||||
|
type: "local" as const,
|
||||||
|
value$: localMembership.participant$,
|
||||||
|
},
|
||||||
|
connection$: localMembership.connection$,
|
||||||
|
userId,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -595,7 +601,7 @@ export function createCallViewModel$(
|
|||||||
switchMap((members) => {
|
switchMap((members) => {
|
||||||
const a$ = combineLatest(
|
const a$ = combineLatest(
|
||||||
members.value.map((member) =>
|
members.value.map((member) =>
|
||||||
combineLatest([member.connection$, member.participant$]).pipe(
|
combineLatest([member.connection$, member.participant.value$]).pipe(
|
||||||
map(([connection, participant]) => {
|
map(([connection, participant]) => {
|
||||||
// do not render audio for local participant
|
// do not render audio for local participant
|
||||||
if (!connection || !participant || participant.isLocal)
|
if (!connection || !participant || participant.isLocal)
|
||||||
@@ -673,7 +679,7 @@ export function createCallViewModel$(
|
|||||||
let localUserMediaId: string | undefined = undefined;
|
let localUserMediaId: string | undefined = undefined;
|
||||||
// add local member if available
|
// add local member if available
|
||||||
if (localMatrixLivekitMember) {
|
if (localMatrixLivekitMember) {
|
||||||
const { userId, participant$, connection$, membership$ } =
|
const { userId, participant, connection$, membership$ } =
|
||||||
localMatrixLivekitMember;
|
localMatrixLivekitMember;
|
||||||
localUserMediaId = `${userId}:${membership$.value.deviceId}`; // should be membership$.value.membershipID which is not optional
|
localUserMediaId = `${userId}:${membership$.value.deviceId}`; // should be membership$.value.membershipID which is not optional
|
||||||
|
|
||||||
@@ -684,7 +690,7 @@ export function createCallViewModel$(
|
|||||||
dup,
|
dup,
|
||||||
localUserMediaId,
|
localUserMediaId,
|
||||||
userId,
|
userId,
|
||||||
participant$,
|
participant satisfies TaggedParticipant as TaggedParticipant, // Widen the type safely
|
||||||
connection$,
|
connection$,
|
||||||
],
|
],
|
||||||
data: undefined,
|
data: undefined,
|
||||||
@@ -695,7 +701,7 @@ export function createCallViewModel$(
|
|||||||
// add remote members that are available
|
// add remote members that are available
|
||||||
for (const {
|
for (const {
|
||||||
userId,
|
userId,
|
||||||
participant$,
|
participant,
|
||||||
connection$,
|
connection$,
|
||||||
membership$,
|
membership$,
|
||||||
} of matrixLivekitMembers.value) {
|
} of matrixLivekitMembers.value) {
|
||||||
@@ -704,7 +710,7 @@ export function createCallViewModel$(
|
|||||||
// const participantId = membership$.value?.identity;
|
// const participantId = membership$.value?.identity;
|
||||||
for (let dup = 0; dup < 1 + duplicateTiles; dup++) {
|
for (let dup = 0; dup < 1 + duplicateTiles; dup++) {
|
||||||
yield {
|
yield {
|
||||||
keys: [dup, userMediaId, userId, participant$, connection$],
|
keys: [dup, userMediaId, userId, participant, connection$],
|
||||||
data: undefined,
|
data: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -716,7 +722,7 @@ export function createCallViewModel$(
|
|||||||
dup,
|
dup,
|
||||||
participantId,
|
participantId,
|
||||||
userId,
|
userId,
|
||||||
participant$,
|
participant,
|
||||||
connection$,
|
connection$,
|
||||||
) => {
|
) => {
|
||||||
const livekitRoom$ = scope.behavior(
|
const livekitRoom$ = scope.behavior(
|
||||||
@@ -735,7 +741,7 @@ export function createCallViewModel$(
|
|||||||
scope,
|
scope,
|
||||||
`${participantId}:${dup}`,
|
`${participantId}:${dup}`,
|
||||||
userId,
|
userId,
|
||||||
participant$,
|
participant,
|
||||||
options.encryptionSystem,
|
options.encryptionSystem,
|
||||||
livekitRoom$,
|
livekitRoom$,
|
||||||
focusUrl$,
|
focusUrl$,
|
||||||
@@ -945,11 +951,12 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasRemoteScreenShares$: Observable<boolean> = spotlight$.pipe(
|
const hasRemoteScreenShares$ = scope.behavior<boolean>(
|
||||||
map((spotlight) =>
|
spotlight$.pipe(
|
||||||
spotlight.some((vm) => !vm.local && vm instanceof ScreenShareViewModel),
|
map((spotlight) =>
|
||||||
|
spotlight.some((vm) => !vm.local && vm instanceof ScreenShareViewModel),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
distinctUntilChanged(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const pipEnabled$ = scope.behavior(setPipEnabled$, false);
|
const pipEnabled$ = scope.behavior(setPipEnabled$, false);
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
import { type Behavior, constant } from "../Behavior";
|
import { type Behavior, constant } from "../Behavior";
|
||||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||||
import { type MediaDevices } from "../MediaDevices";
|
import { type MediaDevices } from "../MediaDevices";
|
||||||
|
import { type MatrixRTCMode } from "../../settings/settings";
|
||||||
|
|
||||||
mockConfig({
|
mockConfig({
|
||||||
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
||||||
@@ -80,117 +81,125 @@ export interface CallViewModelInputs {
|
|||||||
|
|
||||||
const localParticipant = mockLocalParticipant({ identity: "" });
|
const localParticipant = mockLocalParticipant({ identity: "" });
|
||||||
|
|
||||||
export function withCallViewModel(
|
export function withCallViewModel(mode: MatrixRTCMode) {
|
||||||
{
|
return (
|
||||||
remoteParticipants$ = constant([]),
|
{
|
||||||
rtcMembers$ = constant([localRtcMember]),
|
remoteParticipants$ = constant([]),
|
||||||
livekitConnectionState$: connectionState$ = constant(
|
rtcMembers$ = constant([localRtcMember]),
|
||||||
ConnectionState.Connected,
|
livekitConnectionState$: connectionState$ = constant(
|
||||||
),
|
ConnectionState.Connected,
|
||||||
speaking = new Map(),
|
),
|
||||||
mediaDevices = mockMediaDevices({}),
|
speaking = new Map(),
|
||||||
initialSyncState = SyncState.Syncing,
|
mediaDevices = mockMediaDevices({}),
|
||||||
windowSize$ = constant({ width: 1000, height: 800 }),
|
initialSyncState = SyncState.Syncing,
|
||||||
}: Partial<CallViewModelInputs> = {},
|
windowSize$ = constant({ width: 1000, height: 800 }),
|
||||||
continuation: (
|
}: Partial<CallViewModelInputs> = {},
|
||||||
vm: CallViewModel,
|
continuation: (
|
||||||
rtcSession: MockRTCSession,
|
vm: CallViewModel,
|
||||||
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
|
rtcSession: MockRTCSession,
|
||||||
setSyncState: (value: SyncState) => void,
|
subjects: {
|
||||||
) => void,
|
raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>>;
|
||||||
options: CallViewModelOptions = {
|
},
|
||||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
setSyncState: (value: SyncState) => void,
|
||||||
autoLeaveWhenOthersLeft: false,
|
) => void,
|
||||||
},
|
options: Partial<CallViewModelOptions> = {},
|
||||||
): void {
|
): void => {
|
||||||
let syncState = initialSyncState;
|
let syncState = initialSyncState;
|
||||||
const setSyncState = (value: SyncState): void => {
|
const setSyncState = (value: SyncState): void => {
|
||||||
const prev = syncState;
|
const prev = syncState;
|
||||||
syncState = value;
|
syncState = value;
|
||||||
room.client.emit(ClientEvent.Sync, value, prev);
|
room.client.emit(ClientEvent.Sync, value, prev);
|
||||||
};
|
};
|
||||||
const room = mockMatrixRoom({
|
const room = mockMatrixRoom({
|
||||||
client: new (class extends EventEmitter {
|
client: new (class extends EventEmitter {
|
||||||
public getUserId(): string | undefined {
|
public getUserId(): string | undefined {
|
||||||
return localRtcMember.userId;
|
return localRtcMember.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDeviceId(): string {
|
public getDeviceId(): string {
|
||||||
return localRtcMember.deviceId;
|
return localRtcMember.deviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDomain(): string {
|
public getDomain(): string {
|
||||||
return "example.com";
|
return "example.com";
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSyncState(): SyncState {
|
public getSyncState(): SyncState {
|
||||||
return syncState;
|
return syncState;
|
||||||
}
|
}
|
||||||
})() as Partial<MatrixClient> as MatrixClient,
|
})() as Partial<MatrixClient> as MatrixClient,
|
||||||
getMembers: () => Array.from(roomMembers.values()),
|
getMembers: () => Array.from(roomMembers.values()),
|
||||||
getMembersWithMembership: () => Array.from(roomMembers.values()),
|
getMembersWithMembership: () => Array.from(roomMembers.values()),
|
||||||
});
|
});
|
||||||
const rtcSession = new MockRTCSession(room, []).withMemberships(rtcMembers$);
|
const rtcSession = new MockRTCSession(room, []).withMemberships(
|
||||||
const participantsSpy = vi
|
rtcMembers$,
|
||||||
.spyOn(ComponentsCore, "connectedParticipantsObserver")
|
|
||||||
.mockReturnValue(remoteParticipants$);
|
|
||||||
const mediaSpy = vi
|
|
||||||
.spyOn(ComponentsCore, "observeParticipantMedia")
|
|
||||||
.mockImplementation((p) =>
|
|
||||||
of({ participant: p } as Partial<
|
|
||||||
ComponentsCore.ParticipantMedia<LocalParticipant>
|
|
||||||
> as ComponentsCore.ParticipantMedia<LocalParticipant>),
|
|
||||||
);
|
);
|
||||||
const eventsSpy = vi
|
const participantsSpy = vi
|
||||||
.spyOn(ComponentsCore, "observeParticipantEvents")
|
.spyOn(ComponentsCore, "connectedParticipantsObserver")
|
||||||
.mockImplementation((p, ...eventTypes) => {
|
.mockReturnValue(remoteParticipants$);
|
||||||
if (eventTypes.includes(ParticipantEvent.IsSpeakingChanged)) {
|
const mediaSpy = vi
|
||||||
return (speaking.get(p) ?? of(false)).pipe(
|
.spyOn(ComponentsCore, "observeParticipantMedia")
|
||||||
map((s): Participant => ({ ...p, isSpeaking: s }) as Participant),
|
.mockImplementation((p) =>
|
||||||
);
|
of({ participant: p } as Partial<
|
||||||
} else {
|
ComponentsCore.ParticipantMedia<LocalParticipant>
|
||||||
return of(p);
|
> as ComponentsCore.ParticipantMedia<LocalParticipant>),
|
||||||
}
|
);
|
||||||
|
const eventsSpy = vi
|
||||||
|
.spyOn(ComponentsCore, "observeParticipantEvents")
|
||||||
|
.mockImplementation((p, ...eventTypes) => {
|
||||||
|
if (eventTypes.includes(ParticipantEvent.IsSpeakingChanged)) {
|
||||||
|
return (speaking.get(p) ?? of(false)).pipe(
|
||||||
|
map((s): Participant => ({ ...p, isSpeaking: s }) as Participant),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return of(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const roomEventSelectorSpy = vi
|
||||||
|
.spyOn(ComponentsCore, "roomEventSelector")
|
||||||
|
.mockImplementation((_room, _eventType) => of());
|
||||||
|
const muteStates = mockMuteStates();
|
||||||
|
const raisedHands$ = new BehaviorSubject<Record<string, RaisedHandInfo>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const reactions$ = new BehaviorSubject<Record<string, ReactionInfo>>({});
|
||||||
|
|
||||||
|
const vm = createCallViewModel$(
|
||||||
|
testScope(),
|
||||||
|
rtcSession.asMockedSession(),
|
||||||
|
room,
|
||||||
|
mediaDevices,
|
||||||
|
muteStates,
|
||||||
|
{
|
||||||
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
|
autoLeaveWhenOthersLeft: false,
|
||||||
|
livekitRoomFactory: (): LivekitRoom =>
|
||||||
|
mockLivekitRoom({
|
||||||
|
localParticipant,
|
||||||
|
disconnect: async () => Promise.resolve(),
|
||||||
|
setE2EEEnabled: async () => Promise.resolve(),
|
||||||
|
}),
|
||||||
|
connectionState$,
|
||||||
|
windowSize$,
|
||||||
|
matrixRTCMode$: constant(mode),
|
||||||
|
...options,
|
||||||
|
},
|
||||||
|
raisedHands$,
|
||||||
|
reactions$,
|
||||||
|
new BehaviorSubject<ProcessorState>({
|
||||||
|
processor: undefined,
|
||||||
|
supported: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
onTestFinished(() => {
|
||||||
|
participantsSpy.mockRestore();
|
||||||
|
mediaSpy.mockRestore();
|
||||||
|
eventsSpy.mockRestore();
|
||||||
|
roomEventSelectorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
const roomEventSelectorSpy = vi
|
continuation(vm, rtcSession, { raisedHands$: raisedHands$ }, setSyncState);
|
||||||
.spyOn(ComponentsCore, "roomEventSelector")
|
};
|
||||||
.mockImplementation((_room, _eventType) => of());
|
|
||||||
const muteStates = mockMuteStates();
|
|
||||||
const raisedHands$ = new BehaviorSubject<Record<string, RaisedHandInfo>>({});
|
|
||||||
const reactions$ = new BehaviorSubject<Record<string, ReactionInfo>>({});
|
|
||||||
|
|
||||||
const vm = createCallViewModel$(
|
|
||||||
testScope(),
|
|
||||||
rtcSession.asMockedSession(),
|
|
||||||
room,
|
|
||||||
mediaDevices,
|
|
||||||
muteStates,
|
|
||||||
{
|
|
||||||
...options,
|
|
||||||
livekitRoomFactory: (): LivekitRoom =>
|
|
||||||
mockLivekitRoom({
|
|
||||||
localParticipant,
|
|
||||||
disconnect: async () => Promise.resolve(),
|
|
||||||
setE2EEEnabled: async () => Promise.resolve(),
|
|
||||||
}),
|
|
||||||
connectionState$,
|
|
||||||
windowSize$,
|
|
||||||
},
|
|
||||||
raisedHands$,
|
|
||||||
reactions$,
|
|
||||||
new BehaviorSubject<ProcessorState>({
|
|
||||||
processor: undefined,
|
|
||||||
supported: undefined,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
onTestFinished(() => {
|
|
||||||
participantsSpy.mockRestore();
|
|
||||||
mediaSpy.mockRestore();
|
|
||||||
eventsSpy.mockRestore();
|
|
||||||
roomEventSelectorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
continuation(vm, rtcSession, { raisedHands$: raisedHands$ }, setSyncState);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,198 +5,128 @@ 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 { afterEach, beforeEach, describe, expect, test } from "vitest";
|
import { describe, test } from "vitest";
|
||||||
import { firstValueFrom, of } from "rxjs";
|
|
||||||
|
|
||||||
import { createLayoutModeSwitch } from "./LayoutSwitch";
|
import { createLayoutModeSwitch } from "./LayoutSwitch";
|
||||||
import { ObservableScope } from "../ObservableScope";
|
import { testScope, withTestScheduler } from "../../utils/test";
|
||||||
import { constant } from "../Behavior";
|
|
||||||
import { withTestScheduler } from "../../utils/test";
|
|
||||||
|
|
||||||
let scope: ObservableScope;
|
function testLayoutSwitch({
|
||||||
beforeEach(() => {
|
windowMode = "n",
|
||||||
scope = new ObservableScope();
|
hasScreenShares = "n",
|
||||||
});
|
userSelection = "",
|
||||||
afterEach(() => {
|
expectedGridMode,
|
||||||
scope.end();
|
}: {
|
||||||
});
|
windowMode?: string;
|
||||||
|
hasScreenShares?: string;
|
||||||
describe("Default mode", () => {
|
userSelection?: string;
|
||||||
test("Should be in grid layout by default", async () => {
|
expectedGridMode: string;
|
||||||
const { gridMode$ } = createLayoutModeSwitch(
|
}): void {
|
||||||
scope,
|
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||||
constant("normal"),
|
|
||||||
of(false),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mode = await firstValueFrom(gridMode$);
|
|
||||||
expect(mode).toBe("grid");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Should switch to spotlight mode when window mode is flat", async () => {
|
|
||||||
const { gridMode$ } = createLayoutModeSwitch(
|
|
||||||
scope,
|
|
||||||
constant("flat"),
|
|
||||||
of(false),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mode = await firstValueFrom(gridMode$);
|
|
||||||
expect(mode).toBe("spotlight");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Should allow switching modes manually", () => {
|
|
||||||
withTestScheduler(({ cold, behavior, expectObservable, schedule }): void => {
|
|
||||||
const { gridMode$, setGridMode } = createLayoutModeSwitch(
|
const { gridMode$, setGridMode } = createLayoutModeSwitch(
|
||||||
scope,
|
testScope(),
|
||||||
behavior("n", { n: "normal" }),
|
behavior(windowMode, { n: "normal", N: "narrow", f: "flat" }),
|
||||||
cold("f", { f: false, t: true }),
|
behavior(hasScreenShares, { y: true, n: false }),
|
||||||
);
|
);
|
||||||
|
schedule(userSelection, {
|
||||||
schedule("--sgs", {
|
|
||||||
s: () => setGridMode("spotlight"),
|
|
||||||
g: () => setGridMode("grid"),
|
|
||||||
});
|
|
||||||
|
|
||||||
expectObservable(gridMode$).toBe("g-sgs", {
|
|
||||||
g: "grid",
|
|
||||||
s: "spotlight",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Should switch to spotlight mode when there is a remote screen share", () => {
|
|
||||||
withTestScheduler(({ cold, behavior, expectObservable }): void => {
|
|
||||||
const shareMarble = "f--t";
|
|
||||||
const gridsMarble = "g--s";
|
|
||||||
const { gridMode$ } = createLayoutModeSwitch(
|
|
||||||
scope,
|
|
||||||
behavior("n", { n: "normal" }),
|
|
||||||
cold(shareMarble, { f: false, t: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expectObservable(gridMode$).toBe(gridsMarble, {
|
|
||||||
g: "grid",
|
|
||||||
s: "spotlight",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Can manually force grid when there is a screenshare", () => {
|
|
||||||
withTestScheduler(({ cold, behavior, expectObservable, schedule }): void => {
|
|
||||||
const { gridMode$, setGridMode } = createLayoutModeSwitch(
|
|
||||||
scope,
|
|
||||||
behavior("n", { n: "normal" }),
|
|
||||||
cold("-ft", { f: false, t: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
schedule("---g", {
|
|
||||||
g: () => setGridMode("grid"),
|
|
||||||
});
|
|
||||||
|
|
||||||
expectObservable(gridMode$).toBe("ggsg", {
|
|
||||||
g: "grid",
|
|
||||||
s: "spotlight",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Should auto-switch after manually selected grid", () => {
|
|
||||||
withTestScheduler(({ cold, behavior, expectObservable, schedule }): void => {
|
|
||||||
const { gridMode$, setGridMode } = createLayoutModeSwitch(
|
|
||||||
scope,
|
|
||||||
behavior("n", { n: "normal" }),
|
|
||||||
// Two screenshares will happen in sequence
|
|
||||||
cold("-ft-ft", { f: false, t: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
// There was a screen-share that forced spotlight, then
|
|
||||||
// the user manually switch back to grid
|
|
||||||
schedule("---g", {
|
|
||||||
g: () => setGridMode("grid"),
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we did want to respect manual selection, the expectation would be:
|
|
||||||
// const expectation = "ggsg";
|
|
||||||
const expectation = "ggsg-s";
|
|
||||||
|
|
||||||
expectObservable(gridMode$).toBe(expectation, {
|
|
||||||
g: "grid",
|
|
||||||
s: "spotlight",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Should switch back to grid mode when the remote screen share ends", () => {
|
|
||||||
withTestScheduler(({ cold, behavior, expectObservable }): void => {
|
|
||||||
const shareMarble = "f--t--f-";
|
|
||||||
const gridsMarble = "g--s--g-";
|
|
||||||
const { gridMode$ } = createLayoutModeSwitch(
|
|
||||||
scope,
|
|
||||||
behavior("n", { n: "normal" }),
|
|
||||||
cold(shareMarble, { f: false, t: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expectObservable(gridMode$).toBe(gridsMarble, {
|
|
||||||
g: "grid",
|
|
||||||
s: "spotlight",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("can auto-switch to spotlight again after first screen share ends", () => {
|
|
||||||
withTestScheduler(({ cold, behavior, expectObservable }): void => {
|
|
||||||
const shareMarble = "ftft";
|
|
||||||
const gridsMarble = "gsgs";
|
|
||||||
const { gridMode$ } = createLayoutModeSwitch(
|
|
||||||
scope,
|
|
||||||
behavior("n", { n: "normal" }),
|
|
||||||
cold(shareMarble, { f: false, t: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expectObservable(gridMode$).toBe(gridsMarble, {
|
|
||||||
g: "grid",
|
|
||||||
s: "spotlight",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("can switch manually to grid after screen share while manually in spotlight", () => {
|
|
||||||
withTestScheduler(({ cold, behavior, schedule, expectObservable }): void => {
|
|
||||||
// Initially, no one is sharing. Then the user manually switches to
|
|
||||||
// spotlight. After a screen share starts, the user manually switches to
|
|
||||||
// grid.
|
|
||||||
const shareMarbles = " f-t-";
|
|
||||||
const setModeMarbles = "-s-g";
|
|
||||||
const expectation = " gs-g";
|
|
||||||
const { gridMode$, setGridMode } = createLayoutModeSwitch(
|
|
||||||
scope,
|
|
||||||
behavior("n", { n: "normal" }),
|
|
||||||
cold(shareMarbles, { f: false, t: true }),
|
|
||||||
);
|
|
||||||
schedule(setModeMarbles, {
|
|
||||||
g: () => setGridMode("grid"),
|
g: () => setGridMode("grid"),
|
||||||
s: () => setGridMode("spotlight"),
|
s: () => setGridMode("spotlight"),
|
||||||
});
|
});
|
||||||
|
expectObservable(gridMode$).toBe(expectedGridMode, {
|
||||||
expectObservable(gridMode$).toBe(expectation, {
|
|
||||||
g: "grid",
|
g: "grid",
|
||||||
s: "spotlight",
|
s: "spotlight",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("default mode", () => {
|
||||||
|
test("uses grid layout by default", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
expectedGridMode: "g",
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("uses spotlight mode when window mode is flat", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
windowMode: " f",
|
||||||
|
expectedGridMode: "s",
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Should auto-switch to spotlight when in flat window mode", () => {
|
test("allows switching modes manually", () =>
|
||||||
withTestScheduler(({ cold, behavior, expectObservable }): void => {
|
testLayoutSwitch({
|
||||||
const { gridMode$ } = createLayoutModeSwitch(
|
userSelection: " --sgs",
|
||||||
scope,
|
expectedGridMode: "g-sgs",
|
||||||
behavior("naf", { n: "normal", a: "narrow", f: "flat" }),
|
}));
|
||||||
cold("f", { f: false, t: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expectObservable(gridMode$).toBe("g-s-", {
|
test("switches to spotlight mode when there is a remote screen share", () =>
|
||||||
g: "grid",
|
testLayoutSwitch({
|
||||||
s: "spotlight",
|
hasScreenShares: " n--y",
|
||||||
});
|
expectedGridMode: "g--s",
|
||||||
});
|
}));
|
||||||
});
|
|
||||||
|
test("can manually switch to grid when there is a screenshare", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
hasScreenShares: " n-y",
|
||||||
|
userSelection: " ---g",
|
||||||
|
expectedGridMode: "g-sg",
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("auto-switches after manually selecting grid", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
// Two screenshares will happen in sequence. There is a screen share that
|
||||||
|
// forces spotlight, then the user manually switches back to grid.
|
||||||
|
hasScreenShares: " n-y-ny",
|
||||||
|
userSelection: " ---g",
|
||||||
|
expectedGridMode: "g-sg-s",
|
||||||
|
// If we did want to respect manual selection, the expectation would be: g-sg
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("switches back to grid mode when the remote screen share ends", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
hasScreenShares: " n--y--n",
|
||||||
|
expectedGridMode: "g--s--g",
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("auto-switches to spotlight again after first screen share ends", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
hasScreenShares: " nyny",
|
||||||
|
expectedGridMode: "gsgs",
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("switches manually to grid after screen share while manually in spotlight", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
// Initially, no one is sharing. Then the user manually switches to spotlight.
|
||||||
|
// After a screen share starts, the user manually switches to grid.
|
||||||
|
hasScreenShares: " n-y",
|
||||||
|
userSelection: " -s-g",
|
||||||
|
expectedGridMode: "gs-g",
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("auto-switches to spotlight when in flat window mode", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
// First normal, then narrow, then flat.
|
||||||
|
windowMode: " nNf",
|
||||||
|
expectedGridMode: "g-s",
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("allows switching modes manually when in flat window mode", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
// Window becomes flat, then user switches to grid and back.
|
||||||
|
// Finally the window returns to a normal shape.
|
||||||
|
windowMode: " nf--n",
|
||||||
|
userSelection: " --gs",
|
||||||
|
expectedGridMode: "gsgsg",
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("stays in spotlight while there are screen shares even when window mode changes", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
windowMode: " nfn",
|
||||||
|
hasScreenShares: " y",
|
||||||
|
expectedGridMode: "s",
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("ignores end of screen share until window mode returns to normal", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
windowMode: " nf-n",
|
||||||
|
hasScreenShares: " y-n",
|
||||||
|
expectedGridMode: "s--g",
|
||||||
|
}));
|
||||||
|
|||||||
@@ -6,122 +6,85 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
|
||||||
combineLatest,
|
combineLatest,
|
||||||
map,
|
map,
|
||||||
type Observable,
|
Subject,
|
||||||
scan,
|
startWith,
|
||||||
|
skipWhile,
|
||||||
|
switchMap,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
|
||||||
|
|
||||||
import { type GridMode, type WindowMode } from "./CallViewModel.ts";
|
import { type GridMode, type WindowMode } from "./CallViewModel.ts";
|
||||||
import { type Behavior } from "../Behavior.ts";
|
import { constant, type Behavior } from "../Behavior.ts";
|
||||||
import { type ObservableScope } from "../ObservableScope.ts";
|
import { type ObservableScope } from "../ObservableScope.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a layout mode switch that allows switching between grid and spotlight modes.
|
* Creates a layout mode switch that allows switching between grid and spotlight modes.
|
||||||
* The actual layout mode can be overridden to spotlight mode if there is a remote screen share active
|
* The actual layout mode might switch automatically to spotlight if there is a
|
||||||
* or if the window mode is flat.
|
* remote screen share active or if the window mode is flat.
|
||||||
*
|
*
|
||||||
* @param scope - The observable scope to manage subscriptions.
|
* @param scope - The observable scope to manage subscriptions.
|
||||||
* @param windowMode$ - The current window mode observable.
|
* @param windowMode$ - The current window mode.
|
||||||
* @param hasRemoteScreenShares$ - An observable indicating if there are remote screen shares active.
|
* @param hasRemoteScreenShares$ - A behavior indicating if there are remote screen shares active.
|
||||||
*/
|
*/
|
||||||
export function createLayoutModeSwitch(
|
export function createLayoutModeSwitch(
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
windowMode$: Behavior<WindowMode>,
|
windowMode$: Behavior<WindowMode>,
|
||||||
hasRemoteScreenShares$: Observable<boolean>,
|
hasRemoteScreenShares$: Behavior<boolean>,
|
||||||
): {
|
): {
|
||||||
gridMode$: Behavior<GridMode>;
|
gridMode$: Behavior<GridMode>;
|
||||||
setGridMode: (value: GridMode) => void;
|
setGridMode: (value: GridMode) => void;
|
||||||
} {
|
} {
|
||||||
const gridModeUserSelection$ = new BehaviorSubject<GridMode>("grid");
|
const userSelection$ = new Subject<GridMode>();
|
||||||
|
|
||||||
// Callback to set the grid mode desired by the user.
|
// Callback to set the grid mode desired by the user.
|
||||||
// Notice that this is only a preference, the actual grid mode can be overridden
|
// Notice that this is only a preference, the actual grid mode can be overridden
|
||||||
// if there is a remote screen share active.
|
// if there is a remote screen share active.
|
||||||
const setGridMode = (value: GridMode): void => {
|
const setGridMode = (value: GridMode): void => userSelection$.next(value);
|
||||||
gridModeUserSelection$.next(value);
|
|
||||||
};
|
/**
|
||||||
|
* The natural grid mode - the mode that the grid would prefer to be in,
|
||||||
|
* not accounting for the user's manual selections.
|
||||||
|
*/
|
||||||
|
const naturalGridMode$ = scope.behavior<GridMode>(
|
||||||
|
combineLatest(
|
||||||
|
[hasRemoteScreenShares$, windowMode$],
|
||||||
|
(hasRemoteScreenShares, windowMode) =>
|
||||||
|
// When there are screen shares or the window is flat (as with a phone
|
||||||
|
// in landscape orientation), spotlight is a better experience.
|
||||||
|
// We want screen shares to be big and readable, and we want flipping
|
||||||
|
// your phone into landscape to be a quick way of maximising the
|
||||||
|
// spotlight tile.
|
||||||
|
hasRemoteScreenShares || windowMode === "flat" ? "spotlight" : "grid",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The layout mode of the media tile grid.
|
* The layout mode of the media tile grid.
|
||||||
*/
|
*/
|
||||||
const gridMode$ =
|
const gridMode$ = scope.behavior<GridMode>(
|
||||||
// If the user hasn't selected spotlight and somebody starts screen sharing,
|
// Whenever the user makes a selection, we enter a new mode of behavior:
|
||||||
// automatically switch to spotlight mode and reset when screen sharing ends
|
userSelection$.pipe(
|
||||||
scope.behavior<GridMode>(
|
map((selection) => {
|
||||||
combineLatest([
|
if (selection === "grid")
|
||||||
gridModeUserSelection$,
|
// The user has selected grid mode. Start by respecting their choice,
|
||||||
hasRemoteScreenShares$,
|
// but then follow the natural mode again as soon as it matches.
|
||||||
windowMode$,
|
return naturalGridMode$.pipe(
|
||||||
]).pipe(
|
skipWhile((naturalMode) => naturalMode !== selection),
|
||||||
// Scan to keep track if we have auto-switched already or not.
|
startWith(selection),
|
||||||
// To allow the user to override the auto-switch by selecting grid mode again.
|
);
|
||||||
scan<
|
|
||||||
[GridMode, boolean, WindowMode],
|
|
||||||
{
|
|
||||||
mode: GridMode;
|
|
||||||
/** Remember if the change was user driven or not */
|
|
||||||
hasAutoSwitched: boolean;
|
|
||||||
/** To know if it is new screen share or an already handled */
|
|
||||||
hasScreenShares: boolean;
|
|
||||||
}
|
|
||||||
>(
|
|
||||||
(prev, [userSelection, hasScreenShares, windowMode]) => {
|
|
||||||
const isFlatMode = windowMode === "flat";
|
|
||||||
|
|
||||||
// Always force spotlight in flat mode, grid layout is not supported
|
// The user has selected spotlight mode. If this matches the natural
|
||||||
// in that mode.
|
// mode, then follow the natural mode going forward.
|
||||||
// TODO: strange that we do that for flat mode but not for other modes?
|
return selection === naturalGridMode$.value
|
||||||
// TODO: Why is this not handled in layoutMedia$ like other window modes?
|
? naturalGridMode$
|
||||||
if (isFlatMode) {
|
: constant(selection);
|
||||||
logger.debug(`Forcing spotlight mode, windowMode=${windowMode}`);
|
}),
|
||||||
return {
|
// Initially the mode of behavior is to just follow the natural grid mode.
|
||||||
mode: "spotlight",
|
startWith(naturalGridMode$),
|
||||||
hasAutoSwitched: prev.hasAutoSwitched,
|
// Switch between each mode of behavior.
|
||||||
hasScreenShares,
|
switchMap((mode$) => mode$),
|
||||||
};
|
),
|
||||||
}
|
);
|
||||||
|
|
||||||
// User explicitly chose spotlight.
|
|
||||||
// Respect that choice.
|
|
||||||
if (userSelection === "spotlight") {
|
|
||||||
return {
|
|
||||||
mode: "spotlight",
|
|
||||||
hasAutoSwitched: prev.hasAutoSwitched,
|
|
||||||
hasScreenShares,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// User has chosen grid mode. If a screen share starts, we will
|
|
||||||
// auto-switch to spotlight mode for better experience.
|
|
||||||
// But we only do it once, if the user switches back to grid mode,
|
|
||||||
// we respect that choice until they explicitly change it again.
|
|
||||||
const isNewShare = hasScreenShares && !prev.hasScreenShares;
|
|
||||||
if (isNewShare && !prev.hasAutoSwitched) {
|
|
||||||
return {
|
|
||||||
mode: "spotlight",
|
|
||||||
hasAutoSwitched: true,
|
|
||||||
hasScreenShares: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Respect user's grid choice
|
|
||||||
// XXX If we want to forbid switching automatically again after we can
|
|
||||||
// return hasAutoSwitched: acc.hasAutoSwitched here instead of setting to false.
|
|
||||||
return {
|
|
||||||
mode: "grid",
|
|
||||||
hasAutoSwitched: false,
|
|
||||||
hasScreenShares,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
// initial value
|
|
||||||
{ mode: "grid", hasAutoSwitched: false, hasScreenShares: false },
|
|
||||||
),
|
|
||||||
map(({ mode }) => mode),
|
|
||||||
),
|
|
||||||
"grid",
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
gridMode$,
|
gridMode$,
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ export class Publisher {
|
|||||||
const track$ = scope.behavior(
|
const track$ = scope.behavior(
|
||||||
observeTrackReference$(room.localParticipant, Track.Source.Camera).pipe(
|
observeTrackReference$(room.localParticipant, Track.Source.Camera).pipe(
|
||||||
map((trackRef) => {
|
map((trackRef) => {
|
||||||
const track = trackRef?.publication?.track;
|
const track = trackRef?.publication.track;
|
||||||
return track instanceof LocalVideoTrack ? track : null;
|
return track instanceof LocalVideoTrack ? track : null;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
Connection,
|
Connection,
|
||||||
ConnectionState,
|
ConnectionState,
|
||||||
type ConnectionOpts,
|
type ConnectionOpts,
|
||||||
type PublishingParticipant,
|
|
||||||
} from "./Connection.ts";
|
} from "./Connection.ts";
|
||||||
import { ObservableScope } from "../../ObservableScope.ts";
|
import { ObservableScope } from "../../ObservableScope.ts";
|
||||||
import { type OpenIDClientParts } from "../../../livekit/openIDSFU.ts";
|
import { type OpenIDClientParts } from "../../../livekit/openIDSFU.ts";
|
||||||
@@ -40,6 +39,7 @@ import {
|
|||||||
ElementCallError,
|
ElementCallError,
|
||||||
FailToGetOpenIdToken,
|
FailToGetOpenIdToken,
|
||||||
} from "../../../utils/errors.ts";
|
} from "../../../utils/errors.ts";
|
||||||
|
import { mockRemoteParticipant } from "../../../utils/test.ts";
|
||||||
|
|
||||||
let testScope: ObservableScope;
|
let testScope: ObservableScope;
|
||||||
|
|
||||||
@@ -377,46 +377,32 @@ describe("Start connection states", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function fakeRemoteLivekitParticipant(
|
describe("remote participants", () => {
|
||||||
id: string,
|
it("emits the list of remote participants", () => {
|
||||||
publications: number = 1,
|
|
||||||
): RemoteParticipant {
|
|
||||||
return {
|
|
||||||
identity: id,
|
|
||||||
getTrackPublications: () => Array(publications),
|
|
||||||
} as unknown as RemoteParticipant;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Publishing participants observations", () => {
|
|
||||||
it("should emit the list of publishing participants", () => {
|
|
||||||
setupTest();
|
setupTest();
|
||||||
|
|
||||||
const connection = setupRemoteConnection();
|
const connection = setupRemoteConnection();
|
||||||
|
|
||||||
const bobIsAPublisher = Promise.withResolvers<void>();
|
const observedParticipants: RemoteParticipant[][] = [];
|
||||||
const danIsAPublisher = Promise.withResolvers<void>();
|
const s = connection.remoteParticipants$.subscribe((participants) => {
|
||||||
const observedPublishers: PublishingParticipant[][] = [];
|
observedParticipants.push(participants);
|
||||||
const s = connection.remoteParticipantsWithTracks$.subscribe(
|
});
|
||||||
(publishers) => {
|
|
||||||
observedPublishers.push(publishers);
|
|
||||||
if (publishers.some((p) => p.identity === "@bob:example.org:DEV111")) {
|
|
||||||
bobIsAPublisher.resolve();
|
|
||||||
}
|
|
||||||
if (publishers.some((p) => p.identity === "@dan:example.org:DEV333")) {
|
|
||||||
danIsAPublisher.resolve();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
onTestFinished(() => s.unsubscribe());
|
onTestFinished(() => s.unsubscribe());
|
||||||
// The publishingParticipants$ observable is derived from the current members of the
|
// The remoteParticipants$ observable is derived from the current members of the
|
||||||
// livekitRoom and the rtc membership in order to publish the members that are publishing
|
// livekitRoom and the rtc membership in order to publish the members that are publishing
|
||||||
// on this connection.
|
// on this connection.
|
||||||
|
|
||||||
let participants: RemoteParticipant[] = [
|
const participants: RemoteParticipant[] = [
|
||||||
fakeRemoteLivekitParticipant("@alice:example.org:DEV000", 0),
|
mockRemoteParticipant({ identity: "@alice:example.org:DEV000" }),
|
||||||
fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 0),
|
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||||
fakeRemoteLivekitParticipant("@carol:example.org:DEV222", 0),
|
mockRemoteParticipant({ identity: "@carol:example.org:DEV222" }),
|
||||||
fakeRemoteLivekitParticipant("@dan:example.org:DEV333", 0),
|
// Mock Dan to have no published tracks. We want him to still show show up
|
||||||
|
// in the participants list.
|
||||||
|
mockRemoteParticipant({
|
||||||
|
identity: "@dan:example.org:DEV333",
|
||||||
|
getTrackPublication: () => undefined,
|
||||||
|
getTrackPublications: () => [],
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Let's simulate 3 members on the livekitRoom
|
// Let's simulate 3 members on the livekitRoom
|
||||||
@@ -428,21 +414,8 @@ describe("Publishing participants observations", () => {
|
|||||||
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
|
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
|
||||||
);
|
);
|
||||||
|
|
||||||
// At this point there should be no publishers
|
// All remote participants should be present
|
||||||
expect(observedPublishers.pop()!.length).toEqual(0);
|
expect(observedParticipants.pop()!.length).toEqual(4);
|
||||||
|
|
||||||
participants = [
|
|
||||||
fakeRemoteLivekitParticipant("@alice:example.org:DEV000", 1),
|
|
||||||
fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 1),
|
|
||||||
fakeRemoteLivekitParticipant("@carol:example.org:DEV222", 1),
|
|
||||||
fakeRemoteLivekitParticipant("@dan:example.org:DEV333", 2),
|
|
||||||
];
|
|
||||||
participants.forEach((p) =>
|
|
||||||
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
|
|
||||||
);
|
|
||||||
|
|
||||||
// At this point there should be no publishers
|
|
||||||
expect(observedPublishers.pop()!.length).toEqual(4);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be scoped to parent scope", (): void => {
|
it("should be scoped to parent scope", (): void => {
|
||||||
@@ -450,16 +423,14 @@ describe("Publishing participants observations", () => {
|
|||||||
|
|
||||||
const connection = setupRemoteConnection();
|
const connection = setupRemoteConnection();
|
||||||
|
|
||||||
let observedPublishers: PublishingParticipant[][] = [];
|
let observedParticipants: RemoteParticipant[][] = [];
|
||||||
const s = connection.remoteParticipantsWithTracks$.subscribe(
|
const s = connection.remoteParticipants$.subscribe((participants) => {
|
||||||
(publishers) => {
|
observedParticipants.push(participants);
|
||||||
observedPublishers.push(publishers);
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
onTestFinished(() => s.unsubscribe());
|
onTestFinished(() => s.unsubscribe());
|
||||||
|
|
||||||
let participants: RemoteParticipant[] = [
|
let participants: RemoteParticipant[] = [
|
||||||
fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 0),
|
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Let's simulate 3 members on the livekitRoom
|
// Let's simulate 3 members on the livekitRoom
|
||||||
@@ -471,35 +442,26 @@ describe("Publishing participants observations", () => {
|
|||||||
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, participant);
|
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, participant);
|
||||||
}
|
}
|
||||||
|
|
||||||
// At this point there should be no publishers
|
// We should have bob as a participant now
|
||||||
expect(observedPublishers.pop()!.length).toEqual(0);
|
const ps = observedParticipants.pop();
|
||||||
|
expect(ps?.length).toEqual(1);
|
||||||
participants = [fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 1)];
|
expect(ps?.[0]?.identity).toEqual("@bob:example.org:DEV111");
|
||||||
|
|
||||||
for (const participant of participants) {
|
|
||||||
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, participant);
|
|
||||||
}
|
|
||||||
|
|
||||||
// We should have bob has a publisher now
|
|
||||||
const publishers = observedPublishers.pop();
|
|
||||||
expect(publishers?.length).toEqual(1);
|
|
||||||
expect(publishers?.[0]?.identity).toEqual("@bob:example.org:DEV111");
|
|
||||||
|
|
||||||
// end the parent scope
|
// end the parent scope
|
||||||
testScope.end();
|
testScope.end();
|
||||||
observedPublishers = [];
|
observedParticipants = [];
|
||||||
|
|
||||||
// SHOULD NOT emit any more publishers as the scope is ended
|
// SHOULD NOT emit any more participants as the scope is ended
|
||||||
participants = participants.filter(
|
participants = participants.filter(
|
||||||
(p) => p.identity !== "@bob:example.org:DEV111",
|
(p) => p.identity !== "@bob:example.org:DEV111",
|
||||||
);
|
);
|
||||||
|
|
||||||
fakeLivekitRoom.emit(
|
fakeLivekitRoom.emit(
|
||||||
RoomEvent.ParticipantDisconnected,
|
RoomEvent.ParticipantDisconnected,
|
||||||
fakeRemoteLivekitParticipant("@bob:example.org:DEV111"),
|
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(observedPublishers.length).toEqual(0);
|
expect(observedParticipants.length).toEqual(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
ConnectionError,
|
ConnectionError,
|
||||||
type Room as LivekitRoom,
|
type Room as LivekitRoom,
|
||||||
type LocalParticipant,
|
|
||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
RoomEvent,
|
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { BehaviorSubject, map } from "rxjs";
|
import { BehaviorSubject, map } from "rxjs";
|
||||||
@@ -35,8 +33,6 @@ import {
|
|||||||
UnknownCallError,
|
UnknownCallError,
|
||||||
} from "../../../utils/errors.ts";
|
} from "../../../utils/errors.ts";
|
||||||
|
|
||||||
export type PublishingParticipant = LocalParticipant | RemoteParticipant;
|
|
||||||
|
|
||||||
export interface ConnectionOpts {
|
export interface ConnectionOpts {
|
||||||
/** The media transport to connect to. */
|
/** The media transport to connect to. */
|
||||||
transport: LivekitTransport;
|
transport: LivekitTransport;
|
||||||
@@ -99,13 +95,13 @@ export class Connection {
|
|||||||
private scope: ObservableScope;
|
private scope: ObservableScope;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An observable of the participants that are publishing on this connection. (Excluding our local participant)
|
* The remote LiveKit participants that are visible on this connection.
|
||||||
* This is derived from `participantsIncludingSubscribers$` and `remoteTransports$`.
|
*
|
||||||
* It filters the participants to only those that are associated with a membership that claims to publish on this connection.
|
* Note that this may include participants that are connected only to
|
||||||
|
* subscribe, or publishers that are otherwise unattested in MatrixRTC state.
|
||||||
|
* It is therefore more low-level than what should be presented to the user.
|
||||||
*/
|
*/
|
||||||
public readonly remoteParticipantsWithTracks$: Behavior<
|
public readonly remoteParticipants$: Behavior<RemoteParticipant[]>;
|
||||||
PublishingParticipant[]
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the connection has been stopped.
|
* Whether the connection has been stopped.
|
||||||
@@ -236,23 +232,9 @@ export class Connection {
|
|||||||
this.transport = transport;
|
this.transport = transport;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
|
|
||||||
// REMOTE participants with track!!!
|
this.remoteParticipants$ = scope.behavior(
|
||||||
// this.remoteParticipantsWithTracks$
|
// Only tracks remote participants
|
||||||
this.remoteParticipantsWithTracks$ = scope.behavior(
|
connectedParticipantsObserver(this.livekitRoom),
|
||||||
// only tracks remote participants
|
|
||||||
connectedParticipantsObserver(this.livekitRoom, {
|
|
||||||
additionalRoomEvents: [
|
|
||||||
RoomEvent.TrackPublished,
|
|
||||||
RoomEvent.TrackUnpublished,
|
|
||||||
],
|
|
||||||
}).pipe(
|
|
||||||
map((participants) => {
|
|
||||||
return participants.filter(
|
|
||||||
(participant) => participant.getTrackPublications().length > 0,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
scope.onEnd(() => {
|
scope.onEnd(() => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { BehaviorSubject } from "rxjs";
|
import { BehaviorSubject } from "rxjs";
|
||||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { type Participant as LivekitParticipant } from "livekit-client";
|
import { type RemoteParticipant } from "livekit-client";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
import { Epoch, mapEpoch, ObservableScope } from "../../ObservableScope.ts";
|
import { Epoch, mapEpoch, ObservableScope } from "../../ObservableScope.ts";
|
||||||
@@ -52,7 +52,7 @@ beforeEach(() => {
|
|||||||
(transport: LivekitTransport, scope: ObservableScope) => {
|
(transport: LivekitTransport, scope: ObservableScope) => {
|
||||||
const mockConnection = {
|
const mockConnection = {
|
||||||
transport,
|
transport,
|
||||||
remoteParticipantsWithTracks$: new BehaviorSubject([]),
|
remoteParticipants$: new BehaviorSubject([]),
|
||||||
} as unknown as Connection;
|
} as unknown as Connection;
|
||||||
vi.mocked(mockConnection).start = vi.fn();
|
vi.mocked(mockConnection).start = vi.fn();
|
||||||
vi.mocked(mockConnection).stop = vi.fn();
|
vi.mocked(mockConnection).stop = vi.fn();
|
||||||
@@ -200,24 +200,21 @@ describe("connections$ stream", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("connectionManagerData$ stream", () => {
|
describe("connectionManagerData$ stream", () => {
|
||||||
// Used in test to control fake connections' remoteParticipantsWithTracks$ streams
|
// Used in test to control fake connections' remoteParticipants$ streams
|
||||||
let fakePublishingParticipantsStreams: Map<
|
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
|
||||||
string,
|
|
||||||
Behavior<LivekitParticipant[]>
|
|
||||||
>;
|
|
||||||
|
|
||||||
function keyForTransport(transport: LivekitTransport): string {
|
function keyForTransport(transport: LivekitTransport): string {
|
||||||
return `${transport.livekit_service_url}|${transport.livekit_alias}`;
|
return `${transport.livekit_service_url}|${transport.livekit_alias}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fakePublishingParticipantsStreams = new Map();
|
fakeRemoteParticipantsStreams = new Map();
|
||||||
|
|
||||||
function getPublishingParticipantsFor(
|
function getRemoteParticipantsFor(
|
||||||
transport: LivekitTransport,
|
transport: LivekitTransport,
|
||||||
): Behavior<LivekitParticipant[]> {
|
): Behavior<RemoteParticipant[]> {
|
||||||
return (
|
return (
|
||||||
fakePublishingParticipantsStreams.get(keyForTransport(transport)) ??
|
fakeRemoteParticipantsStreams.get(keyForTransport(transport)) ??
|
||||||
new BehaviorSubject([])
|
new BehaviorSubject([])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -227,13 +224,12 @@ describe("connectionManagerData$ stream", () => {
|
|||||||
.fn()
|
.fn()
|
||||||
.mockImplementation(
|
.mockImplementation(
|
||||||
(transport: LivekitTransport, scope: ObservableScope) => {
|
(transport: LivekitTransport, scope: ObservableScope) => {
|
||||||
const fakePublishingParticipants$ = new BehaviorSubject<
|
const fakeRemoteParticipants$ = new BehaviorSubject<
|
||||||
LivekitParticipant[]
|
RemoteParticipant[]
|
||||||
>([]);
|
>([]);
|
||||||
const mockConnection = {
|
const mockConnection = {
|
||||||
transport,
|
transport,
|
||||||
remoteParticipantsWithTracks$:
|
remoteParticipants$: getRemoteParticipantsFor(transport),
|
||||||
getPublishingParticipantsFor(transport),
|
|
||||||
} as unknown as Connection;
|
} as unknown as Connection;
|
||||||
vi.mocked(mockConnection).start = vi.fn();
|
vi.mocked(mockConnection).start = vi.fn();
|
||||||
vi.mocked(mockConnection).stop = vi.fn();
|
vi.mocked(mockConnection).stop = vi.fn();
|
||||||
@@ -242,36 +238,36 @@ describe("connectionManagerData$ stream", () => {
|
|||||||
void mockConnection.stop();
|
void mockConnection.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
fakePublishingParticipantsStreams.set(
|
fakeRemoteParticipantsStreams.set(
|
||||||
keyForTransport(transport),
|
keyForTransport(transport),
|
||||||
fakePublishingParticipants$,
|
fakeRemoteParticipants$,
|
||||||
);
|
);
|
||||||
return mockConnection;
|
return mockConnection;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Should report connections with the publishing participants", () => {
|
test("Should report connections with the remote participants", () => {
|
||||||
withTestScheduler(({ expectObservable, schedule, behavior }) => {
|
withTestScheduler(({ expectObservable, schedule, behavior }) => {
|
||||||
// Setup the fake participants streams behavior
|
// Setup the fake participants streams behavior
|
||||||
// ==============================
|
// ==============================
|
||||||
fakePublishingParticipantsStreams.set(
|
fakeRemoteParticipantsStreams.set(
|
||||||
keyForTransport(TRANSPORT_1),
|
keyForTransport(TRANSPORT_1),
|
||||||
behavior("oa-b", {
|
behavior("oa-b", {
|
||||||
o: [],
|
o: [],
|
||||||
a: [{ identity: "user1A" } as LivekitParticipant],
|
a: [{ identity: "user1A" } as RemoteParticipant],
|
||||||
b: [
|
b: [
|
||||||
{ identity: "user1A" } as LivekitParticipant,
|
{ identity: "user1A" } as RemoteParticipant,
|
||||||
{ identity: "user1B" } as LivekitParticipant,
|
{ identity: "user1B" } as RemoteParticipant,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
fakePublishingParticipantsStreams.set(
|
fakeRemoteParticipantsStreams.set(
|
||||||
keyForTransport(TRANSPORT_2),
|
keyForTransport(TRANSPORT_2),
|
||||||
behavior("o-a", {
|
behavior("o-a", {
|
||||||
o: [],
|
o: [],
|
||||||
a: [{ identity: "user2A" } as LivekitParticipant],
|
a: [{ identity: "user2A" } as RemoteParticipant],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// ==============================
|
// ==============================
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { combineLatest, map, of, switchMap, tap } from "rxjs";
|
import { combineLatest, map, of, switchMap, tap } from "rxjs";
|
||||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
|
import { type RemoteParticipant } from "livekit-client";
|
||||||
|
|
||||||
import { type Behavior } from "../../Behavior.ts";
|
import { type Behavior } from "../../Behavior.ts";
|
||||||
import { type Connection } from "./Connection.ts";
|
import { type Connection } from "./Connection.ts";
|
||||||
@@ -19,17 +19,12 @@ import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
|
|||||||
import { type ConnectionFactory } from "./ConnectionFactory.ts";
|
import { type ConnectionFactory } from "./ConnectionFactory.ts";
|
||||||
|
|
||||||
export class ConnectionManagerData {
|
export class ConnectionManagerData {
|
||||||
private readonly store: Map<
|
private readonly store: Map<string, [Connection, RemoteParticipant[]]> =
|
||||||
string,
|
new Map();
|
||||||
[Connection, (LocalParticipant | RemoteParticipant)[]]
|
|
||||||
> = new Map();
|
|
||||||
|
|
||||||
public constructor() {}
|
public constructor() {}
|
||||||
|
|
||||||
public add(
|
public add(connection: Connection, participants: RemoteParticipant[]): void {
|
||||||
connection: Connection,
|
|
||||||
participants: (LocalParticipant | RemoteParticipant)[],
|
|
||||||
): void {
|
|
||||||
const key = this.getKey(connection.transport);
|
const key = this.getKey(connection.transport);
|
||||||
const existing = this.store.get(key);
|
const existing = this.store.get(key);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -55,7 +50,7 @@ export class ConnectionManagerData {
|
|||||||
|
|
||||||
public getParticipantForTransport(
|
public getParticipantForTransport(
|
||||||
transport: LivekitTransport,
|
transport: LivekitTransport,
|
||||||
): (LocalParticipant | RemoteParticipant)[] {
|
): RemoteParticipant[] {
|
||||||
const key = transport.livekit_service_url + "|" + transport.livekit_alias;
|
const key = transport.livekit_service_url + "|" + transport.livekit_alias;
|
||||||
return this.store.get(key)?.[1] ?? [];
|
return this.store.get(key)?.[1] ?? [];
|
||||||
}
|
}
|
||||||
@@ -67,10 +62,12 @@ interface Props {
|
|||||||
inputTransports$: Behavior<Epoch<LivekitTransport[]>>;
|
inputTransports$: Behavior<Epoch<LivekitTransport[]>>;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO - write test for scopes (do we really need to bind scope)
|
// TODO - write test for scopes (do we really need to bind scope)
|
||||||
export interface IConnectionManager {
|
export interface IConnectionManager {
|
||||||
connectionManagerData$: Behavior<Epoch<ConnectionManagerData>>;
|
connectionManagerData$: Behavior<Epoch<ConnectionManagerData>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crete a `ConnectionManager`
|
* Crete a `ConnectionManager`
|
||||||
* @param scope the observable scope used by this object.
|
* @param scope the observable scope used by this object.
|
||||||
@@ -153,23 +150,24 @@ export function createConnectionManager$({
|
|||||||
const epoch = connections.epoch;
|
const epoch = connections.epoch;
|
||||||
|
|
||||||
// Map the connections to list of {connection, participants}[]
|
// Map the connections to list of {connection, participants}[]
|
||||||
const listOfConnectionsWithPublishingParticipants =
|
const listOfConnectionsWithRemoteParticipants = connections.value.map(
|
||||||
connections.value.map((connection) => {
|
(connection) => {
|
||||||
return connection.remoteParticipantsWithTracks$.pipe(
|
return connection.remoteParticipants$.pipe(
|
||||||
map((participants) => ({
|
map((participants) => ({
|
||||||
connection,
|
connection,
|
||||||
participants,
|
participants,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// probably not required
|
// probably not required
|
||||||
if (listOfConnectionsWithPublishingParticipants.length === 0) {
|
if (listOfConnectionsWithRemoteParticipants.length === 0) {
|
||||||
return of(new Epoch(new ConnectionManagerData(), epoch));
|
return of(new Epoch(new ConnectionManagerData(), epoch));
|
||||||
}
|
}
|
||||||
|
|
||||||
// combineLatest the several streams into a single stream with the ConnectionManagerData
|
// combineLatest the several streams into a single stream with the ConnectionManagerData
|
||||||
return combineLatest(listOfConnectionsWithPublishingParticipants).pipe(
|
return combineLatest(listOfConnectionsWithRemoteParticipants).pipe(
|
||||||
map(
|
map(
|
||||||
(lists) =>
|
(lists) =>
|
||||||
new Epoch(
|
new Epoch(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { BehaviorSubject, combineLatest, map, type Observable } from "rxjs";
|
|||||||
|
|
||||||
import { type IConnectionManager } from "./ConnectionManager.ts";
|
import { type IConnectionManager } from "./ConnectionManager.ts";
|
||||||
import {
|
import {
|
||||||
type MatrixLivekitMember,
|
type RemoteMatrixLivekitMember,
|
||||||
createMatrixLivekitMembers$,
|
createMatrixLivekitMembers$,
|
||||||
} from "./MatrixLivekitMembers.ts";
|
} from "./MatrixLivekitMembers.ts";
|
||||||
import {
|
import {
|
||||||
@@ -102,10 +102,10 @@ test("should signal participant not yet connected to livekit", async () => {
|
|||||||
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(matrixLivekitMember$.value.value).toSatisfy(
|
expect(matrixLivekitMember$.value.value).toSatisfy(
|
||||||
(data: MatrixLivekitMember[]) => {
|
(data: RemoteMatrixLivekitMember[]) => {
|
||||||
expect(data.length).toEqual(1);
|
expect(data.length).toEqual(1);
|
||||||
expect(data[0].membership$.value).toBe(bobMembership);
|
expect(data[0].membership$.value).toBe(bobMembership);
|
||||||
expect(data[0].participant$.value).toBe(null);
|
expect(data[0].participant.value$.value).toBe(null);
|
||||||
expect(data[0].connection$.value).toBe(null);
|
expect(data[0].connection$.value).toBe(null);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -171,10 +171,10 @@ test("should signal participant on a connection that is publishing", async () =>
|
|||||||
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(matrixLivekitMember$.value.value).toSatisfy(
|
expect(matrixLivekitMember$.value.value).toSatisfy(
|
||||||
(data: MatrixLivekitMember[]) => {
|
(data: RemoteMatrixLivekitMember[]) => {
|
||||||
expect(data.length).toEqual(1);
|
expect(data.length).toEqual(1);
|
||||||
expect(data[0].membership$.value).toBe(bobMembership);
|
expect(data[0].membership$.value).toBe(bobMembership);
|
||||||
expect(data[0].participant$.value).toSatisfy((participant) => {
|
expect(data[0].participant.value$.value).toSatisfy((participant) => {
|
||||||
expect(participant).toBeDefined();
|
expect(participant).toBeDefined();
|
||||||
expect(participant!.identity).toEqual(bobParticipantId);
|
expect(participant!.identity).toEqual(bobParticipantId);
|
||||||
return true;
|
return true;
|
||||||
@@ -210,10 +210,10 @@ test("should signal participant on a connection that is not publishing", async (
|
|||||||
});
|
});
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(matrixLivekitMember$.value.value).toSatisfy(
|
expect(matrixLivekitMember$.value.value).toSatisfy(
|
||||||
(data: MatrixLivekitMember[]) => {
|
(data: RemoteMatrixLivekitMember[]) => {
|
||||||
expect(data.length).toEqual(1);
|
expect(data.length).toEqual(1);
|
||||||
expect(data[0].membership$.value).toBe(bobMembership);
|
expect(data[0].membership$.value).toBe(bobMembership);
|
||||||
expect(data[0].participant$.value).toBe(null);
|
expect(data[0].participant.value$.value).toBe(null);
|
||||||
expect(data[0].connection$.value).toBe(connection);
|
expect(data[0].connection$.value).toBe(connection);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -258,11 +258,11 @@ describe("Publication edge case", () => {
|
|||||||
});
|
});
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(matrixLivekitMember$.value.value).toSatisfy(
|
expect(matrixLivekitMember$.value.value).toSatisfy(
|
||||||
(data: MatrixLivekitMember[]) => {
|
(data: RemoteMatrixLivekitMember[]) => {
|
||||||
expect(data.length).toEqual(2);
|
expect(data.length).toEqual(2);
|
||||||
expect(data[0].membership$.value).toBe(bobMembership);
|
expect(data[0].membership$.value).toBe(bobMembership);
|
||||||
expect(data[0].connection$.value).toBe(connectionA);
|
expect(data[0].connection$.value).toBe(connectionA);
|
||||||
expect(data[0].participant$.value).toSatisfy((participant) => {
|
expect(data[0].participant.value$.value).toSatisfy((participant) => {
|
||||||
expect(participant).toBeDefined();
|
expect(participant).toBeDefined();
|
||||||
expect(participant!.identity).toEqual(bobParticipantId);
|
expect(participant!.identity).toEqual(bobParticipantId);
|
||||||
return true;
|
return true;
|
||||||
@@ -317,11 +317,11 @@ test("bob is publishing in the wrong connection", async () => {
|
|||||||
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(matrixLivekitMember$.value.value).toSatisfy(
|
expect(matrixLivekitMember$.value.value).toSatisfy(
|
||||||
(data: MatrixLivekitMember[]) => {
|
(data: RemoteMatrixLivekitMember[]) => {
|
||||||
expect(data.length).toEqual(2);
|
expect(data.length).toEqual(2);
|
||||||
expect(data[0].membership$.value).toBe(bobMembership);
|
expect(data[0].membership$.value).toBe(bobMembership);
|
||||||
expect(data[0].connection$.value).toBe(connectionA);
|
expect(data[0].connection$.value).toBe(connectionA);
|
||||||
expect(data[0].participant$.value).toBe(null);
|
expect(data[0].participant.value$.value).toBe(null);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
|
||||||
type LocalParticipant as LocalLivekitParticipant,
|
|
||||||
type RemoteParticipant as RemoteLivekitParticipant,
|
|
||||||
} from "livekit-client";
|
|
||||||
import {
|
import {
|
||||||
type LivekitTransport,
|
type LivekitTransport,
|
||||||
type CallMembership,
|
type CallMembership,
|
||||||
@@ -25,22 +22,44 @@ import { computeLivekitParticipantIdentity$ } from "./LivekitParticipantIdentity
|
|||||||
|
|
||||||
const logger = rootLogger.getChild("[MatrixLivekitMembers]");
|
const logger = rootLogger.getChild("[MatrixLivekitMembers]");
|
||||||
|
|
||||||
/**
|
interface LocalTaggedParticipant {
|
||||||
* Represents a Matrix call member and their associated LiveKit participation.
|
type: "local";
|
||||||
* `livekitParticipant` can be undefined if the member is not yet connected to the livekit room
|
value$: Behavior<LocalParticipant | null>;
|
||||||
* or if it has no livekit transport at all.
|
}
|
||||||
*/
|
interface RemoteTaggedParticipant {
|
||||||
export interface MatrixLivekitMember {
|
type: "remote";
|
||||||
|
value$: Behavior<RemoteParticipant | null>;
|
||||||
|
}
|
||||||
|
export type TaggedParticipant =
|
||||||
|
| LocalTaggedParticipant
|
||||||
|
| RemoteTaggedParticipant;
|
||||||
|
|
||||||
|
interface MatrixLivekitMember {
|
||||||
membership$: Behavior<CallMembership>;
|
membership$: Behavior<CallMembership>;
|
||||||
participant$: Behavior<
|
|
||||||
LocalLivekitParticipant | RemoteLivekitParticipant | null
|
|
||||||
>;
|
|
||||||
connection$: Behavior<Connection | null>;
|
connection$: Behavior<Connection | null>;
|
||||||
// participantId: string; We do not want a participantId here since it will be generated by the jwt
|
// participantId: string; We do not want a participantId here since it will be generated by the jwt
|
||||||
// TODO decide if we can also drop the userId. Its in the matrix membership anyways.
|
// TODO decide if we can also drop the userId. Its in the matrix membership anyways.
|
||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the local Matrix call member and their associated LiveKit participation.
|
||||||
|
* `livekitParticipant` can be null if the member is not yet connected to the livekit room
|
||||||
|
* or if it has no livekit transport at all.
|
||||||
|
*/
|
||||||
|
export interface LocalMatrixLivekitMember extends MatrixLivekitMember {
|
||||||
|
participant: LocalTaggedParticipant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a remote Matrix call member and their associated LiveKit participation.
|
||||||
|
* `livekitParticipant` can be null if the member is not yet connected to the livekit room
|
||||||
|
* or if it has no livekit transport at all.
|
||||||
|
*/
|
||||||
|
export interface RemoteMatrixLivekitMember extends MatrixLivekitMember {
|
||||||
|
participant: RemoteTaggedParticipant;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
scope: ObservableScope;
|
scope: ObservableScope;
|
||||||
membershipsWithTransport$: Behavior<
|
membershipsWithTransport$: Behavior<
|
||||||
@@ -62,7 +81,7 @@ export function createMatrixLivekitMembers$({
|
|||||||
scope,
|
scope,
|
||||||
membershipsWithTransport$,
|
membershipsWithTransport$,
|
||||||
connectionManager,
|
connectionManager,
|
||||||
}: Props): Behavior<Epoch<MatrixLivekitMember[]>> {
|
}: Props): Behavior<Epoch<RemoteMatrixLivekitMember[]>> {
|
||||||
/**
|
/**
|
||||||
* This internal observable is used to compute the async sha256 hash of the user's identity.
|
* This internal observable is used to compute the async sha256 hash of the user's identity.
|
||||||
* a promise is treated like an observable. So we can switchMap on the promise from the identity computation.
|
* a promise is treated like an observable. So we can switchMap on the promise from the identity computation.
|
||||||
@@ -138,12 +157,14 @@ export function createMatrixLivekitMembers$({
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
`Generating member for livekitIdentity: ${identity}, userId:deviceId: ${userId}${deviceId}`,
|
`Generating member for livekitIdentity: ${identity}, userId:deviceId: ${userId}${deviceId}`,
|
||||||
);
|
);
|
||||||
|
const { participant$, ...rest } = scope.splitBehavior(data$);
|
||||||
// will only get called once per `participantId, userId` pair.
|
// will only get called once per `participantId, userId` pair.
|
||||||
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
|
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
|
||||||
return {
|
return {
|
||||||
identity,
|
identity,
|
||||||
userId,
|
userId,
|
||||||
...scope.splitBehavior(data$),
|
participant: { type: "remote" as const, value$: participant$ },
|
||||||
|
...rest,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { type ProcessorState } from "../../../livekit/TrackProcessorContext.tsx"
|
|||||||
import {
|
import {
|
||||||
areLivekitTransportsEqual,
|
areLivekitTransportsEqual,
|
||||||
createMatrixLivekitMembers$,
|
createMatrixLivekitMembers$,
|
||||||
type MatrixLivekitMember,
|
type RemoteMatrixLivekitMember,
|
||||||
} from "./MatrixLivekitMembers.ts";
|
} from "./MatrixLivekitMembers.ts";
|
||||||
import { createConnectionManager$ } from "./ConnectionManager.ts";
|
import { createConnectionManager$ } from "./ConnectionManager.ts";
|
||||||
import { membershipsAndTransports$ } from "../../SessionBehaviors.ts";
|
import { membershipsAndTransports$ } from "../../SessionBehaviors.ts";
|
||||||
@@ -138,7 +138,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expectObservable(matrixLivekitItems$).toBe(vMarble, {
|
expectObservable(matrixLivekitItems$).toBe(vMarble, {
|
||||||
a: expect.toSatisfy((e: Epoch<MatrixLivekitMember[]>) => {
|
a: expect.toSatisfy((e: Epoch<RemoteMatrixLivekitMember[]>) => {
|
||||||
const items = e.value;
|
const items = e.value;
|
||||||
expect(items.length).toBe(1);
|
expect(items.length).toBe(1);
|
||||||
const item = items[0]!;
|
const item = items[0]!;
|
||||||
@@ -153,12 +153,12 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
expectObservable(item.participant$).toBe("a", {
|
expectObservable(item.participant.value$).toBe("a", {
|
||||||
a: null,
|
a: null,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
b: expect.toSatisfy((e: Epoch<MatrixLivekitMember[]>) => {
|
b: expect.toSatisfy((e: Epoch<RemoteMatrixLivekitMember[]>) => {
|
||||||
const items = e.value;
|
const items = e.value;
|
||||||
expect(items.length).toBe(2);
|
expect(items.length).toBe(2);
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
|||||||
expectObservable(item.membership$).toBe("a", {
|
expectObservable(item.membership$).toBe("a", {
|
||||||
a: bobMembership,
|
a: bobMembership,
|
||||||
});
|
});
|
||||||
expectObservable(item.participant$).toBe("a", {
|
expectObservable(item.participant.value$).toBe("a", {
|
||||||
a: null,
|
a: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -178,7 +178,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
|||||||
expectObservable(item.membership$).toBe("a", {
|
expectObservable(item.membership$).toBe("a", {
|
||||||
a: carlMembership,
|
a: carlMembership,
|
||||||
});
|
});
|
||||||
expectObservable(item.participant$).toBe("a", {
|
expectObservable(item.participant.value$).toBe("a", {
|
||||||
a: null,
|
a: null,
|
||||||
});
|
});
|
||||||
expectObservable(item.connection$).toBe("a", {
|
expectObservable(item.connection$).toBe("a", {
|
||||||
@@ -195,7 +195,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
c: expect.toSatisfy((e: Epoch<MatrixLivekitMember[]>) => {
|
c: expect.toSatisfy((e: Epoch<RemoteMatrixLivekitMember[]>) => {
|
||||||
const items = e.value;
|
const items = e.value;
|
||||||
expect(items.length).toBe(3);
|
expect(items.length).toBe(3);
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
|||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expectObservable(item.participant$).toBe("a", {
|
expectObservable(item.participant.value$).toBe("a", {
|
||||||
a: null,
|
a: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { constant } from "./Behavior.ts";
|
|||||||
import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts";
|
import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts";
|
||||||
import { ElementWidgetActions, widget } from "../widget.ts";
|
import { ElementWidgetActions, widget } from "../widget.ts";
|
||||||
import { E2eeType } from "../e2ee/e2eeType.ts";
|
import { E2eeType } from "../e2ee/e2eeType.ts";
|
||||||
|
import { MatrixRTCMode } from "../settings/settings.ts";
|
||||||
|
|
||||||
vi.mock("@livekit/components-core", { spy: true });
|
vi.mock("@livekit/components-core", { spy: true });
|
||||||
|
|
||||||
@@ -34,36 +35,43 @@ vi.mock("../widget", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it("expect leave when ElementWidgetActions.HangupCall is called", async () => {
|
it.each([
|
||||||
const pr = Promise.withResolvers<string>();
|
[MatrixRTCMode.Legacy],
|
||||||
withCallViewModel(
|
[MatrixRTCMode.Compatibil],
|
||||||
{
|
[MatrixRTCMode.Matrix_2_0],
|
||||||
remoteParticipants$: constant([aliceParticipant]),
|
])(
|
||||||
rtcMembers$: constant([localRtcMember]),
|
"expect leave when ElementWidgetActions.HangupCall is called (%s mode)",
|
||||||
},
|
async (mode) => {
|
||||||
(vm: CallViewModel) => {
|
const pr = Promise.withResolvers<string>();
|
||||||
vm.leave$.subscribe((s: string) => {
|
withCallViewModel(mode)(
|
||||||
pr.resolve(s);
|
{
|
||||||
});
|
remoteParticipants$: constant([aliceParticipant]),
|
||||||
|
rtcMembers$: constant([localRtcMember]),
|
||||||
|
},
|
||||||
|
(vm: CallViewModel) => {
|
||||||
|
vm.leave$.subscribe((s: string) => {
|
||||||
|
pr.resolve(s);
|
||||||
|
});
|
||||||
|
|
||||||
widget!.lazyActions!.emit(
|
widget!.lazyActions!.emit(
|
||||||
ElementWidgetActions.HangupCall,
|
ElementWidgetActions.HangupCall,
|
||||||
new CustomEvent(ElementWidgetActions.HangupCall, {
|
new CustomEvent(ElementWidgetActions.HangupCall, {
|
||||||
detail: {
|
detail: {
|
||||||
action: "im.vector.hangup",
|
action: "im.vector.hangup",
|
||||||
api: "toWidget",
|
api: "toWidget",
|
||||||
data: {},
|
data: {},
|
||||||
requestId: "widgetapi-1761237395918",
|
requestId: "widgetapi-1761237395918",
|
||||||
widgetId: "mrUjS9T6uKUOWHMxXvLbSv0F",
|
widgetId: "mrUjS9T6uKUOWHMxXvLbSv0F",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const source = await pr.promise;
|
const source = await pr.promise;
|
||||||
expect(source).toBe("user");
|
expect(source).toBe("user");
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
createLocalMedia,
|
createLocalMedia,
|
||||||
createRemoteMedia,
|
createRemoteMedia,
|
||||||
withTestScheduler,
|
withTestScheduler,
|
||||||
|
mockRemoteParticipant,
|
||||||
} from "../utils/test";
|
} from "../utils/test";
|
||||||
import { getValue } from "../utils/observable";
|
import { getValue } from "../utils/observable";
|
||||||
import { constant } from "./Behavior";
|
import { constant } from "./Behavior";
|
||||||
@@ -44,7 +45,11 @@ const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
|
|||||||
|
|
||||||
test("control a participant's volume", () => {
|
test("control a participant's volume", () => {
|
||||||
const setVolumeSpy = vi.fn();
|
const setVolumeSpy = vi.fn();
|
||||||
const vm = createRemoteMedia(rtcMembership, {}, { setVolume: setVolumeSpy });
|
const vm = createRemoteMedia(
|
||||||
|
rtcMembership,
|
||||||
|
{},
|
||||||
|
mockRemoteParticipant({ setVolume: setVolumeSpy }),
|
||||||
|
);
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
schedule("-ab---c---d|", {
|
schedule("-ab---c---d|", {
|
||||||
a() {
|
a() {
|
||||||
@@ -88,7 +93,7 @@ test("control a participant's volume", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("toggle fit/contain for a participant's video", () => {
|
test("toggle fit/contain for a participant's video", () => {
|
||||||
const vm = createRemoteMedia(rtcMembership, {}, {});
|
const vm = createRemoteMedia(rtcMembership, {}, mockRemoteParticipant({}));
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
schedule("-ab|", {
|
schedule("-ab|", {
|
||||||
a: () => vm.toggleFitContain(),
|
a: () => vm.toggleFitContain(),
|
||||||
@@ -199,3 +204,35 @@ test("switch cameras", async () => {
|
|||||||
});
|
});
|
||||||
expect(deviceId).toBe("front camera");
|
expect(deviceId).toBe("front camera");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("remote media is in waiting state when participant has not yet connected", () => {
|
||||||
|
const vm = createRemoteMedia(rtcMembership, {}, null); // null participant
|
||||||
|
expect(vm.waitingForMedia$.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remote media is not in waiting state when participant is connected", () => {
|
||||||
|
const vm = createRemoteMedia(rtcMembership, {}, mockRemoteParticipant({}));
|
||||||
|
expect(vm.waitingForMedia$.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remote media is not in waiting state when participant is connected with no publications", () => {
|
||||||
|
const vm = createRemoteMedia(
|
||||||
|
rtcMembership,
|
||||||
|
{},
|
||||||
|
mockRemoteParticipant({
|
||||||
|
getTrackPublication: () => undefined,
|
||||||
|
getTrackPublications: () => [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(vm.waitingForMedia$.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remote media is not in waiting state when user does not intend to publish anywhere", () => {
|
||||||
|
const vm = createRemoteMedia(
|
||||||
|
rtcMembership,
|
||||||
|
{},
|
||||||
|
mockRemoteParticipant({}),
|
||||||
|
undefined, // No room (no advertised transport)
|
||||||
|
);
|
||||||
|
expect(vm.waitingForMedia$.value).toBe(false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type AudioSource,
|
type AudioSource,
|
||||||
type TrackReferenceOrPlaceholder,
|
|
||||||
type VideoSource,
|
type VideoSource,
|
||||||
|
type TrackReference,
|
||||||
observeParticipantEvents,
|
observeParticipantEvents,
|
||||||
observeParticipantMedia,
|
observeParticipantMedia,
|
||||||
roomEventSelector,
|
roomEventSelector,
|
||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
type Observable,
|
type Observable,
|
||||||
Subject,
|
Subject,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
distinctUntilKeyChanged,
|
|
||||||
filter,
|
filter,
|
||||||
fromEvent,
|
fromEvent,
|
||||||
interval,
|
interval,
|
||||||
@@ -60,14 +59,11 @@ import { type ObservableScope } from "./ObservableScope";
|
|||||||
export function observeTrackReference$(
|
export function observeTrackReference$(
|
||||||
participant: Participant,
|
participant: Participant,
|
||||||
source: Track.Source,
|
source: Track.Source,
|
||||||
): Observable<TrackReferenceOrPlaceholder> {
|
): Observable<TrackReference | undefined> {
|
||||||
return observeParticipantMedia(participant).pipe(
|
return observeParticipantMedia(participant).pipe(
|
||||||
map(() => ({
|
map(() => participant.getTrackPublication(source)),
|
||||||
participant: participant,
|
distinctUntilChanged(),
|
||||||
publication: participant.getTrackPublication(source),
|
map((publication) => publication && { participant, publication, source }),
|
||||||
source,
|
|
||||||
})),
|
|
||||||
distinctUntilKeyChanged("publication"),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +222,7 @@ abstract class BaseMediaViewModel {
|
|||||||
/**
|
/**
|
||||||
* The LiveKit video track for this media.
|
* The LiveKit video track for this media.
|
||||||
*/
|
*/
|
||||||
public readonly video$: Behavior<TrackReferenceOrPlaceholder | null>;
|
public readonly video$: Behavior<TrackReference | undefined>;
|
||||||
/**
|
/**
|
||||||
* Whether there should be a warning that this media is unencrypted.
|
* Whether there should be a warning that this media is unencrypted.
|
||||||
*/
|
*/
|
||||||
@@ -241,10 +237,12 @@ abstract class BaseMediaViewModel {
|
|||||||
|
|
||||||
private observeTrackReference$(
|
private observeTrackReference$(
|
||||||
source: Track.Source,
|
source: Track.Source,
|
||||||
): Behavior<TrackReferenceOrPlaceholder | null> {
|
): Behavior<TrackReference | undefined> {
|
||||||
return this.scope.behavior(
|
return this.scope.behavior(
|
||||||
this.participant$.pipe(
|
this.participant$.pipe(
|
||||||
switchMap((p) => (!p ? of(null) : observeTrackReference$(p, source))),
|
switchMap((p) =>
|
||||||
|
!p ? of(undefined) : observeTrackReference$(p, source),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -268,7 +266,7 @@ abstract class BaseMediaViewModel {
|
|||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
audioSource: AudioSource,
|
audioSource: AudioSource,
|
||||||
videoSource: VideoSource,
|
videoSource: VideoSource,
|
||||||
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
protected readonly livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||||
public readonly focusUrl$: Behavior<string | undefined>,
|
public readonly focusUrl$: Behavior<string | undefined>,
|
||||||
public readonly displayName$: Behavior<string>,
|
public readonly displayName$: Behavior<string>,
|
||||||
public readonly mxcAvatarUrl$: Behavior<string | undefined>,
|
public readonly mxcAvatarUrl$: Behavior<string | undefined>,
|
||||||
@@ -281,8 +279,8 @@ abstract class BaseMediaViewModel {
|
|||||||
[audio$, this.video$],
|
[audio$, this.video$],
|
||||||
(a, v) =>
|
(a, v) =>
|
||||||
encryptionSystem.kind !== E2eeType.NONE &&
|
encryptionSystem.kind !== E2eeType.NONE &&
|
||||||
(a?.publication?.isEncrypted === false ||
|
(a?.publication.isEncrypted === false ||
|
||||||
v?.publication?.isEncrypted === false),
|
v?.publication.isEncrypted === false),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -471,7 +469,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
private readonly videoTrack$: Observable<LocalVideoTrack | null> =
|
private readonly videoTrack$: Observable<LocalVideoTrack | null> =
|
||||||
this.video$.pipe(
|
this.video$.pipe(
|
||||||
switchMap((v) => {
|
switchMap((v) => {
|
||||||
const track = v?.publication?.track;
|
const track = v?.publication.track;
|
||||||
if (!(track instanceof LocalVideoTrack)) return of(null);
|
if (!(track instanceof LocalVideoTrack)) return of(null);
|
||||||
return merge(
|
return merge(
|
||||||
// Watch for track restarts because they indicate a camera switch.
|
// Watch for track restarts because they indicate a camera switch.
|
||||||
@@ -596,6 +594,21 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
* A remote participant's user media.
|
* A remote participant's user media.
|
||||||
*/
|
*/
|
||||||
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||||
|
/**
|
||||||
|
* Whether we are waiting for this user's LiveKit participant to exist. This
|
||||||
|
* could be because either we or the remote party are still connecting.
|
||||||
|
*/
|
||||||
|
public readonly waitingForMedia$ = this.scope.behavior<boolean>(
|
||||||
|
combineLatest(
|
||||||
|
[this.livekitRoom$, this.participant$],
|
||||||
|
(livekitRoom, participant) =>
|
||||||
|
// If livekitRoom is undefined, the user is not attempting to publish on
|
||||||
|
// any transport and so we shouldn't expect a participant. (They might
|
||||||
|
// be a subscribe-only bot for example.)
|
||||||
|
livekitRoom !== undefined && participant === null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// This private field is used to override the value from the superclass
|
// This private field is used to override the value from the superclass
|
||||||
private __speaking$: Behavior<boolean>;
|
private __speaking$: Behavior<boolean>;
|
||||||
public get speaking$(): Behavior<boolean> {
|
public get speaking$(): Behavior<boolean> {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type { ReactionOption } from "../reactions";
|
|||||||
import { observeSpeaker$ } from "./observeSpeaker.ts";
|
import { observeSpeaker$ } from "./observeSpeaker.ts";
|
||||||
import { generateItems } from "../utils/observable.ts";
|
import { generateItems } from "../utils/observable.ts";
|
||||||
import { ScreenShare } from "./ScreenShare.ts";
|
import { ScreenShare } from "./ScreenShare.ts";
|
||||||
|
import { type TaggedParticipant } from "./CallViewModel/remoteMembers/MatrixLivekitMembers.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorting bins defining the order in which media tiles appear in the layout.
|
* Sorting bins defining the order in which media tiles appear in the layout.
|
||||||
@@ -68,40 +69,46 @@ enum SortingBin {
|
|||||||
* for inclusion in the call layout and tracks associated screen shares.
|
* for inclusion in the call layout and tracks associated screen shares.
|
||||||
*/
|
*/
|
||||||
export class UserMedia {
|
export class UserMedia {
|
||||||
public readonly vm: UserMediaViewModel = this.participant$.value?.isLocal
|
public readonly vm: UserMediaViewModel =
|
||||||
? new LocalUserMediaViewModel(
|
this.participant.type === "local"
|
||||||
this.scope,
|
? new LocalUserMediaViewModel(
|
||||||
this.id,
|
this.scope,
|
||||||
this.userId,
|
this.id,
|
||||||
this.participant$ as Behavior<LocalParticipant | null>,
|
this.userId,
|
||||||
this.encryptionSystem,
|
this.participant.value$,
|
||||||
this.livekitRoom$,
|
this.encryptionSystem,
|
||||||
this.focusUrl$,
|
this.livekitRoom$,
|
||||||
this.mediaDevices,
|
this.focusUrl$,
|
||||||
this.displayName$,
|
this.mediaDevices,
|
||||||
this.mxcAvatarUrl$,
|
this.displayName$,
|
||||||
this.scope.behavior(this.handRaised$),
|
this.mxcAvatarUrl$,
|
||||||
this.scope.behavior(this.reaction$),
|
this.scope.behavior(this.handRaised$),
|
||||||
)
|
this.scope.behavior(this.reaction$),
|
||||||
: new RemoteUserMediaViewModel(
|
)
|
||||||
this.scope,
|
: new RemoteUserMediaViewModel(
|
||||||
this.id,
|
this.scope,
|
||||||
this.userId,
|
this.id,
|
||||||
this.participant$ as Behavior<RemoteParticipant | null>,
|
this.userId,
|
||||||
this.encryptionSystem,
|
this.participant.value$,
|
||||||
this.livekitRoom$,
|
this.encryptionSystem,
|
||||||
this.focusUrl$,
|
this.livekitRoom$,
|
||||||
this.pretendToBeDisconnected$,
|
this.focusUrl$,
|
||||||
this.displayName$,
|
this.pretendToBeDisconnected$,
|
||||||
this.mxcAvatarUrl$,
|
this.displayName$,
|
||||||
this.scope.behavior(this.handRaised$),
|
this.mxcAvatarUrl$,
|
||||||
this.scope.behavior(this.reaction$),
|
this.scope.behavior(this.handRaised$),
|
||||||
);
|
this.scope.behavior(this.reaction$),
|
||||||
|
);
|
||||||
|
|
||||||
private readonly speaker$ = this.scope.behavior(
|
private readonly speaker$ = this.scope.behavior(
|
||||||
observeSpeaker$(this.vm.speaking$),
|
observeSpeaker$(this.vm.speaking$),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TypeScript needs this widening of the type to happen in a separate statement
|
||||||
|
private readonly participant$: Behavior<
|
||||||
|
LocalParticipant | RemoteParticipant | null
|
||||||
|
> = this.participant.value$;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All screen share media associated with this user media.
|
* All screen share media associated with this user media.
|
||||||
*/
|
*/
|
||||||
@@ -184,9 +191,7 @@ export class UserMedia {
|
|||||||
private readonly scope: ObservableScope,
|
private readonly scope: ObservableScope,
|
||||||
public readonly id: string,
|
public readonly id: string,
|
||||||
private readonly userId: string,
|
private readonly userId: string,
|
||||||
private readonly participant$: Behavior<
|
private readonly participant: TaggedParticipant,
|
||||||
LocalParticipant | RemoteParticipant | null
|
|
||||||
>,
|
|
||||||
private readonly encryptionSystem: EncryptionSystem,
|
private readonly encryptionSystem: EncryptionSystem,
|
||||||
private readonly livekitRoom$: Behavior<LivekitRoom | undefined>,
|
private readonly livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||||
private readonly focusUrl$: Behavior<string | undefined>,
|
private readonly focusUrl$: Behavior<string | undefined>,
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import { axe } from "vitest-axe";
|
|||||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
import { GridTile } from "./GridTile";
|
import { GridTile } from "./GridTile";
|
||||||
import { mockRtcMembership, createRemoteMedia } from "../utils/test";
|
import {
|
||||||
|
mockRtcMembership,
|
||||||
|
createRemoteMedia,
|
||||||
|
mockRemoteParticipant,
|
||||||
|
} from "../utils/test";
|
||||||
import { GridTileViewModel } from "../state/TileViewModel";
|
import { GridTileViewModel } from "../state/TileViewModel";
|
||||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||||
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
|
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||||
@@ -31,11 +35,11 @@ test("GridTile is accessible", async () => {
|
|||||||
rawDisplayName: "Alice",
|
rawDisplayName: "Alice",
|
||||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||||
},
|
},
|
||||||
{
|
mockRemoteParticipant({
|
||||||
setVolume() {},
|
setVolume() {},
|
||||||
getTrackPublication: () =>
|
getTrackPublication: () =>
|
||||||
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
|
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const fakeRtcSession = {
|
const fakeRtcSession = {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ interface UserMediaTileProps extends TileProps {
|
|||||||
vm: UserMediaViewModel;
|
vm: UserMediaViewModel;
|
||||||
mirror: boolean;
|
mirror: boolean;
|
||||||
locallyMuted: boolean;
|
locallyMuted: boolean;
|
||||||
|
waitingForMedia?: boolean;
|
||||||
primaryButton?: ReactNode;
|
primaryButton?: ReactNode;
|
||||||
menuStart?: ReactNode;
|
menuStart?: ReactNode;
|
||||||
menuEnd?: ReactNode;
|
menuEnd?: ReactNode;
|
||||||
@@ -79,6 +80,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
vm,
|
vm,
|
||||||
showSpeakingIndicators,
|
showSpeakingIndicators,
|
||||||
locallyMuted,
|
locallyMuted,
|
||||||
|
waitingForMedia,
|
||||||
primaryButton,
|
primaryButton,
|
||||||
menuStart,
|
menuStart,
|
||||||
menuEnd,
|
menuEnd,
|
||||||
@@ -148,7 +150,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
const tile = (
|
const tile = (
|
||||||
<MediaView
|
<MediaView
|
||||||
ref={ref}
|
ref={ref}
|
||||||
video={video ?? undefined}
|
video={video}
|
||||||
userId={vm.userId}
|
userId={vm.userId}
|
||||||
unencryptedWarning={unencryptedWarning}
|
unencryptedWarning={unencryptedWarning}
|
||||||
encryptionStatus={encryptionStatus}
|
encryptionStatus={encryptionStatus}
|
||||||
@@ -194,7 +196,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
raisedHandTime={handRaised ?? undefined}
|
raisedHandTime={handRaised ?? undefined}
|
||||||
currentReaction={reaction ?? undefined}
|
currentReaction={reaction ?? undefined}
|
||||||
raisedHandOnClick={raisedHandOnClick}
|
raisedHandOnClick={raisedHandOnClick}
|
||||||
localParticipant={vm.local}
|
waitingForMedia={waitingForMedia}
|
||||||
focusUrl={focusUrl}
|
focusUrl={focusUrl}
|
||||||
audioStreamStats={audioStreamStats}
|
audioStreamStats={audioStreamStats}
|
||||||
videoStreamStats={videoStreamStats}
|
videoStreamStats={videoStreamStats}
|
||||||
@@ -290,6 +292,7 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const waitingForMedia = useBehavior(vm.waitingForMedia$);
|
||||||
const locallyMuted = useBehavior(vm.locallyMuted$);
|
const locallyMuted = useBehavior(vm.locallyMuted$);
|
||||||
const localVolume = useBehavior(vm.localVolume$);
|
const localVolume = useBehavior(vm.localVolume$);
|
||||||
const onSelectMute = useCallback(
|
const onSelectMute = useCallback(
|
||||||
@@ -311,6 +314,7 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
|||||||
<UserMediaTile
|
<UserMediaTile
|
||||||
ref={ref}
|
ref={ref}
|
||||||
vm={vm}
|
vm={vm}
|
||||||
|
waitingForMedia={waitingForMedia}
|
||||||
locallyMuted={locallyMuted}
|
locallyMuted={locallyMuted}
|
||||||
mirror={false}
|
mirror={false}
|
||||||
menuStart={
|
menuStart={
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ describe("MediaView", () => {
|
|||||||
video: trackReference,
|
video: trackReference,
|
||||||
userId: "@alice:example.com",
|
userId: "@alice:example.com",
|
||||||
mxcAvatarUrl: undefined,
|
mxcAvatarUrl: undefined,
|
||||||
localParticipant: false,
|
|
||||||
focusable: true,
|
focusable: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,24 +65,13 @@ describe("MediaView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("with no participant", () => {
|
describe("with no video", () => {
|
||||||
it("shows avatar for local user", () => {
|
it("shows avatar", () => {
|
||||||
render(
|
render(<MediaView {...baseProps} video={undefined} />);
|
||||||
<MediaView {...baseProps} video={undefined} localParticipant={true} />,
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("img", { name: "@alice:example.com" }),
|
screen.getByRole("img", { name: "@alice:example.com" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(screen.queryAllByText("Waiting for media...").length).toBe(0);
|
expect(screen.queryByTestId("video")).toBe(null);
|
||||||
});
|
|
||||||
it("shows avatar and label for remote user", () => {
|
|
||||||
render(
|
|
||||||
<MediaView {...baseProps} video={undefined} localParticipant={false} />,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
screen.getByRole("img", { name: "@alice:example.com" }),
|
|
||||||
).toBeVisible();
|
|
||||||
expect(screen.getByText("Waiting for media...")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,6 +82,22 @@ describe("MediaView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("waitingForMedia", () => {
|
||||||
|
test("defaults to false", () => {
|
||||||
|
render(<MediaView {...baseProps} />);
|
||||||
|
expect(screen.queryAllByText("Waiting for media...").length).toBe(0);
|
||||||
|
});
|
||||||
|
test("shows and is accessible", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<MediaView {...baseProps} waitingForMedia={true} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
expect(await axe(container)).toHaveNoViolations();
|
||||||
|
expect(screen.getByText("Waiting for media...")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("unencryptedWarning", () => {
|
describe("unencryptedWarning", () => {
|
||||||
test("is shown and accessible", async () => {
|
test("is shown and accessible", async () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ interface Props extends ComponentProps<typeof animated.div> {
|
|||||||
raisedHandTime?: Date;
|
raisedHandTime?: Date;
|
||||||
currentReaction?: ReactionOption;
|
currentReaction?: ReactionOption;
|
||||||
raisedHandOnClick?: () => void;
|
raisedHandOnClick?: () => void;
|
||||||
localParticipant: boolean;
|
waitingForMedia?: boolean;
|
||||||
audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||||
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||||
// The focus url, mainly for debugging purposes
|
// The focus url, mainly for debugging purposes
|
||||||
@@ -71,7 +71,7 @@ export const MediaView: FC<Props> = ({
|
|||||||
raisedHandTime,
|
raisedHandTime,
|
||||||
currentReaction,
|
currentReaction,
|
||||||
raisedHandOnClick,
|
raisedHandOnClick,
|
||||||
localParticipant,
|
waitingForMedia,
|
||||||
audioStreamStats,
|
audioStreamStats,
|
||||||
videoStreamStats,
|
videoStreamStats,
|
||||||
focusUrl,
|
focusUrl,
|
||||||
@@ -129,7 +129,7 @@ export const MediaView: FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!video && !localParticipant && (
|
{waitingForMedia && (
|
||||||
<div className={styles.status}>
|
<div className={styles.status}>
|
||||||
{t("video_tile.waiting_for_media")}
|
{t("video_tile.waiting_for_media")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
mockRtcMembership,
|
mockRtcMembership,
|
||||||
createLocalMedia,
|
createLocalMedia,
|
||||||
createRemoteMedia,
|
createRemoteMedia,
|
||||||
|
mockRemoteParticipant,
|
||||||
} from "../utils/test";
|
} from "../utils/test";
|
||||||
import { SpotlightTileViewModel } from "../state/TileViewModel";
|
import { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||||
import { constant } from "../state/Behavior";
|
import { constant } from "../state/Behavior";
|
||||||
@@ -33,7 +34,7 @@ test("SpotlightTile is accessible", async () => {
|
|||||||
rawDisplayName: "Alice",
|
rawDisplayName: "Alice",
|
||||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||||
},
|
},
|
||||||
{},
|
mockRemoteParticipant({}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const vm2 = createLocalMedia(
|
const vm2 = createLocalMedia(
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
type MediaViewModel,
|
type MediaViewModel,
|
||||||
ScreenShareViewModel,
|
ScreenShareViewModel,
|
||||||
type UserMediaViewModel,
|
type UserMediaViewModel,
|
||||||
|
type RemoteUserMediaViewModel,
|
||||||
} from "../state/MediaViewModel";
|
} from "../state/MediaViewModel";
|
||||||
import { useInitial } from "../useInitial";
|
import { useInitial } from "../useInitial";
|
||||||
import { useMergedRefs } from "../useMergedRefs";
|
import { useMergedRefs } from "../useMergedRefs";
|
||||||
@@ -84,6 +85,21 @@ const SpotlightLocalUserMediaItem: FC<SpotlightLocalUserMediaItemProps> = ({
|
|||||||
|
|
||||||
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
|
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
|
||||||
|
|
||||||
|
interface SpotlightRemoteUserMediaItemProps
|
||||||
|
extends SpotlightUserMediaItemBaseProps {
|
||||||
|
vm: RemoteUserMediaViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpotlightRemoteUserMediaItem: FC<SpotlightRemoteUserMediaItemProps> = ({
|
||||||
|
vm,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const waitingForMedia = useBehavior(vm.waitingForMedia$);
|
||||||
|
return (
|
||||||
|
<MediaView waitingForMedia={waitingForMedia} mirror={false} {...props} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps {
|
interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps {
|
||||||
vm: UserMediaViewModel;
|
vm: UserMediaViewModel;
|
||||||
}
|
}
|
||||||
@@ -103,7 +119,7 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
|||||||
return vm instanceof LocalUserMediaViewModel ? (
|
return vm instanceof LocalUserMediaViewModel ? (
|
||||||
<SpotlightLocalUserMediaItem vm={vm} {...baseProps} />
|
<SpotlightLocalUserMediaItem vm={vm} {...baseProps} />
|
||||||
) : (
|
) : (
|
||||||
<MediaView mirror={false} {...baseProps} />
|
<SpotlightRemoteUserMediaItem vm={vm} {...baseProps} />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { Subject } from "rxjs";
|
import { type Observable, of, Subject, switchMap } from "rxjs";
|
||||||
|
|
||||||
import { withTestScheduler } from "./test";
|
import { withTestScheduler } from "./test";
|
||||||
import { generateItems, pauseWhen } from "./observable";
|
import { filterBehavior, generateItems, pauseWhen } from "./observable";
|
||||||
|
import { type Behavior } from "../state/Behavior";
|
||||||
|
|
||||||
test("pauseWhen", () => {
|
test("pauseWhen", () => {
|
||||||
withTestScheduler(({ behavior, expectObservable }) => {
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
@@ -72,3 +73,31 @@ test("generateItems", () => {
|
|||||||
expectObservable(scope4$).toBe(scope4Marbles);
|
expectObservable(scope4$).toBe(scope4Marbles);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("filterBehavior", () => {
|
||||||
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
|
// Filtering the input should segment it into 2 modes of non-null behavior.
|
||||||
|
const inputMarbles = " abcxabx";
|
||||||
|
const filteredMarbles = "a--xa-x";
|
||||||
|
|
||||||
|
const input$ = behavior(inputMarbles, {
|
||||||
|
a: "a",
|
||||||
|
b: "b",
|
||||||
|
c: "c",
|
||||||
|
x: null,
|
||||||
|
});
|
||||||
|
const filtered$: Observable<Behavior<string> | null> = input$.pipe(
|
||||||
|
filterBehavior((value) => typeof value === "string"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expectObservable(filtered$).toBe(filteredMarbles, {
|
||||||
|
a: expect.any(Object),
|
||||||
|
x: null,
|
||||||
|
});
|
||||||
|
expectObservable(
|
||||||
|
filtered$.pipe(
|
||||||
|
switchMap((value$) => (value$ === null ? of(null) : value$)),
|
||||||
|
),
|
||||||
|
).toBe(inputMarbles, { a: "a", b: "b", c: "c", x: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
withLatestFrom,
|
withLatestFrom,
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
type OperatorFunction,
|
type OperatorFunction,
|
||||||
|
distinctUntilChanged,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
import { type Behavior } from "../state/Behavior";
|
import { type Behavior } from "../state/Behavior";
|
||||||
@@ -185,6 +186,28 @@ export function generateItemsWithEpoch<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Segments a behavior into periods during which its value matches the filter
|
||||||
|
* (outputting a behavior with a narrowed type) and periods during which it does
|
||||||
|
* not match (outputting null).
|
||||||
|
*/
|
||||||
|
export function filterBehavior<T, S extends T>(
|
||||||
|
predicate: (value: T) => value is S,
|
||||||
|
): OperatorFunction<T, Behavior<S> | null> {
|
||||||
|
return (input$) =>
|
||||||
|
input$.pipe(
|
||||||
|
scan<T, BehaviorSubject<S> | null>((acc$, input) => {
|
||||||
|
if (predicate(input)) {
|
||||||
|
const output$ = acc$ ?? new BehaviorSubject(input);
|
||||||
|
output$.next(input);
|
||||||
|
return output$;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, null),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function generateItemsInternal<
|
function generateItemsInternal<
|
||||||
Input,
|
Input,
|
||||||
Keys extends [unknown, ...unknown[]],
|
Keys extends [unknown, ...unknown[]],
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
import { aliceRtcMember, localRtcMember } from "./test-fixtures";
|
import { aliceRtcMember, localRtcMember } from "./test-fixtures";
|
||||||
import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
|
import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
|
||||||
import { constant } from "../state/Behavior";
|
import { constant } from "../state/Behavior";
|
||||||
|
import { MatrixRTCMode } from "../settings/settings";
|
||||||
|
|
||||||
mockConfig({ livekit: { livekit_service_url: "https://example.com" } });
|
mockConfig({ livekit: { livekit_service_url: "https://example.com" } });
|
||||||
|
|
||||||
@@ -162,6 +163,7 @@ export function getBasicCallViewModelEnvironment(
|
|||||||
setE2EEEnabled: async () => Promise.resolve(),
|
setE2EEEnabled: async () => Promise.resolve(),
|
||||||
}),
|
}),
|
||||||
connectionState$: constant(ConnectionState.Connected),
|
connectionState$: constant(ConnectionState.Connected),
|
||||||
|
matrixRTCMode$: constant(MatrixRTCMode.Legacy),
|
||||||
...callViewModelOptions,
|
...callViewModelOptions,
|
||||||
},
|
},
|
||||||
handRaisedSubject$,
|
handRaisedSubject$,
|
||||||
|
|||||||
@@ -321,12 +321,12 @@ export function mockLocalParticipant(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createLocalMedia(
|
export function createLocalMedia(
|
||||||
localRtcMember: CallMembership,
|
rtcMember: CallMembership,
|
||||||
roomMember: Partial<RoomMember>,
|
roomMember: Partial<RoomMember>,
|
||||||
localParticipant: LocalParticipant,
|
localParticipant: LocalParticipant,
|
||||||
mediaDevices: MediaDevices,
|
mediaDevices: MediaDevices,
|
||||||
): LocalUserMediaViewModel {
|
): LocalUserMediaViewModel {
|
||||||
const member = mockMatrixRoomMember(localRtcMember, roomMember);
|
const member = mockMatrixRoomMember(rtcMember, roomMember);
|
||||||
return new LocalUserMediaViewModel(
|
return new LocalUserMediaViewModel(
|
||||||
testScope(),
|
testScope(),
|
||||||
"local",
|
"local",
|
||||||
@@ -361,23 +361,26 @@ export function mockRemoteParticipant(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createRemoteMedia(
|
export function createRemoteMedia(
|
||||||
localRtcMember: CallMembership,
|
rtcMember: CallMembership,
|
||||||
roomMember: Partial<RoomMember>,
|
roomMember: Partial<RoomMember>,
|
||||||
participant: Partial<RemoteParticipant>,
|
participant: RemoteParticipant | null,
|
||||||
|
livekitRoom: LivekitRoom | undefined = mockLivekitRoom(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
remoteParticipants$: of(participant ? [participant] : []),
|
||||||
|
},
|
||||||
|
),
|
||||||
): RemoteUserMediaViewModel {
|
): RemoteUserMediaViewModel {
|
||||||
const member = mockMatrixRoomMember(localRtcMember, roomMember);
|
const member = mockMatrixRoomMember(rtcMember, roomMember);
|
||||||
const remoteParticipant = mockRemoteParticipant(participant);
|
|
||||||
return new RemoteUserMediaViewModel(
|
return new RemoteUserMediaViewModel(
|
||||||
testScope(),
|
testScope(),
|
||||||
"remote",
|
"remote",
|
||||||
member.userId,
|
member.userId,
|
||||||
of(remoteParticipant),
|
constant(participant),
|
||||||
{
|
{
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
constant(
|
constant(livekitRoom),
|
||||||
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
|
|
||||||
),
|
|
||||||
constant("https://rtc-example.org"),
|
constant("https://rtc-example.org"),
|
||||||
constant(false),
|
constant(false),
|
||||||
constant(member.rawDisplayName ?? "nodisplayname"),
|
constant(member.rawDisplayName ?? "nodisplayname"),
|
||||||
|
|||||||
Reference in New Issue
Block a user