Replace generateKeyed$ with a redesigned generateItems operator
And use it to clean up a number of code smells, fix some reactivity bugs, and avoid some resource leaks.
This commit is contained in:
@@ -52,7 +52,7 @@ import {
|
|||||||
ScreenShareViewModel,
|
ScreenShareViewModel,
|
||||||
type UserMediaViewModel,
|
type UserMediaViewModel,
|
||||||
} from "../MediaViewModel";
|
} from "../MediaViewModel";
|
||||||
import { accumulate, generateKeyed$, pauseWhen } from "../../utils/observable";
|
import { accumulate, generateItems, pauseWhen } from "../../utils/observable";
|
||||||
import {
|
import {
|
||||||
duplicateTiles,
|
duplicateTiles,
|
||||||
MatrixRTCMode,
|
MatrixRTCMode,
|
||||||
@@ -75,7 +75,7 @@ import {
|
|||||||
} from "../../reactions";
|
} from "../../reactions";
|
||||||
import { shallowEquals } from "../../utils/array";
|
import { shallowEquals } from "../../utils/array";
|
||||||
import { type MediaDevices } from "../MediaDevices";
|
import { type MediaDevices } from "../MediaDevices";
|
||||||
import { type Behavior, constant } from "../Behavior";
|
import { type Behavior } from "../Behavior";
|
||||||
import { E2eeType } from "../../e2ee/e2eeType";
|
import { E2eeType } from "../../e2ee/e2eeType";
|
||||||
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
|
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
|
||||||
import { type MuteStates } from "../MuteStates";
|
import { type MuteStates } from "../MuteStates";
|
||||||
@@ -370,103 +370,101 @@ export class CallViewModel {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of MediaItems that we want to have tiles for.
|
* List of user media (camera feeds) that we want tiles for.
|
||||||
*/
|
*/
|
||||||
// TODO KEEP THIS!! and adapt it to what our membershipManger returns
|
|
||||||
// TODO this also needs the local participant to be added.
|
// TODO this also needs the local participant to be added.
|
||||||
private readonly mediaItems$ = this.scope.behavior<MediaItem[]>(
|
private readonly userMedia$ = this.scope.behavior<UserMedia[]>(
|
||||||
generateKeyed$<
|
combineLatest([this.matrixLivekitMembers$, duplicateTiles.value$]).pipe(
|
||||||
[typeof this.matrixLivekitMembers$.value, number],
|
|
||||||
MediaItem,
|
|
||||||
MediaItem[]
|
|
||||||
>(
|
|
||||||
// Generate a collection of MediaItems from the list of expected (whether
|
// Generate a collection of MediaItems from the list of expected (whether
|
||||||
// present or missing) LiveKit participants.
|
// present or missing) LiveKit participants.
|
||||||
combineLatest([this.matrixLivekitMembers$, duplicateTiles.value$]),
|
generateItems(
|
||||||
([{ value: matrixLivekitMembers }, duplicateTiles], createOrGet) => {
|
function* ([{ value: matrixLivekitMembers }, duplicateTiles]) {
|
||||||
const items: MediaItem[] = [];
|
for (const {
|
||||||
|
participantId,
|
||||||
for (const {
|
userId,
|
||||||
connection,
|
participant$,
|
||||||
participant,
|
connection$,
|
||||||
member,
|
displayName$,
|
||||||
displayName,
|
mxcAvatarUrl$,
|
||||||
|
} of matrixLivekitMembers)
|
||||||
|
for (let dup = 0; dup < 1 + duplicateTiles; dup++)
|
||||||
|
yield {
|
||||||
|
keys: [
|
||||||
|
dup,
|
||||||
|
participantId,
|
||||||
|
userId,
|
||||||
|
participant$,
|
||||||
|
connection$,
|
||||||
|
displayName$,
|
||||||
|
mxcAvatarUrl$,
|
||||||
|
],
|
||||||
|
data: undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
(
|
||||||
|
scope,
|
||||||
|
_data$,
|
||||||
|
dup,
|
||||||
participantId,
|
participantId,
|
||||||
} of matrixLivekitMembers) {
|
userId,
|
||||||
if (connection === undefined) {
|
participant$,
|
||||||
logger.warn("connection is not yet initialised.");
|
connection$,
|
||||||
continue;
|
displayName$,
|
||||||
}
|
mxcAvatarUrl$,
|
||||||
for (let i = 0; i < 1 + duplicateTiles; i++) {
|
) => {
|
||||||
const mediaId = `${participantId}:${i}`;
|
const livekitRoom$ = scope.behavior(
|
||||||
const lkRoom = connection?.livekitRoom;
|
connection$.pipe(map((c) => c?.livekitRoom)),
|
||||||
const url = connection?.transport.livekit_service_url;
|
);
|
||||||
|
const focusUrl$ = scope.behavior(
|
||||||
|
connection$.pipe(map((c) => c?.transport.livekit_service_url)),
|
||||||
|
);
|
||||||
|
|
||||||
const item = createOrGet(
|
return new UserMedia(
|
||||||
mediaId,
|
scope,
|
||||||
(scope) =>
|
`${participantId}:${dup}`,
|
||||||
// We create UserMedia with or without a participant.
|
userId,
|
||||||
// This will be the initial value of a BehaviourSubject.
|
participant$,
|
||||||
// Once a participant appears we will update the BehaviourSubject. (see below)
|
this.options.encryptionSystem,
|
||||||
new UserMedia(
|
livekitRoom$,
|
||||||
scope,
|
focusUrl$,
|
||||||
mediaId,
|
this.mediaDevices,
|
||||||
member,
|
this.pretendToBeDisconnected$,
|
||||||
participant,
|
displayName$,
|
||||||
this.options.encryptionSystem,
|
mxcAvatarUrl$,
|
||||||
lkRoom,
|
this.handsRaised$.pipe(map((v) => v[participantId]?.time ?? null)),
|
||||||
url,
|
this.reactions$.pipe(map((v) => v[participantId] ?? undefined)),
|
||||||
this.mediaDevices,
|
);
|
||||||
this.pretendToBeDisconnected$,
|
},
|
||||||
constant(displayName ?? "[👻]"),
|
),
|
||||||
this.handsRaised$.pipe(
|
|
||||||
map((v) => v[participantId]?.time ?? null),
|
|
||||||
),
|
|
||||||
this.reactions$.pipe(
|
|
||||||
map((v) => v[participantId] ?? undefined),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
items.push(item);
|
|
||||||
(item as UserMedia).updateParticipant(participant);
|
|
||||||
|
|
||||||
if (participant?.isScreenShareEnabled) {
|
|
||||||
const screenShareId = `${mediaId}:screen-share`;
|
|
||||||
items.push(
|
|
||||||
createOrGet(
|
|
||||||
screenShareId,
|
|
||||||
(scope) =>
|
|
||||||
new ScreenShare(
|
|
||||||
scope,
|
|
||||||
screenShareId,
|
|
||||||
member,
|
|
||||||
participant,
|
|
||||||
this.options.encryptionSystem,
|
|
||||||
lkRoom,
|
|
||||||
url,
|
|
||||||
this.pretendToBeDisconnected$,
|
|
||||||
constant(displayName ?? "[👻]"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of MediaItems that we want to display, that are of type UserMedia
|
* List of all media items (user media and screen share media) that we want
|
||||||
|
* tiles for.
|
||||||
*/
|
*/
|
||||||
private readonly userMedia$ = this.scope.behavior<UserMedia[]>(
|
private readonly mediaItems$ = this.scope.behavior<MediaItem[]>(
|
||||||
this.mediaItems$.pipe(
|
this.userMedia$.pipe(
|
||||||
map((mediaItems) =>
|
switchMap((userMedia) =>
|
||||||
mediaItems.filter((m): m is UserMedia => m instanceof UserMedia),
|
userMedia.length === 0
|
||||||
|
? of([])
|
||||||
|
: combineLatest(
|
||||||
|
userMedia.map((m) => m.screenShares$),
|
||||||
|
(...screenShares) => [...userMedia, ...screenShares.flat(1)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of MediaItems that we want to display, that are of type ScreenShare
|
||||||
|
*/
|
||||||
|
private readonly screenShares$ = this.scope.behavior<ScreenShare[]>(
|
||||||
|
this.mediaItems$.pipe(
|
||||||
|
map((mediaItems) =>
|
||||||
|
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
[],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
public readonly joinSoundEffect$ = this.userMedia$.pipe(
|
public readonly joinSoundEffect$ = this.userMedia$.pipe(
|
||||||
@@ -544,17 +542,6 @@ export class CallViewModel {
|
|||||||
tap((reason) => this.leaveHoisted$.next(reason)),
|
tap((reason) => this.leaveHoisted$.next(reason)),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* List of MediaItems that we want to display, that are of type ScreenShare
|
|
||||||
*/
|
|
||||||
private readonly screenShares$ = this.scope.behavior<ScreenShare[]>(
|
|
||||||
this.mediaItems$.pipe(
|
|
||||||
map((mediaItems) =>
|
|
||||||
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
private readonly spotlightSpeaker$ =
|
private readonly spotlightSpeaker$ =
|
||||||
this.scope.behavior<UserMediaViewModel | null>(
|
this.scope.behavior<UserMediaViewModel | null>(
|
||||||
this.userMedia$.pipe(
|
this.userMedia$.pipe(
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type LocalTrack, type E2EEOptions } from "livekit-client";
|
import {
|
||||||
|
type LocalTrack,
|
||||||
|
type E2EEOptions,
|
||||||
|
type Participant,
|
||||||
|
ParticipantEvent,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { observeParticipantEvents } from "@livekit/components-core";
|
||||||
import {
|
import {
|
||||||
type LivekitTransport,
|
type LivekitTransport,
|
||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
@@ -26,11 +32,9 @@ import {
|
|||||||
switchMap,
|
switchMap,
|
||||||
take,
|
take,
|
||||||
takeWhile,
|
takeWhile,
|
||||||
tap,
|
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
import { sharingScreen$ as observeSharingScreen$ } from "../../UserMedia.ts";
|
|
||||||
import { type Behavior } from "../../Behavior";
|
import { type Behavior } from "../../Behavior";
|
||||||
import { type IConnectionManager } from "../remoteMembers/ConnectionManager";
|
import { type IConnectionManager } from "../remoteMembers/ConnectionManager";
|
||||||
import { ObservableScope } from "../../ObservableScope";
|
import { ObservableScope } from "../../ObservableScope";
|
||||||
@@ -521,3 +525,13 @@ export const createLocalMembership$ = ({
|
|||||||
toggleScreenSharing,
|
toggleScreenSharing,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function observeSharingScreen$(p: Participant): Observable<boolean> {
|
||||||
|
return observeParticipantEvents(
|
||||||
|
p,
|
||||||
|
ParticipantEvent.TrackPublished,
|
||||||
|
ParticipantEvent.TrackUnpublished,
|
||||||
|
ParticipantEvent.LocalTrackPublished,
|
||||||
|
ParticipantEvent.LocalTrackUnpublished,
|
||||||
|
).pipe(map((p) => p.isScreenShareEnabled));
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { type LocalParticipant, 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";
|
||||||
import { Epoch, type ObservableScope } from "../../ObservableScope.ts";
|
import { Epoch, type ObservableScope } from "../../ObservableScope.ts";
|
||||||
import { generateKeyed$ } from "../../../utils/observable.ts";
|
import { generateItemsWithEpoch } from "../../../utils/observable.ts";
|
||||||
import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
|
import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
|
||||||
import { type ConnectionFactory } from "./ConnectionFactory.ts";
|
import { type ConnectionFactory } from "./ConnectionFactory.ts";
|
||||||
|
|
||||||
@@ -144,34 +144,32 @@ export function createConnectionManager$({
|
|||||||
* Connections for each transport in use by one or more session members.
|
* Connections for each transport in use by one or more session members.
|
||||||
*/
|
*/
|
||||||
const connections$ = scope.behavior(
|
const connections$ = scope.behavior(
|
||||||
generateKeyed$<Epoch<LivekitTransport[]>, Connection, Epoch<Connection[]>>(
|
transports$.pipe(
|
||||||
transports$,
|
generateItemsWithEpoch(
|
||||||
(transports, createOrGet) => {
|
function* (transports) {
|
||||||
const createConnection =
|
for (const transport of transports)
|
||||||
(
|
yield {
|
||||||
transport: LivekitTransport,
|
keys: [transport.livekit_service_url, transport.livekit_alias],
|
||||||
): ((scope: ObservableScope) => Connection) =>
|
data: undefined,
|
||||||
(scope) => {
|
};
|
||||||
const connection = connectionFactory.createConnection(
|
},
|
||||||
transport,
|
(scope, _data$, serviceUrl, alias) => {
|
||||||
scope,
|
const connection = connectionFactory.createConnection(
|
||||||
logger,
|
{
|
||||||
);
|
type: "livekit",
|
||||||
// Start the connection immediately
|
livekit_service_url: serviceUrl,
|
||||||
// Use connection state to track connection progress
|
livekit_alias: alias,
|
||||||
void connection.start();
|
},
|
||||||
// TODO subscribe to connection state to retry or log issues?
|
scope,
|
||||||
return connection;
|
logger,
|
||||||
};
|
);
|
||||||
|
// Start the connection immediately
|
||||||
return transports.mapInner((transports) => {
|
// Use connection state to track connection progress
|
||||||
return transports.map((transport) => {
|
void connection.start();
|
||||||
const key =
|
// TODO subscribe to connection state to retry or log issues?
|
||||||
transport.livekit_service_url + "|" + transport.livekit_alias;
|
return connection;
|
||||||
return createOrGet(key, createConnection(transport));
|
},
|
||||||
});
|
),
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -16,32 +16,31 @@ import {
|
|||||||
import { combineLatest, filter, map } from "rxjs";
|
import { combineLatest, filter, map } from "rxjs";
|
||||||
// eslint-disable-next-line rxjs/no-internal
|
// eslint-disable-next-line rxjs/no-internal
|
||||||
import { type NodeStyleEventEmitter } from "rxjs/internal/observable/fromEvent";
|
import { type NodeStyleEventEmitter } from "rxjs/internal/observable/fromEvent";
|
||||||
import { type Room as MatrixRoom, type RoomMember } from "matrix-js-sdk";
|
import { type Room as MatrixRoom } from "matrix-js-sdk";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
import { type Behavior } from "../../Behavior";
|
import { type Behavior } from "../../Behavior";
|
||||||
import { type IConnectionManager } from "./ConnectionManager";
|
import { type IConnectionManager } from "./ConnectionManager";
|
||||||
import { Epoch, mapEpoch, type ObservableScope } from "../../ObservableScope";
|
import { Epoch, mapEpoch, type ObservableScope } from "../../ObservableScope";
|
||||||
import { getRoomMemberFromRtcMember, memberDisplaynames$ } from "./displayname";
|
import { memberDisplaynames$ } from "./displayname";
|
||||||
import { type Connection } from "./Connection";
|
import { type Connection } from "./Connection";
|
||||||
|
import { generateItemsWithEpoch } from "../../../utils/observable";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represent a matrix call member and his associated livekit participation.
|
* Represents a Matrix call member and their associated LiveKit participation.
|
||||||
* `livekitParticipant` can be undefined if the member is not yet connected to the livekit room
|
* `livekitParticipant` can be undefined if the member is not yet connected to the livekit room
|
||||||
* or if it has no livekit transport at all.
|
* or if it has no livekit transport at all.
|
||||||
*/
|
*/
|
||||||
export interface MatrixLivekitMember {
|
export interface MatrixLivekitMember {
|
||||||
membership: CallMembership;
|
|
||||||
displayName?: string;
|
|
||||||
participant?: LocalLivekitParticipant | RemoteLivekitParticipant;
|
|
||||||
connection?: Connection;
|
|
||||||
/**
|
|
||||||
* TODO Try to remove this! Its waaay to much information.
|
|
||||||
* Just get the member's avatar
|
|
||||||
* @deprecated
|
|
||||||
*/
|
|
||||||
member: RoomMember;
|
|
||||||
mxcAvatarUrl?: string;
|
|
||||||
participantId: string;
|
participantId: string;
|
||||||
|
userId: string;
|
||||||
|
membership$: Behavior<CallMembership>;
|
||||||
|
participant$: Behavior<
|
||||||
|
LocalLivekitParticipant | RemoteLivekitParticipant | null
|
||||||
|
>;
|
||||||
|
connection$: Behavior<Connection | undefined>;
|
||||||
|
displayName$: Behavior<string>;
|
||||||
|
mxcAvatarUrl$: Behavior<string | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -100,44 +99,54 @@ export function createMatrixLivekitMembers$({
|
|||||||
{ value: membershipsWithTransports, epoch },
|
{ value: membershipsWithTransports, epoch },
|
||||||
{ value: managerData },
|
{ value: managerData },
|
||||||
{ value: displaynames },
|
{ value: displaynames },
|
||||||
]) => {
|
]) =>
|
||||||
const items: MatrixLivekitMember[] = membershipsWithTransports.map(
|
new Epoch(
|
||||||
({ membership, transport }) => {
|
[membershipsWithTransports, managerData, displaynames] as const,
|
||||||
// TODO! cannot use membership.membershipID yet, Currently its hardcoded by the jwt service to
|
epoch,
|
||||||
const participantId = /*membership.membershipID*/ `${membership.userId}:${membership.deviceId}`;
|
),
|
||||||
|
),
|
||||||
|
generateItemsWithEpoch(
|
||||||
|
function* ([membershipsWithTransports, managerData, displaynames]) {
|
||||||
|
for (const { membership, transport } of membershipsWithTransports) {
|
||||||
|
// TODO! cannot use membership.membershipID yet, Currently its hardcoded by the jwt service to
|
||||||
|
const participantId = /*membership.membershipID*/ `${membership.userId}:${membership.deviceId}`;
|
||||||
|
|
||||||
const participants = transport
|
const participants = transport
|
||||||
? managerData.getParticipantForTransport(transport)
|
? managerData.getParticipantForTransport(transport)
|
||||||
: [];
|
: [];
|
||||||
const participant = participants.find(
|
const participant =
|
||||||
(p) => p.identity == participantId,
|
participants.find((p) => p.identity == participantId) ?? null;
|
||||||
);
|
// This makes sense to add to the js-sdk callMembership (we only need the avatar so probably the call memberhsip just should aquire the avatar)
|
||||||
const member = getRoomMemberFromRtcMember(
|
const member = matrixRoom.getMember(membership.userId);
|
||||||
|
const connection = transport
|
||||||
|
? managerData.getConnectionForTransport(transport)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let displayName = displaynames.get(membership.userId);
|
||||||
|
if (displayName === undefined) {
|
||||||
|
logger.warn(`No display name for user ${membership.userId}`);
|
||||||
|
displayName = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
keys: [participantId, membership.userId],
|
||||||
|
data: {
|
||||||
membership,
|
membership,
|
||||||
matrixRoom,
|
|
||||||
)?.member;
|
|
||||||
const connection = transport
|
|
||||||
? managerData.getConnectionForTransport(transport)
|
|
||||||
: undefined;
|
|
||||||
const displayName = displaynames.get(participantId);
|
|
||||||
return {
|
|
||||||
participant,
|
participant,
|
||||||
membership,
|
|
||||||
connection,
|
connection,
|
||||||
// This makes sense to add to the js-sdk callMembership (we only need the avatar so probably the call memberhsip just should aquire the avatar)
|
|
||||||
// TODO Ugh this is hidign that it might be undefined!! best we remove the member entirely.
|
|
||||||
member: member as RoomMember,
|
|
||||||
displayName,
|
displayName,
|
||||||
mxcAvatarUrl: member?.getMxcAvatarUrl(),
|
mxcAvatarUrl: member?.getMxcAvatarUrl(),
|
||||||
participantId,
|
},
|
||||||
};
|
};
|
||||||
},
|
}
|
||||||
);
|
|
||||||
return new Epoch(items, epoch);
|
|
||||||
},
|
},
|
||||||
|
(scope, data$, participantId, userId) => ({
|
||||||
|
participantId,
|
||||||
|
userId,
|
||||||
|
...scope.splitBehavior(data$),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// new Epoch([]),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ test.skip("should always have our own user", () => {
|
|||||||
|
|
||||||
expectObservable(dn$.pipe(map((e) => e.value))).toBe("a", {
|
expectObservable(dn$.pipe(map((e) => e.value))).toBe("a", {
|
||||||
a: new Map<string, string>([
|
a: new Map<string, string>([
|
||||||
["@local:example.com:DEVICE000", "@local:example.com"],
|
["@local:example.com", "@local:example.com"],
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -130,9 +130,9 @@ test("should get displayName for users", () => {
|
|||||||
|
|
||||||
expectObservable(dn$.pipe(map((e) => e.value))).toBe("a", {
|
expectObservable(dn$.pipe(map((e) => e.value))).toBe("a", {
|
||||||
a: new Map<string, string>([
|
a: new Map<string, string>([
|
||||||
// ["@local:example.com:DEVICE000", "it's a me"],
|
// ["@local:example.com", "it's a me"],
|
||||||
["@alice:example.com:DEVICE1", "Alice"],
|
["@alice:example.com", "Alice"],
|
||||||
["@bob:example.com:DEVICE1", "Bob"],
|
["@bob:example.com", "Bob"],
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -152,8 +152,8 @@ test("should use userId if no display name", () => {
|
|||||||
|
|
||||||
expectObservable(dn$.pipe(map((e) => e.value))).toBe("a", {
|
expectObservable(dn$.pipe(map((e) => e.value))).toBe("a", {
|
||||||
a: new Map<string, string>([
|
a: new Map<string, string>([
|
||||||
// ["@local:example.com:DEVICE000", "it's a me"],
|
// ["@local:example.com", "it's a me"],
|
||||||
["@no-name:foo.bar:D000", "@no-name:foo.bar"],
|
["@no-name:foo.bar", "@no-name:foo.bar"],
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -179,12 +179,12 @@ test("should disambiguate users with same display name", () => {
|
|||||||
|
|
||||||
expectObservable(dn$.pipe(map((e) => e.value))).toBe("a", {
|
expectObservable(dn$.pipe(map((e) => e.value))).toBe("a", {
|
||||||
a: new Map<string, string>([
|
a: new Map<string, string>([
|
||||||
// ["@local:example.com:DEVICE000", "it's a me"],
|
// ["@local:example.com", "it's a me"],
|
||||||
["@bob:example.com:DEVICE1", "Bob (@bob:example.com)"],
|
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||||
["@bob:example.com:DEVICE2", "Bob (@bob:example.com)"],
|
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||||
["@bob:foo.bar:BOB000", "Bob (@bob:foo.bar)"],
|
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
|
||||||
["@carl:example.com:C000", "Carl (@carl:example.com)"],
|
["@carl:example.com", "Carl (@carl:example.com)"],
|
||||||
["@evil:example.com:E000", "Carl (@evil:example.com)"],
|
["@evil:example.com", "Carl (@evil:example.com)"],
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -208,13 +208,13 @@ test("should disambiguate when needed", () => {
|
|||||||
|
|
||||||
expectObservable(dn$.pipe(map((e) => e.value))).toBe("ab", {
|
expectObservable(dn$.pipe(map((e) => e.value))).toBe("ab", {
|
||||||
a: new Map<string, string>([
|
a: new Map<string, string>([
|
||||||
// ["@local:example.com:DEVICE000", "it's a me"],
|
// ["@local:example.com", "it's a me"],
|
||||||
["@bob:example.com:DEVICE1", "Bob"],
|
["@bob:example.com", "Bob"],
|
||||||
]),
|
]),
|
||||||
b: new Map<string, string>([
|
b: new Map<string, string>([
|
||||||
// ["@local:example.com:DEVICE000", "it's a me"],
|
// ["@local:example.com", "it's a me"],
|
||||||
["@bob:example.com:DEVICE1", "Bob (@bob:example.com)"],
|
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||||
["@bob:foo.bar:BOB000", "Bob (@bob:foo.bar)"],
|
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -238,13 +238,13 @@ test.skip("should keep disambiguated name when other leave", () => {
|
|||||||
|
|
||||||
expectObservable(dn$.pipe(map((e) => e.value))).toBe("ab", {
|
expectObservable(dn$.pipe(map((e) => e.value))).toBe("ab", {
|
||||||
a: new Map<string, string>([
|
a: new Map<string, string>([
|
||||||
// ["@local:example.com:DEVICE000", "it's a me"],
|
// ["@local:example.com", "it's a me"],
|
||||||
["@bob:example.com:DEVICE1", "Bob (@bob:example.com)"],
|
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||||
["@bob:foo.bar:BOB000", "Bob (@bob:foo.bar)"],
|
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
|
||||||
]),
|
]),
|
||||||
b: new Map<string, string>([
|
b: new Map<string, string>([
|
||||||
// ["@local:example.com:DEVICE000", "it's a me"],
|
// ["@local:example.com", "it's a me"],
|
||||||
["@bob:example.com:DEVICE1", "Bob (@bob:example.com)"],
|
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -273,14 +273,14 @@ test("should disambiguate on name change", () => {
|
|||||||
|
|
||||||
expectObservable(dn$.pipe(map((e) => e.value))).toBe("ab", {
|
expectObservable(dn$.pipe(map((e) => e.value))).toBe("ab", {
|
||||||
a: new Map<string, string>([
|
a: new Map<string, string>([
|
||||||
// ["@local:example.com:DEVICE000", "it's a me"],
|
// ["@local:example.com", "it's a me"],
|
||||||
["@bob:example.com:B000", "Bob"],
|
["@bob:example.com", "Bob"],
|
||||||
["@carl:example.com:C000", "Carl"],
|
["@carl:example.com", "Carl"],
|
||||||
]),
|
]),
|
||||||
b: new Map<string, string>([
|
b: new Map<string, string>([
|
||||||
// ["@local:example.com:DEVICE000", "it's a me"],
|
// ["@local:example.com", "it's a me"],
|
||||||
["@bob:example.com:B000", "Bob (@bob:example.com)"],
|
["@bob:example.com", "Bob (@bob:example.com)"],
|
||||||
["@carl:example.com:C000", "Bob (@carl:example.com)"],
|
["@carl:example.com", "Bob (@carl:example.com)"],
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function createRoomMembers$(
|
|||||||
* any displayname that clashes with another member. Only members
|
* any displayname that clashes with another member. Only members
|
||||||
* joined to the call are considered here.
|
* joined to the call are considered here.
|
||||||
*
|
*
|
||||||
* @returns Map<member.id, displayname> uses the rtc member idenitfier as the key.
|
* @returns Map<userId, displayname> uses the Matrix user ID as the key.
|
||||||
*/
|
*/
|
||||||
// don't do this work more times than we need to. This is achieved by converting to a behavior:
|
// don't do this work more times than we need to. This is achieved by converting to a behavior:
|
||||||
export const memberDisplaynames$ = (
|
export const memberDisplaynames$ = (
|
||||||
@@ -66,19 +66,14 @@ export const memberDisplaynames$ = (
|
|||||||
|
|
||||||
// We only consider RTC members for disambiguation as they are the only visible members.
|
// We only consider RTC members for disambiguation as they are the only visible members.
|
||||||
for (const rtcMember of memberships) {
|
for (const rtcMember of memberships) {
|
||||||
// TODO a hard-coded participant ID ? should use rtcMember.membershipID instead?
|
const member = room.getMember(rtcMember.userId);
|
||||||
const matrixIdentifier = `${rtcMember.userId}:${rtcMember.deviceId}`;
|
if (member === null) {
|
||||||
const { member } = getRoomMemberFromRtcMember(rtcMember, room);
|
logger.error(`Could not find member for user ${rtcMember.userId}`);
|
||||||
if (!member) {
|
|
||||||
logger.error(
|
|
||||||
"Could not find member for participant id:",
|
|
||||||
matrixIdentifier,
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const disambiguate = shouldDisambiguate(member, memberships, room);
|
const disambiguate = shouldDisambiguate(member, memberships, room);
|
||||||
displaynameMap.set(
|
displaynameMap.set(
|
||||||
matrixIdentifier,
|
rtcMember.userId,
|
||||||
calculateDisplayName(member, disambiguate),
|
calculateDisplayName(member, disambiguate),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -87,13 +82,3 @@ export const memberDisplaynames$ = (
|
|||||||
),
|
),
|
||||||
new Epoch(new Map<string, string>()),
|
new Epoch(new Map<string, string>()),
|
||||||
);
|
);
|
||||||
|
|
||||||
export function getRoomMemberFromRtcMember(
|
|
||||||
rtcMember: CallMembership,
|
|
||||||
room: Pick<MatrixRoom, "getMember">,
|
|
||||||
): { id: string; member: RoomMember | undefined } {
|
|
||||||
return {
|
|
||||||
id: rtcMember.userId + ":" + rtcMember.deviceId,
|
|
||||||
member: room.getMember(rtcMember.userId) ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
RoomEvent as LivekitRoomEvent,
|
RoomEvent as LivekitRoomEvent,
|
||||||
RemoteTrack,
|
RemoteTrack,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { type RoomMember } from "matrix-js-sdk";
|
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
@@ -44,6 +43,7 @@ import {
|
|||||||
startWith,
|
startWith,
|
||||||
switchMap,
|
switchMap,
|
||||||
throttleTime,
|
throttleTime,
|
||||||
|
distinctUntilChanged,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
import { alwaysShowSelf } from "../settings/settings";
|
import { alwaysShowSelf } from "../settings/settings";
|
||||||
@@ -180,29 +180,35 @@ function observeRemoteTrackReceivingOkay$(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function encryptionErrorObservable$(
|
function encryptionErrorObservable$(
|
||||||
room: LivekitRoom,
|
room$: Behavior<LivekitRoom | undefined>,
|
||||||
participant: Participant,
|
participant: Participant,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
criteria: string,
|
criteria: string,
|
||||||
): Observable<boolean> {
|
): Observable<boolean> {
|
||||||
return roomEventSelector(room, LivekitRoomEvent.EncryptionError).pipe(
|
return room$.pipe(
|
||||||
map((e) => {
|
switchMap((room) => {
|
||||||
const [err] = e;
|
if (room === undefined) return of(false);
|
||||||
if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
return roomEventSelector(room, LivekitRoomEvent.EncryptionError).pipe(
|
||||||
return (
|
map((e) => {
|
||||||
// Ideally we would pull the participant identity from the field on the error.
|
const [err] = e;
|
||||||
// However, it gets lost in the serialization process between workers.
|
if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||||
// So, instead we do a string match
|
return (
|
||||||
(err?.message.includes(participant.identity) &&
|
// Ideally we would pull the participant identity from the field on the error.
|
||||||
err?.message.includes(criteria)) ??
|
// However, it gets lost in the serialization process between workers.
|
||||||
false
|
// So, instead we do a string match
|
||||||
);
|
(err?.message.includes(participant.identity) &&
|
||||||
} else if (encryptionSystem.kind === E2eeType.SHARED_KEY) {
|
err?.message.includes(criteria)) ??
|
||||||
return !!err?.message.includes(criteria);
|
false
|
||||||
}
|
);
|
||||||
|
} else if (encryptionSystem.kind === E2eeType.SHARED_KEY) {
|
||||||
|
return !!err?.message.includes(criteria);
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
}),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
|
distinctUntilChanged(),
|
||||||
throttleTime(1000), // Throttle to avoid spamming the UI
|
throttleTime(1000), // Throttle to avoid spamming the UI
|
||||||
startWith(false),
|
startWith(false),
|
||||||
);
|
);
|
||||||
@@ -250,11 +256,9 @@ abstract class BaseMediaViewModel {
|
|||||||
*/
|
*/
|
||||||
public readonly id: string,
|
public readonly id: string,
|
||||||
/**
|
/**
|
||||||
* The Matrix room member to which this media belongs.
|
* The Matrix user to which this media belongs.
|
||||||
*/
|
*/
|
||||||
// TODO: Fully separate the data layer from the UI layer by keeping the
|
public readonly userId: string,
|
||||||
// member object internal
|
|
||||||
public readonly member: RoomMember,
|
|
||||||
// We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through
|
// We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through
|
||||||
// livekit.
|
// livekit.
|
||||||
protected readonly participant$: Observable<
|
protected readonly participant$: Observable<
|
||||||
@@ -264,9 +268,10 @@ abstract class BaseMediaViewModel {
|
|||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
audioSource: AudioSource,
|
audioSource: AudioSource,
|
||||||
videoSource: VideoSource,
|
videoSource: VideoSource,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||||
public readonly focusURL: string,
|
public readonly focusUrl$: Behavior<string | undefined>,
|
||||||
public readonly displayName$: Behavior<string>,
|
public readonly displayName$: Behavior<string>,
|
||||||
|
public readonly mxcAvatarUrl$: Behavior<string | undefined>,
|
||||||
) {
|
) {
|
||||||
const audio$ = this.observeTrackReference$(audioSource);
|
const audio$ = this.observeTrackReference$(audioSource);
|
||||||
this.video$ = this.observeTrackReference$(videoSource);
|
this.video$ = this.observeTrackReference$(videoSource);
|
||||||
@@ -294,13 +299,13 @@ abstract class BaseMediaViewModel {
|
|||||||
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||||
return combineLatest([
|
return combineLatest([
|
||||||
encryptionErrorObservable$(
|
encryptionErrorObservable$(
|
||||||
livekitRoom,
|
livekitRoom$,
|
||||||
participant,
|
participant,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
"MissingKey",
|
"MissingKey",
|
||||||
),
|
),
|
||||||
encryptionErrorObservable$(
|
encryptionErrorObservable$(
|
||||||
livekitRoom,
|
livekitRoom$,
|
||||||
participant,
|
participant,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
"InvalidKey",
|
"InvalidKey",
|
||||||
@@ -320,7 +325,7 @@ abstract class BaseMediaViewModel {
|
|||||||
} else {
|
} else {
|
||||||
return combineLatest([
|
return combineLatest([
|
||||||
encryptionErrorObservable$(
|
encryptionErrorObservable$(
|
||||||
livekitRoom,
|
livekitRoom$,
|
||||||
participant,
|
participant,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
"InvalidKey",
|
"InvalidKey",
|
||||||
@@ -402,26 +407,28 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
|||||||
public constructor(
|
public constructor(
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
id: string,
|
id: string,
|
||||||
member: RoomMember,
|
userId: string,
|
||||||
participant$: Observable<LocalParticipant | RemoteParticipant | null>,
|
participant$: Observable<LocalParticipant | RemoteParticipant | null>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||||
focusUrl: string,
|
focusUrl$: Behavior<string | undefined>,
|
||||||
displayName$: Behavior<string>,
|
displayName$: Behavior<string>,
|
||||||
|
mxcAvatarUrl$: Behavior<string | undefined>,
|
||||||
public readonly handRaised$: Behavior<Date | null>,
|
public readonly handRaised$: Behavior<Date | null>,
|
||||||
public readonly reaction$: Behavior<ReactionOption | null>,
|
public readonly reaction$: Behavior<ReactionOption | null>,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
scope,
|
scope,
|
||||||
id,
|
id,
|
||||||
member,
|
userId,
|
||||||
participant$,
|
participant$,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
Track.Source.Microphone,
|
Track.Source.Microphone,
|
||||||
Track.Source.Camera,
|
Track.Source.Camera,
|
||||||
livekitRoom,
|
livekitRoom$,
|
||||||
focusUrl,
|
focusUrl$,
|
||||||
displayName$,
|
displayName$,
|
||||||
|
mxcAvatarUrl$,
|
||||||
);
|
);
|
||||||
|
|
||||||
const media$ = this.scope.behavior(
|
const media$ = this.scope.behavior(
|
||||||
@@ -538,25 +545,27 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
public constructor(
|
public constructor(
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
id: string,
|
id: string,
|
||||||
member: RoomMember,
|
userId: string,
|
||||||
participant$: Behavior<LocalParticipant | null>,
|
participant$: Behavior<LocalParticipant | null>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||||
focusURL: string,
|
focusUrl$: Behavior<string | undefined>,
|
||||||
private readonly mediaDevices: MediaDevices,
|
private readonly mediaDevices: MediaDevices,
|
||||||
displayName$: Behavior<string>,
|
displayName$: Behavior<string>,
|
||||||
|
mxcAvatarUrl$: Behavior<string | undefined>,
|
||||||
handRaised$: Behavior<Date | null>,
|
handRaised$: Behavior<Date | null>,
|
||||||
reaction$: Behavior<ReactionOption | null>,
|
reaction$: Behavior<ReactionOption | null>,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
scope,
|
scope,
|
||||||
id,
|
id,
|
||||||
member,
|
userId,
|
||||||
participant$,
|
participant$,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
livekitRoom,
|
livekitRoom$,
|
||||||
focusURL,
|
focusUrl$,
|
||||||
displayName$,
|
displayName$,
|
||||||
|
mxcAvatarUrl$,
|
||||||
handRaised$,
|
handRaised$,
|
||||||
reaction$,
|
reaction$,
|
||||||
);
|
);
|
||||||
@@ -648,25 +657,27 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
public constructor(
|
public constructor(
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
id: string,
|
id: string,
|
||||||
member: RoomMember,
|
userId: string,
|
||||||
participant$: Observable<RemoteParticipant | null>,
|
participant$: Observable<RemoteParticipant | null>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||||
focusUrl: string,
|
focusUrl$: Behavior<string | undefined>,
|
||||||
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
||||||
displayname$: Behavior<string>,
|
displayName$: Behavior<string>,
|
||||||
|
mxcAvatarUrl$: Behavior<string | undefined>,
|
||||||
handRaised$: Behavior<Date | null>,
|
handRaised$: Behavior<Date | null>,
|
||||||
reaction$: Behavior<ReactionOption | null>,
|
reaction$: Behavior<ReactionOption | null>,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
scope,
|
scope,
|
||||||
id,
|
id,
|
||||||
member,
|
userId,
|
||||||
participant$,
|
participant$,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
livekitRoom,
|
livekitRoom$,
|
||||||
focusUrl,
|
focusUrl$,
|
||||||
displayname$,
|
displayName$,
|
||||||
|
mxcAvatarUrl$,
|
||||||
handRaised$,
|
handRaised$,
|
||||||
reaction$,
|
reaction$,
|
||||||
);
|
);
|
||||||
@@ -747,26 +758,28 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
|
|||||||
public constructor(
|
public constructor(
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
id: string,
|
id: string,
|
||||||
member: RoomMember,
|
userId: string,
|
||||||
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||||
focusUrl: string,
|
focusUrl$: Behavior<string | undefined>,
|
||||||
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
||||||
displayname$: Behavior<string>,
|
displayName$: Behavior<string>,
|
||||||
|
mxcAvatarUrl$: Behavior<string | undefined>,
|
||||||
public readonly local: boolean,
|
public readonly local: boolean,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
scope,
|
scope,
|
||||||
id,
|
id,
|
||||||
member,
|
userId,
|
||||||
participant$,
|
participant$,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
Track.Source.ScreenShareAudio,
|
Track.Source.ScreenShareAudio,
|
||||||
Track.Source.ScreenShare,
|
Track.Source.ScreenShare,
|
||||||
livekitRoom,
|
livekitRoom$,
|
||||||
focusUrl,
|
focusUrl$,
|
||||||
displayname$,
|
displayName$,
|
||||||
|
mxcAvatarUrl$,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ import { type Behavior } from "./Behavior";
|
|||||||
|
|
||||||
type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
|
type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
|
||||||
|
|
||||||
export const noInitialValue = Symbol("nothing");
|
type SplitBehavior<T> = keyof T extends string | number
|
||||||
|
? { [K in keyof T as `${K}$`]: Behavior<T[K]> }
|
||||||
|
: never;
|
||||||
|
|
||||||
|
const nothing = Symbol("nothing");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A scope which limits the execution lifetime of its bound Observables.
|
* A scope which limits the execution lifetime of its bound Observables.
|
||||||
@@ -59,7 +63,10 @@ export class ObservableScope {
|
|||||||
* Converts an Observable to a Behavior. If no initial value is specified, the
|
* Converts an Observable to a Behavior. If no initial value is specified, the
|
||||||
* Observable must synchronously emit an initial value.
|
* Observable must synchronously emit an initial value.
|
||||||
*/
|
*/
|
||||||
public behavior<T>(setValue$: Observable<T>, initialValue?: T): Behavior<T> {
|
public behavior<T>(
|
||||||
|
setValue$: Observable<T>,
|
||||||
|
initialValue: T | typeof nothing = nothing,
|
||||||
|
): Behavior<T> {
|
||||||
const subject$ = new BehaviorSubject(initialValue);
|
const subject$ = new BehaviorSubject(initialValue);
|
||||||
// Push values from the Observable into the BehaviorSubject.
|
// Push values from the Observable into the BehaviorSubject.
|
||||||
// BehaviorSubjects have an undesirable feature where if you call 'complete',
|
// BehaviorSubjects have an undesirable feature where if you call 'complete',
|
||||||
@@ -74,7 +81,7 @@ export class ObservableScope {
|
|||||||
subject$.error(err);
|
subject$.error(err);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (subject$.value === noInitialValue)
|
if (subject$.value === nothing)
|
||||||
throw new Error("Behavior failed to synchronously emit an initial value");
|
throw new Error("Behavior failed to synchronously emit an initial value");
|
||||||
return subject$ as Behavior<T>;
|
return subject$ as Behavior<T>;
|
||||||
}
|
}
|
||||||
@@ -115,27 +122,27 @@ export class ObservableScope {
|
|||||||
value$: Behavior<T>,
|
value$: Behavior<T>,
|
||||||
callback: (value: T) => Promise<(() => Promise<void>) | void>,
|
callback: (value: T) => Promise<(() => Promise<void>) | void>,
|
||||||
): void {
|
): void {
|
||||||
let latestValue: T | typeof noInitialValue = noInitialValue;
|
let latestValue: T | typeof nothing = nothing;
|
||||||
let reconciledValue: T | typeof noInitialValue = noInitialValue;
|
let reconciledValue: T | typeof nothing = nothing;
|
||||||
let cleanUp: (() => Promise<void>) | void = undefined;
|
let cleanUp: (() => Promise<void>) | void = undefined;
|
||||||
value$
|
value$
|
||||||
.pipe(
|
.pipe(
|
||||||
catchError(() => EMPTY), // Ignore errors
|
catchError(() => EMPTY), // Ignore errors
|
||||||
this.bind(), // Limit to the duration of the scope
|
this.bind(), // Limit to the duration of the scope
|
||||||
endWith(noInitialValue), // Clean up when the scope ends
|
endWith(nothing), // Clean up when the scope ends
|
||||||
)
|
)
|
||||||
.subscribe((value) => {
|
.subscribe((value) => {
|
||||||
void (async (): Promise<void> => {
|
void (async (): Promise<void> => {
|
||||||
if (latestValue === noInitialValue) {
|
if (latestValue === nothing) {
|
||||||
latestValue = value;
|
latestValue = value;
|
||||||
while (latestValue !== reconciledValue) {
|
while (latestValue !== reconciledValue) {
|
||||||
await cleanUp?.(); // Call the previous value's clean-up handler
|
await cleanUp?.(); // Call the previous value's clean-up handler
|
||||||
reconciledValue = latestValue;
|
reconciledValue = latestValue;
|
||||||
if (latestValue !== noInitialValue)
|
if (latestValue !== nothing)
|
||||||
cleanUp = await callback(latestValue); // Sync current value
|
cleanUp = await callback(latestValue); // Sync current value
|
||||||
}
|
}
|
||||||
// Reset to signal that reconciliation is done for now
|
// Reset to signal that reconciliation is done for now
|
||||||
latestValue = noInitialValue;
|
latestValue = nothing;
|
||||||
} else {
|
} else {
|
||||||
// There's already an instance of the above 'while' loop running
|
// There's already an instance of the above 'while' loop running
|
||||||
// concurrently. Just update the latest value and let it be handled.
|
// concurrently. Just update the latest value and let it be handled.
|
||||||
@@ -144,6 +151,24 @@ export class ObservableScope {
|
|||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a Behavior of objects with static properties into an object with
|
||||||
|
* Behavior properties.
|
||||||
|
*
|
||||||
|
* For example, splitting a Behavior<{ name: string, age: number }> results in
|
||||||
|
* an object of type { name$: Behavior<string> age$: Behavior<number> }.
|
||||||
|
*/
|
||||||
|
public splitBehavior<T extends object>(
|
||||||
|
input$: Behavior<T>,
|
||||||
|
): SplitBehavior<T> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.keys(input$.value).map((key) => [
|
||||||
|
`${key}$`,
|
||||||
|
this.behavior(input$.pipe(map((input) => input[key as keyof T]))),
|
||||||
|
]),
|
||||||
|
) as SplitBehavior<T>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Copyright 2025 New Vector Ltd.
|
|||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
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 { of, type Observable } from "rxjs";
|
import { of } from "rxjs";
|
||||||
import {
|
import {
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
|
|
||||||
import { type ObservableScope } from "./ObservableScope.ts";
|
import { type ObservableScope } from "./ObservableScope.ts";
|
||||||
import { ScreenShareViewModel } from "./MediaViewModel.ts";
|
import { ScreenShareViewModel } from "./MediaViewModel.ts";
|
||||||
import type { RoomMember } from "matrix-js-sdk";
|
|
||||||
import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts";
|
import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts";
|
||||||
import type { Behavior } from "./Behavior.ts";
|
import type { Behavior } from "./Behavior.ts";
|
||||||
|
|
||||||
@@ -28,24 +27,26 @@ export class ScreenShare {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly scope: ObservableScope,
|
private readonly scope: ObservableScope,
|
||||||
id: string,
|
id: string,
|
||||||
member: RoomMember,
|
userId: string,
|
||||||
participant: LocalParticipant | RemoteParticipant,
|
participant: LocalParticipant | RemoteParticipant,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
livekitRoom: LivekitRoom,
|
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||||
focusUrl: string,
|
focusUrl$: Behavior<string | undefined>,
|
||||||
pretendToBeDisconnected$: Behavior<boolean>,
|
pretendToBeDisconnected$: Behavior<boolean>,
|
||||||
displayName$: Observable<string>,
|
displayName$: Behavior<string>,
|
||||||
|
mxcAvatarUrl$: Behavior<string | undefined>,
|
||||||
) {
|
) {
|
||||||
this.vm = new ScreenShareViewModel(
|
this.vm = new ScreenShareViewModel(
|
||||||
this.scope,
|
this.scope,
|
||||||
id,
|
id,
|
||||||
member,
|
userId,
|
||||||
of(participant),
|
of(participant),
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
livekitRoom,
|
livekitRoom$,
|
||||||
focusUrl,
|
focusUrl$,
|
||||||
pretendToBeDisconnected$,
|
pretendToBeDisconnected$,
|
||||||
this.scope.behavior(displayName$),
|
displayName$,
|
||||||
|
mxcAvatarUrl$,
|
||||||
participant.isLocal,
|
participant.isLocal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { fillGaps } from "../utils/iter";
|
|||||||
import { debugTileLayout } from "../settings/settings";
|
import { debugTileLayout } from "../settings/settings";
|
||||||
|
|
||||||
function debugEntries(entries: GridTileData[]): string[] {
|
function debugEntries(entries: GridTileData[]): string[] {
|
||||||
return entries.map((e) => e.media.member?.rawDisplayName ?? "[👻]");
|
return entries.map((e) => e.media.displayName$.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
let DEBUG_ENABLED = false;
|
let DEBUG_ENABLED = false;
|
||||||
@@ -156,7 +156,7 @@ export class TileStoreBuilder {
|
|||||||
public registerSpotlight(media: MediaViewModel[], maximised: boolean): void {
|
public registerSpotlight(media: MediaViewModel[], maximised: boolean): void {
|
||||||
if (DEBUG_ENABLED)
|
if (DEBUG_ENABLED)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[TileStore, ${this.generation}] register spotlight: ${media.map((m) => m.member?.rawDisplayName ?? "[👻]")}`,
|
`[TileStore, ${this.generation}] register spotlight: ${media.map((m) => m.displayName$.value)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.spotlight !== null) throw new Error("Spotlight already set");
|
if (this.spotlight !== null) throw new Error("Spotlight already set");
|
||||||
@@ -180,7 +180,7 @@ export class TileStoreBuilder {
|
|||||||
public registerGridTile(media: UserMediaViewModel): void {
|
public registerGridTile(media: UserMediaViewModel): void {
|
||||||
if (DEBUG_ENABLED)
|
if (DEBUG_ENABLED)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[TileStore, ${this.generation}] register grid tile: ${media.member?.rawDisplayName ?? "[👻]"}`,
|
`[TileStore, ${this.generation}] register grid tile: ${media.displayName$.value}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (this.spotlight !== null) {
|
if (this.spotlight !== null) {
|
||||||
@@ -263,7 +263,7 @@ export class TileStoreBuilder {
|
|||||||
public registerPipTile(media: UserMediaViewModel): void {
|
public registerPipTile(media: UserMediaViewModel): void {
|
||||||
if (DEBUG_ENABLED)
|
if (DEBUG_ENABLED)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[TileStore, ${this.generation}] register PiP tile: ${media.member?.rawDisplayName ?? "[👻]"}`,
|
`[TileStore, ${this.generation}] register PiP tile: ${media.displayName$.value}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// If there is a single grid tile that we can reuse
|
// If there is a single grid tile that we can reuse
|
||||||
|
|||||||
@@ -5,17 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { combineLatest, map, type Observable, of, switchMap } from "rxjs";
|
||||||
BehaviorSubject,
|
|
||||||
combineLatest,
|
|
||||||
map,
|
|
||||||
type Observable,
|
|
||||||
of,
|
|
||||||
switchMap,
|
|
||||||
} from "rxjs";
|
|
||||||
import {
|
import {
|
||||||
type LocalParticipant,
|
type LocalParticipant,
|
||||||
type Participant,
|
|
||||||
ParticipantEvent,
|
ParticipantEvent,
|
||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
type Room as LivekitRoom,
|
type Room as LivekitRoom,
|
||||||
@@ -29,11 +21,12 @@ import {
|
|||||||
type UserMediaViewModel,
|
type UserMediaViewModel,
|
||||||
} from "./MediaViewModel.ts";
|
} from "./MediaViewModel.ts";
|
||||||
import type { Behavior } from "./Behavior.ts";
|
import type { Behavior } from "./Behavior.ts";
|
||||||
import type { RoomMember } from "matrix-js-sdk";
|
|
||||||
import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts";
|
import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts";
|
||||||
import type { MediaDevices } from "./MediaDevices.ts";
|
import type { MediaDevices } from "./MediaDevices.ts";
|
||||||
import type { ReactionOption } from "../reactions";
|
import type { ReactionOption } from "../reactions";
|
||||||
import { observeSpeaker$ } from "./observeSpeaker.ts";
|
import { observeSpeaker$ } from "./observeSpeaker.ts";
|
||||||
|
import { generateItems } from "../utils/observable.ts";
|
||||||
|
import { ScreenShare } from "./ScreenShare.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.
|
||||||
@@ -72,35 +65,35 @@ enum SortingBin {
|
|||||||
/**
|
/**
|
||||||
* A user media item to be presented in a tile. This is a thin wrapper around
|
* A user media item to be presented in a tile. This is a thin wrapper around
|
||||||
* UserMediaViewModel which additionally determines the media item's sorting bin
|
* UserMediaViewModel which additionally determines the media item's sorting bin
|
||||||
* for inclusion in the call layout.
|
* for inclusion in the call layout and tracks associated screen shares.
|
||||||
*/
|
*/
|
||||||
export class UserMedia {
|
export class UserMedia {
|
||||||
private readonly participant$ = new BehaviorSubject(this.initialParticipant);
|
|
||||||
|
|
||||||
public readonly vm: UserMediaViewModel = this.participant$.value?.isLocal
|
public readonly vm: UserMediaViewModel = this.participant$.value?.isLocal
|
||||||
? new LocalUserMediaViewModel(
|
? new LocalUserMediaViewModel(
|
||||||
this.scope,
|
this.scope,
|
||||||
this.id,
|
this.id,
|
||||||
this.member,
|
this.userId,
|
||||||
this.participant$ as Behavior<LocalParticipant | null>,
|
this.participant$ as Behavior<LocalParticipant | null>,
|
||||||
this.encryptionSystem,
|
this.encryptionSystem,
|
||||||
this.livekitRoom,
|
this.livekitRoom$,
|
||||||
this.focusURL,
|
this.focusUrl$,
|
||||||
this.mediaDevices,
|
this.mediaDevices,
|
||||||
this.scope.behavior(this.displayname$),
|
this.displayName$,
|
||||||
|
this.mxcAvatarUrl$,
|
||||||
this.scope.behavior(this.handRaised$),
|
this.scope.behavior(this.handRaised$),
|
||||||
this.scope.behavior(this.reaction$),
|
this.scope.behavior(this.reaction$),
|
||||||
)
|
)
|
||||||
: new RemoteUserMediaViewModel(
|
: new RemoteUserMediaViewModel(
|
||||||
this.scope,
|
this.scope,
|
||||||
this.id,
|
this.id,
|
||||||
this.member,
|
this.userId,
|
||||||
this.participant$ as Behavior<RemoteParticipant | null>,
|
this.participant$ as Behavior<RemoteParticipant | null>,
|
||||||
this.encryptionSystem,
|
this.encryptionSystem,
|
||||||
this.livekitRoom,
|
this.livekitRoom$,
|
||||||
this.focusURL,
|
this.focusUrl$,
|
||||||
this.pretendToBeDisconnected$,
|
this.pretendToBeDisconnected$,
|
||||||
this.scope.behavior(this.displayname$),
|
this.displayName$,
|
||||||
|
this.mxcAvatarUrl$,
|
||||||
this.scope.behavior(this.handRaised$),
|
this.scope.behavior(this.handRaised$),
|
||||||
this.scope.behavior(this.reaction$),
|
this.scope.behavior(this.reaction$),
|
||||||
);
|
);
|
||||||
@@ -109,12 +102,55 @@ export class UserMedia {
|
|||||||
observeSpeaker$(this.vm.speaking$),
|
observeSpeaker$(this.vm.speaking$),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly presenter$ = this.scope.behavior(
|
/**
|
||||||
|
* All screen share media associated with this user media.
|
||||||
|
*/
|
||||||
|
public readonly screenShares$ = this.scope.behavior(
|
||||||
this.participant$.pipe(
|
this.participant$.pipe(
|
||||||
switchMap((p) => (p === null ? of(false) : sharingScreen$(p))),
|
switchMap((p) =>
|
||||||
|
p === null
|
||||||
|
? of([])
|
||||||
|
: observeParticipantEvents(
|
||||||
|
p,
|
||||||
|
ParticipantEvent.TrackPublished,
|
||||||
|
ParticipantEvent.TrackUnpublished,
|
||||||
|
ParticipantEvent.LocalTrackPublished,
|
||||||
|
ParticipantEvent.LocalTrackUnpublished,
|
||||||
|
).pipe(
|
||||||
|
// Technically more than one screen share might be possible... our
|
||||||
|
// MediaViewModels don't support it though since they look for a unique
|
||||||
|
// track for the given source. So generateItems here is a bit overkill.
|
||||||
|
generateItems(
|
||||||
|
function* (p) {
|
||||||
|
if (p.isScreenShareEnabled)
|
||||||
|
yield {
|
||||||
|
keys: ["screen-share"],
|
||||||
|
data: undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
(scope, _data$, key) =>
|
||||||
|
new ScreenShare(
|
||||||
|
scope,
|
||||||
|
`${this.id}:${key}`,
|
||||||
|
this.userId,
|
||||||
|
p,
|
||||||
|
this.encryptionSystem,
|
||||||
|
this.livekitRoom$,
|
||||||
|
this.focusUrl$,
|
||||||
|
this.pretendToBeDisconnected$,
|
||||||
|
this.displayName$,
|
||||||
|
this.mxcAvatarUrl$,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private readonly presenter$ = this.scope.behavior(
|
||||||
|
this.screenShares$.pipe(map((screenShares) => screenShares.length > 0)),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which sorting bin the media item should be placed in.
|
* Which sorting bin the media item should be placed in.
|
||||||
*/
|
*/
|
||||||
@@ -147,37 +183,18 @@ export class UserMedia {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly scope: ObservableScope,
|
private readonly scope: ObservableScope,
|
||||||
public readonly id: string,
|
public readonly id: string,
|
||||||
private readonly member: RoomMember,
|
private readonly userId: string,
|
||||||
private readonly initialParticipant:
|
private readonly participant$: Behavior<
|
||||||
| LocalParticipant
|
LocalParticipant | RemoteParticipant | null
|
||||||
| RemoteParticipant
|
>,
|
||||||
| null = null,
|
|
||||||
private readonly encryptionSystem: EncryptionSystem,
|
private readonly encryptionSystem: EncryptionSystem,
|
||||||
private readonly livekitRoom: LivekitRoom,
|
private readonly livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||||
private readonly focusURL: string,
|
private readonly focusUrl$: Behavior<string | undefined>,
|
||||||
private readonly mediaDevices: MediaDevices,
|
private readonly mediaDevices: MediaDevices,
|
||||||
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
||||||
private readonly displayname$: Observable<string>,
|
private readonly displayName$: Behavior<string>,
|
||||||
|
private readonly mxcAvatarUrl$: Behavior<string | undefined>,
|
||||||
private readonly handRaised$: Observable<Date | null>,
|
private readonly handRaised$: Observable<Date | null>,
|
||||||
private readonly reaction$: Observable<ReactionOption | null>,
|
private readonly reaction$: Observable<ReactionOption | null>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public updateParticipant(
|
|
||||||
newParticipant: LocalParticipant | RemoteParticipant | null = null,
|
|
||||||
): void {
|
|
||||||
if (this.participant$.value !== newParticipant) {
|
|
||||||
// Update the BehaviourSubject in the UserMedia.
|
|
||||||
this.participant$.next(newParticipant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sharingScreen$(p: Participant): Observable<boolean> {
|
|
||||||
return observeParticipantEvents(
|
|
||||||
p,
|
|
||||||
ParticipantEvent.TrackPublished,
|
|
||||||
ParticipantEvent.TrackUnpublished,
|
|
||||||
ParticipantEvent.LocalTrackPublished,
|
|
||||||
ParticipantEvent.LocalTrackUnpublished,
|
|
||||||
).pipe(map((p) => p.isScreenShareEnabled));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ interface TileProps {
|
|||||||
style?: ComponentProps<typeof animated.div>["style"];
|
style?: ComponentProps<typeof animated.div>["style"];
|
||||||
targetWidth: number;
|
targetWidth: number;
|
||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
|
focusUrl: string | undefined;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
mxcAvatarUrl: string | undefined;
|
||||||
showSpeakingIndicators: boolean;
|
showSpeakingIndicators: boolean;
|
||||||
focusable: boolean;
|
focusable: boolean;
|
||||||
}
|
}
|
||||||
@@ -81,7 +83,9 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
menuStart,
|
menuStart,
|
||||||
menuEnd,
|
menuEnd,
|
||||||
className,
|
className,
|
||||||
|
focusUrl,
|
||||||
displayName,
|
displayName,
|
||||||
|
mxcAvatarUrl,
|
||||||
focusable,
|
focusable,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
@@ -145,7 +149,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
<MediaView
|
<MediaView
|
||||||
ref={ref}
|
ref={ref}
|
||||||
video={video ?? undefined}
|
video={video ?? undefined}
|
||||||
member={vm.member}
|
userId={vm.userId}
|
||||||
unencryptedWarning={unencryptedWarning}
|
unencryptedWarning={unencryptedWarning}
|
||||||
encryptionStatus={encryptionStatus}
|
encryptionStatus={encryptionStatus}
|
||||||
videoEnabled={videoEnabled}
|
videoEnabled={videoEnabled}
|
||||||
@@ -164,6 +168,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
|
mxcAvatarUrl={mxcAvatarUrl}
|
||||||
focusable={focusable}
|
focusable={focusable}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
primaryButton ?? (
|
primaryButton ?? (
|
||||||
@@ -190,7 +195,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
currentReaction={reaction ?? undefined}
|
currentReaction={reaction ?? undefined}
|
||||||
raisedHandOnClick={raisedHandOnClick}
|
raisedHandOnClick={raisedHandOnClick}
|
||||||
localParticipant={vm.local}
|
localParticipant={vm.local}
|
||||||
focusUrl={vm.focusURL}
|
focusUrl={focusUrl}
|
||||||
audioStreamStats={audioStreamStats}
|
audioStreamStats={audioStreamStats}
|
||||||
videoStreamStats={videoStreamStats}
|
videoStreamStats={videoStreamStats}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -359,7 +364,9 @@ export const GridTile: FC<GridTileProps> = ({
|
|||||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||||
const ref = useMergedRefs(ourRef, theirRef);
|
const ref = useMergedRefs(ourRef, theirRef);
|
||||||
const media = useBehavior(vm.media$);
|
const media = useBehavior(vm.media$);
|
||||||
|
const focusUrl = useBehavior(media.focusUrl$);
|
||||||
const displayName = useBehavior(media.displayName$);
|
const displayName = useBehavior(media.displayName$);
|
||||||
|
const mxcAvatarUrl = useBehavior(media.mxcAvatarUrl$);
|
||||||
|
|
||||||
if (media instanceof LocalUserMediaViewModel) {
|
if (media instanceof LocalUserMediaViewModel) {
|
||||||
return (
|
return (
|
||||||
@@ -367,7 +374,9 @@ export const GridTile: FC<GridTileProps> = ({
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
vm={media}
|
vm={media}
|
||||||
onOpenProfile={onOpenProfile}
|
onOpenProfile={onOpenProfile}
|
||||||
|
focusUrl={focusUrl}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
|
mxcAvatarUrl={mxcAvatarUrl}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -376,7 +385,9 @@ export const GridTile: FC<GridTileProps> = ({
|
|||||||
<RemoteUserMediaTile
|
<RemoteUserMediaTile
|
||||||
ref={ref}
|
ref={ref}
|
||||||
vm={media}
|
vm={media}
|
||||||
|
focusUrl={focusUrl}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
|
mxcAvatarUrl={mxcAvatarUrl}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||||
import { animated } from "@react-spring/web";
|
import { animated } from "@react-spring/web";
|
||||||
import { type RoomMember } from "matrix-js-sdk";
|
|
||||||
import { type FC, type ComponentProps, type ReactNode } from "react";
|
import { type FC, type ComponentProps, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
@@ -32,12 +31,13 @@ interface Props extends ComponentProps<typeof animated.div> {
|
|||||||
video: TrackReferenceOrPlaceholder | undefined;
|
video: TrackReferenceOrPlaceholder | undefined;
|
||||||
videoFit: "cover" | "contain";
|
videoFit: "cover" | "contain";
|
||||||
mirror: boolean;
|
mirror: boolean;
|
||||||
member: RoomMember;
|
userId: string;
|
||||||
videoEnabled: boolean;
|
videoEnabled: boolean;
|
||||||
unencryptedWarning: boolean;
|
unencryptedWarning: boolean;
|
||||||
encryptionStatus: EncryptionStatus;
|
encryptionStatus: EncryptionStatus;
|
||||||
nameTagLeadingIcon?: ReactNode;
|
nameTagLeadingIcon?: ReactNode;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
mxcAvatarUrl: string | undefined;
|
||||||
focusable: boolean;
|
focusable: boolean;
|
||||||
primaryButton?: ReactNode;
|
primaryButton?: ReactNode;
|
||||||
raisedHandTime?: Date;
|
raisedHandTime?: Date;
|
||||||
@@ -59,11 +59,12 @@ export const MediaView: FC<Props> = ({
|
|||||||
video,
|
video,
|
||||||
videoFit,
|
videoFit,
|
||||||
mirror,
|
mirror,
|
||||||
member,
|
userId,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
unencryptedWarning,
|
unencryptedWarning,
|
||||||
nameTagLeadingIcon,
|
nameTagLeadingIcon,
|
||||||
displayName,
|
displayName,
|
||||||
|
mxcAvatarUrl,
|
||||||
focusable,
|
focusable,
|
||||||
primaryButton,
|
primaryButton,
|
||||||
encryptionStatus,
|
encryptionStatus,
|
||||||
@@ -94,10 +95,10 @@ export const MediaView: FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<div className={styles.bg}>
|
<div className={styles.bg}>
|
||||||
<Avatar
|
<Avatar
|
||||||
id={member?.userId ?? displayName}
|
id={userId}
|
||||||
name={displayName}
|
name={displayName}
|
||||||
size={avatarSize}
|
size={avatarSize}
|
||||||
src={member?.getMxcAvatarUrl()}
|
src={mxcAvatarUrl}
|
||||||
className={styles.avatar}
|
className={styles.avatar}
|
||||||
style={{ display: video && videoEnabled ? "none" : "initial" }}
|
style={{ display: video && videoEnabled ? "none" : "initial" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import { useObservableRef } from "observable-hooks";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||||
import { type RoomMember } from "matrix-js-sdk";
|
|
||||||
|
|
||||||
import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
|
import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
|
||||||
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
|
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
|
||||||
@@ -55,10 +54,12 @@ interface SpotlightItemBaseProps {
|
|||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
video: TrackReferenceOrPlaceholder | undefined;
|
video: TrackReferenceOrPlaceholder | undefined;
|
||||||
videoEnabled: boolean;
|
videoEnabled: boolean;
|
||||||
member: RoomMember;
|
userId: string;
|
||||||
unencryptedWarning: boolean;
|
unencryptedWarning: boolean;
|
||||||
encryptionStatus: EncryptionStatus;
|
encryptionStatus: EncryptionStatus;
|
||||||
|
focusUrl: string | undefined;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
mxcAvatarUrl: string | undefined;
|
||||||
focusable: boolean;
|
focusable: boolean;
|
||||||
"aria-hidden"?: boolean;
|
"aria-hidden"?: boolean;
|
||||||
localParticipant: boolean;
|
localParticipant: boolean;
|
||||||
@@ -78,7 +79,7 @@ const SpotlightLocalUserMediaItem: FC<SpotlightLocalUserMediaItemProps> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const mirror = useBehavior(vm.mirror$);
|
const mirror = useBehavior(vm.mirror$);
|
||||||
return <MediaView mirror={mirror} focusUrl={vm.focusURL} {...props} />;
|
return <MediaView mirror={mirror} {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
|
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
|
||||||
@@ -134,7 +135,9 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||||
const ref = useMergedRefs(ourRef, theirRef);
|
const ref = useMergedRefs(ourRef, theirRef);
|
||||||
|
const focusUrl = useBehavior(vm.focusUrl$);
|
||||||
const displayName = useBehavior(vm.displayName$);
|
const displayName = useBehavior(vm.displayName$);
|
||||||
|
const mxcAvatarUrl = useBehavior(vm.mxcAvatarUrl$);
|
||||||
const video = useBehavior(vm.video$);
|
const video = useBehavior(vm.video$);
|
||||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||||
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
||||||
@@ -161,11 +164,13 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
|||||||
className: classNames(styles.item, { [styles.snap]: snap }),
|
className: classNames(styles.item, { [styles.snap]: snap }),
|
||||||
targetWidth,
|
targetWidth,
|
||||||
targetHeight,
|
targetHeight,
|
||||||
video,
|
video: video ?? undefined,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
member: vm.member,
|
userId: vm.userId,
|
||||||
unencryptedWarning,
|
unencryptedWarning,
|
||||||
|
focusUrl,
|
||||||
displayName,
|
displayName,
|
||||||
|
mxcAvatarUrl,
|
||||||
focusable,
|
focusable,
|
||||||
encryptionStatus,
|
encryptionStatus,
|
||||||
"aria-hidden": ariaHidden,
|
"aria-hidden": ariaHidden,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { test } from "vitest";
|
|||||||
import { Subject } from "rxjs";
|
import { Subject } from "rxjs";
|
||||||
|
|
||||||
import { withTestScheduler } from "./test";
|
import { withTestScheduler } from "./test";
|
||||||
import { generateKeyed$, pauseWhen } from "./observable";
|
import { generateItems, pauseWhen } from "./observable";
|
||||||
|
|
||||||
test("pauseWhen", () => {
|
test("pauseWhen", () => {
|
||||||
withTestScheduler(({ behavior, expectObservable }) => {
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
@@ -24,7 +24,7 @@ test("pauseWhen", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("generateKeyed$ has the right output and ends scopes at the right times", () => {
|
test("generateItems", () => {
|
||||||
const scope1$ = new Subject<string>();
|
const scope1$ = new Subject<string>();
|
||||||
const scope2$ = new Subject<string>();
|
const scope2$ = new Subject<string>();
|
||||||
const scope3$ = new Subject<string>();
|
const scope3$ = new Subject<string>();
|
||||||
@@ -44,18 +44,27 @@ test("generateKeyed$ has the right output and ends scopes at the right times", (
|
|||||||
const scope4Marbles = " ----yn";
|
const scope4Marbles = " ----yn";
|
||||||
|
|
||||||
expectObservable(
|
expectObservable(
|
||||||
generateKeyed$(hot<string>(inputMarbles), (input, createOrGet) => {
|
hot<string>(inputMarbles).pipe(
|
||||||
for (let i = 1; i <= +input; i++) {
|
generateItems(
|
||||||
createOrGet(i.toString(), (scope) => {
|
function* (input) {
|
||||||
|
for (let i = 1; i <= +input; i++) {
|
||||||
|
yield { keys: [i], data: undefined };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(scope, data$, i) => {
|
||||||
scopeSubjects[i - 1].next("y");
|
scopeSubjects[i - 1].next("y");
|
||||||
scope.onEnd(() => scopeSubjects[i - 1].next("n"));
|
scope.onEnd(() => scopeSubjects[i - 1].next("n"));
|
||||||
return i.toString();
|
return i.toString();
|
||||||
});
|
},
|
||||||
}
|
),
|
||||||
return "abcd"[+input - 1];
|
),
|
||||||
}),
|
|
||||||
subscriptionMarbles,
|
subscriptionMarbles,
|
||||||
).toBe(outputMarbles);
|
).toBe(outputMarbles, {
|
||||||
|
a: ["1"],
|
||||||
|
b: ["1", "2"],
|
||||||
|
c: ["1", "2", "3"],
|
||||||
|
d: ["1", "2", "3", "4"],
|
||||||
|
});
|
||||||
|
|
||||||
expectObservable(scope1$).toBe(scope1Marbles);
|
expectObservable(scope1$).toBe(scope1Marbles);
|
||||||
expectObservable(scope2$).toBe(scope2Marbles);
|
expectObservable(scope2$).toBe(scope2Marbles);
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ import {
|
|||||||
takeWhile,
|
takeWhile,
|
||||||
tap,
|
tap,
|
||||||
withLatestFrom,
|
withLatestFrom,
|
||||||
|
BehaviorSubject,
|
||||||
|
type OperatorFunction,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
import { type Behavior } from "../state/Behavior";
|
import { type Behavior } from "../state/Behavior";
|
||||||
import { ObservableScope } from "../state/ObservableScope";
|
import { Epoch, ObservableScope } from "../state/ObservableScope";
|
||||||
|
|
||||||
const nothing = Symbol("nothing");
|
const nothing = Symbol("nothing");
|
||||||
|
|
||||||
@@ -119,70 +121,156 @@ export function pauseWhen<T>(pause$: Behavior<boolean>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ItemHandle<Data, Item> {
|
||||||
|
scope: ObservableScope;
|
||||||
|
data$: BehaviorSubject<Data>;
|
||||||
|
item: Item;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps a changing input value to an output value consisting of items that have
|
* Maps a changing input value to a collection of items that each capture some
|
||||||
* automatically generated ObservableScopes tied to a key. Items will be
|
* dynamic data and are tied to a key. Items will be automatically created when
|
||||||
* automatically created when their key is requested for the first time, reused
|
* their key is requested for the first time, reused when the same key is
|
||||||
* when the same key is requested at a later time, and destroyed (have their
|
* requested at a later time, and destroyed (have their scope ended) when the
|
||||||
* scope ended) when the key is no longer requested.
|
* key is no longer requested.
|
||||||
*
|
*
|
||||||
* @param input$ The input value to be mapped.
|
* @param input$ The input value to be mapped.
|
||||||
* @param project A function mapping input values to output values. This
|
* @param generator A generator function yielding a tuple of keys and the
|
||||||
* function receives an additional callback `createOrGet` which can be used
|
* currently associated data for each item that it wants to exist.
|
||||||
* within the function body to request that an item be generated for a certain
|
* @param factory A function constructing an individual item, given the item's key,
|
||||||
* key. The caller provides a factory which will be used to create the item if
|
* dynamic data, and an automatically managed ObservableScope for the item.
|
||||||
* it is being requested for the first time. Otherwise, the item previously
|
|
||||||
* existing under that key will be returned.
|
|
||||||
*/
|
*/
|
||||||
export function generateKeyed$<In, Item, Out>(
|
export function generateItems<
|
||||||
input$: Observable<In>,
|
Input,
|
||||||
project: (
|
Keys extends [unknown, ...unknown[]],
|
||||||
input: In,
|
Data,
|
||||||
createOrGet: (
|
Item,
|
||||||
key: string,
|
>(
|
||||||
factory: (scope: ObservableScope) => Item,
|
generator: (
|
||||||
) => Item,
|
input: Input,
|
||||||
) => Out,
|
) => Generator<{ keys: readonly [...Keys]; data: Data }, void, void>,
|
||||||
): Observable<Out> {
|
factory: (
|
||||||
return input$.pipe(
|
scope: ObservableScope,
|
||||||
// Keep track of the existing items over time, so we can reuse them
|
data$: Behavior<Data>,
|
||||||
scan<
|
...keys: Keys
|
||||||
In,
|
) => Item,
|
||||||
{
|
): OperatorFunction<Input, Item[]> {
|
||||||
items: Map<string, { item: Item; scope: ObservableScope }>;
|
return generateItemsInternal(generator, factory, (items) => items);
|
||||||
output: Out;
|
}
|
||||||
},
|
|
||||||
{ items: Map<string, { item: Item; scope: ObservableScope }> }
|
|
||||||
>(
|
|
||||||
(state, data) => {
|
|
||||||
const nextItems = new Map<
|
|
||||||
string,
|
|
||||||
{ item: Item; scope: ObservableScope }
|
|
||||||
>();
|
|
||||||
|
|
||||||
const output = project(data, (key, factory) => {
|
/**
|
||||||
let item = state.items.get(key);
|
* Same as generateItems, but preserves epoch data.
|
||||||
if (item === undefined) {
|
*/
|
||||||
// First time requesting the key; create the item
|
export function generateItemsWithEpoch<
|
||||||
const scope = new ObservableScope();
|
Input,
|
||||||
item = { item: factory(scope), scope };
|
Keys extends [unknown, ...unknown[]],
|
||||||
}
|
Data,
|
||||||
nextItems.set(key, item);
|
Item,
|
||||||
return item.item;
|
>(
|
||||||
});
|
generator: (
|
||||||
|
input: Input,
|
||||||
// Destroy all items that are no longer being requested
|
) => Generator<{ keys: readonly [...Keys]; data: Data }, void, void>,
|
||||||
for (const [key, { scope }] of state.items)
|
factory: (
|
||||||
if (!nextItems.has(key)) scope.end();
|
scope: ObservableScope,
|
||||||
|
data$: Behavior<Data>,
|
||||||
return { items: nextItems, output };
|
...keys: Keys
|
||||||
},
|
) => Item,
|
||||||
{ items: new Map() },
|
): OperatorFunction<Epoch<Input>, Epoch<Item[]>> {
|
||||||
),
|
return generateItemsInternal(
|
||||||
finalizeValue((state) => {
|
function* (input) {
|
||||||
// Destroy all remaining items when no longer subscribed
|
yield* generator(input.value);
|
||||||
for (const { scope } of state.items.values()) scope.end();
|
},
|
||||||
}),
|
factory,
|
||||||
map(({ output }) => output),
|
(items, input) => new Epoch(items, input.epoch),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateItemsInternal<
|
||||||
|
Input,
|
||||||
|
Keys extends [unknown, ...unknown[]],
|
||||||
|
Data,
|
||||||
|
Item,
|
||||||
|
Output,
|
||||||
|
>(
|
||||||
|
generator: (
|
||||||
|
input: Input,
|
||||||
|
) => Generator<{ keys: readonly [...Keys]; data: Data }, void, void>,
|
||||||
|
factory: (
|
||||||
|
scope: ObservableScope,
|
||||||
|
data$: Behavior<Data>,
|
||||||
|
...keys: Keys
|
||||||
|
) => Item,
|
||||||
|
project: (items: Item[], input: Input) => Output,
|
||||||
|
): OperatorFunction<Input, Output> {
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
return (input$) =>
|
||||||
|
input$.pipe(
|
||||||
|
// Keep track of the existing items over time, so they can persist
|
||||||
|
scan<
|
||||||
|
Input,
|
||||||
|
{
|
||||||
|
map: Map<any, any>;
|
||||||
|
items: Set<ItemHandle<Data, Item>>;
|
||||||
|
input: Input;
|
||||||
|
},
|
||||||
|
{ map: Map<any, any>; items: Set<ItemHandle<Data, Item>> }
|
||||||
|
>(
|
||||||
|
({ map: prevMap, items: prevItems }, input) => {
|
||||||
|
const nextMap = new Map();
|
||||||
|
const nextItems = new Set<ItemHandle<Data, Item>>();
|
||||||
|
|
||||||
|
for (const { keys, data } of generator(input)) {
|
||||||
|
// Disable type checks for a second to grab the item out of a nested map
|
||||||
|
let i: any = prevMap;
|
||||||
|
for (const key of keys) i = i?.get(key);
|
||||||
|
let item = i as ItemHandle<Data, Item> | undefined;
|
||||||
|
|
||||||
|
if (item === undefined) {
|
||||||
|
// First time requesting the key; create the item
|
||||||
|
const scope = new ObservableScope();
|
||||||
|
const data$ = new BehaviorSubject(data);
|
||||||
|
item = { scope, data$, item: factory(scope, data$, ...keys) };
|
||||||
|
} else {
|
||||||
|
item.data$.next(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Likewise, disable type checks to insert the item in the nested map
|
||||||
|
let m: Map<any, any> = nextMap;
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
let inner = m.get(keys[i]);
|
||||||
|
if (inner === undefined) {
|
||||||
|
inner = new Map();
|
||||||
|
m.set(keys[i], inner);
|
||||||
|
}
|
||||||
|
m = inner;
|
||||||
|
}
|
||||||
|
const finalKey = keys[keys.length - 1];
|
||||||
|
if (m.has(finalKey))
|
||||||
|
throw new Error(
|
||||||
|
`Keys must be unique (tried to generate multiple items for key ${keys})`,
|
||||||
|
);
|
||||||
|
m.set(keys[keys.length - 1], item);
|
||||||
|
nextItems.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroy all items that are no longer being requested
|
||||||
|
for (const item of prevItems)
|
||||||
|
if (!nextItems.has(item)) item.scope.end();
|
||||||
|
|
||||||
|
return { map: nextMap, items: nextItems, input };
|
||||||
|
},
|
||||||
|
{ map: new Map(), items: new Set() },
|
||||||
|
),
|
||||||
|
finalizeValue(({ items }) => {
|
||||||
|
// Destroy all remaining items when no longer subscribed
|
||||||
|
for (const { scope } of items) scope.end();
|
||||||
|
}),
|
||||||
|
map(({ items, input }) =>
|
||||||
|
project(
|
||||||
|
[...items].map(({ item }) => item),
|
||||||
|
input,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { BehaviorSubject, of } from "rxjs";
|
import { BehaviorSubject } from "rxjs";
|
||||||
import { vitest } from "vitest";
|
import { vitest } from "vitest";
|
||||||
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
@@ -158,7 +158,7 @@ export function getBasicCallViewModelEnvironment(
|
|||||||
},
|
},
|
||||||
handRaisedSubject$,
|
handRaisedSubject$,
|
||||||
reactionsSubject$,
|
reactionsSubject$,
|
||||||
of({ processor: undefined, supported: false }),
|
constant({ processor: undefined, supported: false }),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
vm,
|
vm,
|
||||||
|
|||||||
@@ -304,18 +304,20 @@ export function createLocalMedia(
|
|||||||
localParticipant: LocalParticipant,
|
localParticipant: LocalParticipant,
|
||||||
mediaDevices: MediaDevices,
|
mediaDevices: MediaDevices,
|
||||||
): LocalUserMediaViewModel {
|
): LocalUserMediaViewModel {
|
||||||
|
const member = mockMatrixRoomMember(localRtcMember, roomMember);
|
||||||
return new LocalUserMediaViewModel(
|
return new LocalUserMediaViewModel(
|
||||||
testScope(),
|
testScope(),
|
||||||
"local",
|
"local",
|
||||||
mockMatrixRoomMember(localRtcMember, roomMember),
|
member.userId,
|
||||||
constant(localParticipant),
|
constant(localParticipant),
|
||||||
{
|
{
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
mockLivekitRoom({ localParticipant }),
|
constant(mockLivekitRoom({ localParticipant })),
|
||||||
"https://rtc-example.org",
|
constant("https://rtc-example.org"),
|
||||||
mediaDevices,
|
mediaDevices,
|
||||||
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
constant(member.rawDisplayName ?? "nodisplayname"),
|
||||||
|
constant(member.getMxcAvatarUrl()),
|
||||||
constant(null),
|
constant(null),
|
||||||
constant(null),
|
constant(null),
|
||||||
);
|
);
|
||||||
@@ -339,19 +341,23 @@ export function createRemoteMedia(
|
|||||||
roomMember: Partial<RoomMember>,
|
roomMember: Partial<RoomMember>,
|
||||||
participant: Partial<RemoteParticipant>,
|
participant: Partial<RemoteParticipant>,
|
||||||
): RemoteUserMediaViewModel {
|
): RemoteUserMediaViewModel {
|
||||||
|
const member = mockMatrixRoomMember(localRtcMember, roomMember);
|
||||||
const remoteParticipant = mockRemoteParticipant(participant);
|
const remoteParticipant = mockRemoteParticipant(participant);
|
||||||
return new RemoteUserMediaViewModel(
|
return new RemoteUserMediaViewModel(
|
||||||
testScope(),
|
testScope(),
|
||||||
"remote",
|
"remote",
|
||||||
mockMatrixRoomMember(localRtcMember, roomMember),
|
member.userId,
|
||||||
of(remoteParticipant),
|
of(remoteParticipant),
|
||||||
{
|
{
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
|
constant(
|
||||||
"https://rtc-example.org",
|
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
|
||||||
|
),
|
||||||
|
constant("https://rtc-example.org"),
|
||||||
constant(false),
|
constant(false),
|
||||||
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
constant(member.rawDisplayName ?? "nodisplayname"),
|
||||||
|
constant(member.getMxcAvatarUrl()),
|
||||||
constant(null),
|
constant(null),
|
||||||
constant(null),
|
constant(null),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user