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:
Robin
2025-11-07 17:36:16 -05:00
parent 1f386a1d57
commit b4c17ed26d
18 changed files with 610 additions and 441 deletions

View File

@@ -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(

View File

@@ -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));
}

View File

@@ -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)); },
}); ),
});
},
), ),
); );

View File

@@ -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([]),
); );
} }

View File

@@ -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)"],
]), ]),
}); });
}); });

View File

@@ -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,
};
}

View File

@@ -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$,
); );
} }
} }

View File

@@ -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>;
}
} }
/** /**

View File

@@ -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,
); );
} }

View File

@@ -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

View File

@@ -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));
} }

View File

@@ -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}
/> />
); );

View File

@@ -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" }}
/> />

View File

@@ -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,

View File

@@ -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);

View File

@@ -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 */
}

View File

@@ -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,

View File

@@ -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),
); );