Introduce MatrixMemberMetadata and use it to simplify username and
avatar computation This removes member from the tiles entirely!
This commit is contained in:
@@ -1,160 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2023, 2024 New Vector Ltd.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
||||||
Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
type MatrixRTCSession,
|
|
||||||
isLivekitTransportConfig,
|
|
||||||
type LivekitTransportConfig,
|
|
||||||
type LivekitTransport,
|
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
|
||||||
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
|
|
||||||
import { type MatrixClient } from "matrix-js-sdk";
|
|
||||||
|
|
||||||
import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
|
|
||||||
import { Config } from "./config/Config";
|
|
||||||
import { ElementWidgetActions, widget } from "./widget";
|
|
||||||
import { MatrixRTCTransportMissingError } from "./utils/errors";
|
|
||||||
import { getUrlParams } from "./UrlParams";
|
|
||||||
import { getSFUConfigWithOpenID } from "./livekit/openIDSFU.ts";
|
|
||||||
import { MatrixRTCMode } from "./settings/settings.ts";
|
|
||||||
|
|
||||||
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
|
|
||||||
|
|
||||||
async function makeTransportInternal(
|
|
||||||
client: MatrixClient,
|
|
||||||
roomId: string,
|
|
||||||
): Promise<LivekitTransport> {
|
|
||||||
logger.log("Searching for a preferred transport");
|
|
||||||
//TODO refactor this to use the jwt service returned alias.
|
|
||||||
const livekitAlias = roomId;
|
|
||||||
|
|
||||||
// TODO-MULTI-SFU: Either remove this dev tool or make it more official
|
|
||||||
const urlFromStorage =
|
|
||||||
localStorage.getItem("robin-matrixrtc-auth") ??
|
|
||||||
localStorage.getItem("timo-focus-url");
|
|
||||||
if (urlFromStorage !== null) {
|
|
||||||
const transportFromStorage: LivekitTransport = {
|
|
||||||
type: "livekit",
|
|
||||||
livekit_service_url: urlFromStorage,
|
|
||||||
livekit_alias: livekitAlias,
|
|
||||||
};
|
|
||||||
logger.log(
|
|
||||||
"Using LiveKit transport from local storage: ",
|
|
||||||
transportFromStorage,
|
|
||||||
);
|
|
||||||
return transportFromStorage;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prioritize the .well-known/matrix/client, if available, over the configured SFU
|
|
||||||
const domain = client.getDomain();
|
|
||||||
if (domain) {
|
|
||||||
// we use AutoDiscovery instead of relying on the MatrixClient having already
|
|
||||||
// been fully configured and started
|
|
||||||
const wellKnownFoci = (await AutoDiscovery.getRawClientConfig(domain))?.[
|
|
||||||
FOCI_WK_KEY
|
|
||||||
];
|
|
||||||
if (Array.isArray(wellKnownFoci)) {
|
|
||||||
const transport: LivekitTransportConfig | undefined = wellKnownFoci.find(
|
|
||||||
(f) => f && isLivekitTransportConfig(f),
|
|
||||||
);
|
|
||||||
if (transport !== undefined) {
|
|
||||||
logger.log("Using LiveKit transport from .well-known: ", transport);
|
|
||||||
return { ...transport, livekit_alias: livekitAlias };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlFromConf = Config.get().livekit?.livekit_service_url;
|
|
||||||
if (urlFromConf) {
|
|
||||||
const transportFromConf: LivekitTransport = {
|
|
||||||
type: "livekit",
|
|
||||||
livekit_service_url: urlFromConf,
|
|
||||||
livekit_alias: livekitAlias,
|
|
||||||
};
|
|
||||||
logger.log("Using LiveKit transport from config: ", transportFromConf);
|
|
||||||
return transportFromConf;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new MatrixRTCTransportMissingError(domain ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function makeTransport(
|
|
||||||
client: MatrixClient,
|
|
||||||
roomId: string,
|
|
||||||
): Promise<LivekitTransport> {
|
|
||||||
const transport = await makeTransportInternal(client, roomId);
|
|
||||||
// this will call the jwt/sfu/get endpoint to pre create the livekit room.
|
|
||||||
await getSFUConfigWithOpenID(
|
|
||||||
client,
|
|
||||||
transport.livekit_service_url,
|
|
||||||
transport.livekit_alias,
|
|
||||||
);
|
|
||||||
return transport;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnterRTCSessionOptions {
|
|
||||||
encryptMedia: boolean;
|
|
||||||
matrixRTCMode: MatrixRTCMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO! document this function properly
|
|
||||||
* @param rtcSession
|
|
||||||
* @param transport
|
|
||||||
* @param options
|
|
||||||
*/
|
|
||||||
export async function enterRTCSession(
|
|
||||||
rtcSession: MatrixRTCSession,
|
|
||||||
transport: LivekitTransport,
|
|
||||||
{ encryptMedia, matrixRTCMode }: EnterRTCSessionOptions,
|
|
||||||
): Promise<void> {
|
|
||||||
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
|
|
||||||
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
|
|
||||||
|
|
||||||
// This must be called before we start trying to join the call, as we need to
|
|
||||||
// have started tracking by the time calls start getting created.
|
|
||||||
// groupCallOTelMembership?.onJoinCall();
|
|
||||||
|
|
||||||
const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
|
|
||||||
const useDeviceSessionMemberEvents =
|
|
||||||
features?.feature_use_device_session_member_events;
|
|
||||||
const { sendNotificationType: notificationType, callIntent } = getUrlParams();
|
|
||||||
const multiSFU = matrixRTCMode !== MatrixRTCMode.Legacy;
|
|
||||||
// Multi-sfu does not need a preferred foci list. just the focus that is actually used.
|
|
||||||
rtcSession.joinRoomSession(
|
|
||||||
multiSFU ? [] : [transport],
|
|
||||||
multiSFU ? transport : undefined,
|
|
||||||
{
|
|
||||||
notificationType,
|
|
||||||
callIntent,
|
|
||||||
manageMediaKeys: encryptMedia,
|
|
||||||
...(useDeviceSessionMemberEvents !== undefined && {
|
|
||||||
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
|
|
||||||
}),
|
|
||||||
delayedLeaveEventRestartMs:
|
|
||||||
matrixRtcSessionConfig?.delayed_leave_event_restart_ms,
|
|
||||||
delayedLeaveEventDelayMs:
|
|
||||||
matrixRtcSessionConfig?.delayed_leave_event_delay_ms,
|
|
||||||
delayedLeaveEventRestartLocalTimeoutMs:
|
|
||||||
matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms,
|
|
||||||
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
|
|
||||||
makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms,
|
|
||||||
membershipEventExpiryMs:
|
|
||||||
matrixRtcSessionConfig?.membership_event_expiry_ms,
|
|
||||||
useExperimentalToDeviceTransport: true,
|
|
||||||
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (widget) {
|
|
||||||
try {
|
|
||||||
await widget.api.transport.send(ElementWidgetActions.JoinCall, {});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("Failed to send join action", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,10 +18,6 @@ import { BehaviorSubject } from "rxjs";
|
|||||||
*/
|
*/
|
||||||
export type Behavior<T> = Omit<BehaviorSubject<T>, "next" | "observers">;
|
export type Behavior<T> = Omit<BehaviorSubject<T>, "next" | "observers">;
|
||||||
|
|
||||||
export type BehaviorWithEpoch<T> = Behavior<T> & {
|
|
||||||
pipeEpoch(): Behavior<{ value: T; epoch: number }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a Behavior which never changes in value.
|
* Creates a Behavior which never changes in value.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -109,7 +109,10 @@ import {
|
|||||||
createReceivedDecline$,
|
createReceivedDecline$,
|
||||||
createSentCallNotification$,
|
createSentCallNotification$,
|
||||||
} from "./CallNotificationLifecycle.ts";
|
} from "./CallNotificationLifecycle.ts";
|
||||||
import { createRoomMembers$ } from "./remoteMembers/displayname.ts";
|
import {
|
||||||
|
createMatrixMemberMetadata$,
|
||||||
|
createRoomMembers$,
|
||||||
|
} from "./remoteMembers/MatrixMemberMetadata.ts";
|
||||||
|
|
||||||
const logger = rootLogger.getChild("[CallViewModel]");
|
const logger = rootLogger.getChild("[CallViewModel]");
|
||||||
//TODO
|
//TODO
|
||||||
@@ -240,7 +243,6 @@ export class CallViewModel {
|
|||||||
membershipsWithTransport$:
|
membershipsWithTransport$:
|
||||||
this.membershipsAndTransports.membershipsWithTransport$,
|
this.membershipsAndTransports.membershipsWithTransport$,
|
||||||
connectionManager: this.connectionManager,
|
connectionManager: this.connectionManager,
|
||||||
matrixRoom: this.matrixRoom,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
private connectOptions$ = this.scope.behavior(
|
private connectOptions$ = this.scope.behavior(
|
||||||
@@ -280,11 +282,9 @@ export class CallViewModel {
|
|||||||
options: this.options,
|
options: this.options,
|
||||||
localUser: { userId: this.userId, deviceId: this.deviceId },
|
localUser: { userId: this.userId, deviceId: this.deviceId },
|
||||||
});
|
});
|
||||||
|
public autoLeave$ = this.callLifecycle.autoLeave$;
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
// ROOM MEMBER tracking TODO
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
private roomMembers$ = createRoomMembers$(this.scope, this.matrixRoom);
|
|
||||||
/**
|
/**
|
||||||
* If there is a configuration error with the call (e.g. misconfigured E2EE).
|
* If there is a configuration error with the call (e.g. misconfigured E2EE).
|
||||||
* This is a fatal error that prevents the call from being created/joined.
|
* This is a fatal error that prevents the call from being created/joined.
|
||||||
@@ -305,14 +305,6 @@ export class CallViewModel {
|
|||||||
"user" | "timeout" | "decline" | "allOthersLeft"
|
"user" | "timeout" | "decline" | "allOthersLeft"
|
||||||
>();
|
>();
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether we are joined to the call. This reflects our local state rather
|
|
||||||
* than whether all connections are truly up and running.
|
|
||||||
*/
|
|
||||||
// DISCUSS ? lets think why we need joined and how to do it better
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
private readonly joined$ = this.localMembership.connected$;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether various media/event sources should pretend to be disconnected from
|
* Whether various media/event sources should pretend to be disconnected from
|
||||||
* all network input, even if their connection still technically works.
|
* all network input, even if their connection still technically works.
|
||||||
@@ -385,6 +377,14 @@ export class CallViewModel {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private roomMembers$ = createRoomMembers$(this.scope, this.matrixRoom);
|
||||||
|
|
||||||
|
private matrixMemberMetadataStore = createMatrixMemberMetadata$(
|
||||||
|
this.scope,
|
||||||
|
this.scope.behavior(this.memberships$.pipe(map((mems) => mems.value))),
|
||||||
|
this.roomMembers$,
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of user media (camera feeds) that we want tiles for.
|
* List of user media (camera feeds) that we want tiles for.
|
||||||
*/
|
*/
|
||||||
@@ -400,20 +400,10 @@ export class CallViewModel {
|
|||||||
userId,
|
userId,
|
||||||
participant$,
|
participant$,
|
||||||
connection$,
|
connection$,
|
||||||
displayName$,
|
|
||||||
mxcAvatarUrl$,
|
|
||||||
} of matrixLivekitMembers)
|
} of matrixLivekitMembers)
|
||||||
for (let dup = 0; dup < 1 + duplicateTiles; dup++)
|
for (let dup = 0; dup < 1 + duplicateTiles; dup++)
|
||||||
yield {
|
yield {
|
||||||
keys: [
|
keys: [dup, participantId, userId, participant$, connection$],
|
||||||
dup,
|
|
||||||
participantId,
|
|
||||||
userId,
|
|
||||||
participant$,
|
|
||||||
connection$,
|
|
||||||
displayName$,
|
|
||||||
mxcAvatarUrl$,
|
|
||||||
],
|
|
||||||
data: undefined,
|
data: undefined,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -425,8 +415,6 @@ export class CallViewModel {
|
|||||||
userId,
|
userId,
|
||||||
participant$,
|
participant$,
|
||||||
connection$,
|
connection$,
|
||||||
displayName$,
|
|
||||||
mxcAvatarUrl$,
|
|
||||||
) => {
|
) => {
|
||||||
const livekitRoom$ = scope.behavior(
|
const livekitRoom$ = scope.behavior(
|
||||||
connection$.pipe(map((c) => c?.livekitRoom)),
|
connection$.pipe(map((c) => c?.livekitRoom)),
|
||||||
@@ -434,6 +422,11 @@ export class CallViewModel {
|
|||||||
const focusUrl$ = scope.behavior(
|
const focusUrl$ = scope.behavior(
|
||||||
connection$.pipe(map((c) => c?.transport.livekit_service_url)),
|
connection$.pipe(map((c) => c?.transport.livekit_service_url)),
|
||||||
);
|
);
|
||||||
|
const displayName$ = scope.behavior(
|
||||||
|
this.matrixMemberMetadataStore
|
||||||
|
.createDisplayNameBehavior$(userId)
|
||||||
|
.pipe(map((name) => name ?? userId)),
|
||||||
|
);
|
||||||
|
|
||||||
return new UserMedia(
|
return new UserMedia(
|
||||||
scope,
|
scope,
|
||||||
@@ -446,7 +439,7 @@ export class CallViewModel {
|
|||||||
this.mediaDevices,
|
this.mediaDevices,
|
||||||
this.pretendToBeDisconnected$,
|
this.pretendToBeDisconnected$,
|
||||||
displayName$,
|
displayName$,
|
||||||
mxcAvatarUrl$,
|
this.matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
|
||||||
this.handsRaised$.pipe(map((v) => v[participantId]?.time ?? null)),
|
this.handsRaised$.pipe(map((v) => v[participantId]?.time ?? null)),
|
||||||
this.reactions$.pipe(map((v) => v[participantId] ?? undefined)),
|
this.reactions$.pipe(map((v) => v[participantId] ?? undefined)),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,14 +43,17 @@ import { type MuteStates } from "../../MuteStates";
|
|||||||
import { type ProcessorState } from "../../../livekit/TrackProcessorContext";
|
import { type ProcessorState } from "../../../livekit/TrackProcessorContext";
|
||||||
import { type MediaDevices } from "../../MediaDevices";
|
import { type MediaDevices } from "../../MediaDevices";
|
||||||
import { and$ } from "../../../utils/observable";
|
import { and$ } from "../../../utils/observable";
|
||||||
import {
|
|
||||||
enterRTCSession,
|
|
||||||
type EnterRTCSessionOptions,
|
|
||||||
} from "../../../rtcSessionHelpers";
|
|
||||||
import { type ElementCallError } from "../../../utils/errors";
|
import { type ElementCallError } from "../../../utils/errors";
|
||||||
import { ElementWidgetActions, type WidgetHelpers } from "../../../widget";
|
import {
|
||||||
|
ElementWidgetActions,
|
||||||
|
widget,
|
||||||
|
type WidgetHelpers,
|
||||||
|
} from "../../../widget";
|
||||||
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers";
|
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers";
|
||||||
import { getUrlParams } from "../../../UrlParams.ts";
|
import { getUrlParams } from "../../../UrlParams.ts";
|
||||||
|
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
|
||||||
|
import { MatrixRTCMode } from "../../../settings/settings.ts";
|
||||||
|
import { Config } from "../../../config/Config.ts";
|
||||||
|
|
||||||
export enum LivekitState {
|
export enum LivekitState {
|
||||||
Uninitialized = "uninitialized",
|
Uninitialized = "uninitialized",
|
||||||
@@ -535,3 +538,65 @@ export function observeSharingScreen$(p: Participant): Observable<boolean> {
|
|||||||
ParticipantEvent.LocalTrackUnpublished,
|
ParticipantEvent.LocalTrackUnpublished,
|
||||||
).pipe(map((p) => p.isScreenShareEnabled));
|
).pipe(map((p) => p.isScreenShareEnabled));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EnterRTCSessionOptions {
|
||||||
|
encryptMedia: boolean;
|
||||||
|
matrixRTCMode: MatrixRTCMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO! document this function properly
|
||||||
|
* @param rtcSession
|
||||||
|
* @param transport
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
async function enterRTCSession(
|
||||||
|
rtcSession: MatrixRTCSession,
|
||||||
|
transport: LivekitTransport,
|
||||||
|
{ encryptMedia, matrixRTCMode }: EnterRTCSessionOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
|
||||||
|
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
|
||||||
|
|
||||||
|
// This must be called before we start trying to join the call, as we need to
|
||||||
|
// have started tracking by the time calls start getting created.
|
||||||
|
// groupCallOTelMembership?.onJoinCall();
|
||||||
|
|
||||||
|
const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
|
||||||
|
const useDeviceSessionMemberEvents =
|
||||||
|
features?.feature_use_device_session_member_events;
|
||||||
|
const { sendNotificationType: notificationType, callIntent } = getUrlParams();
|
||||||
|
const multiSFU = matrixRTCMode !== MatrixRTCMode.Legacy;
|
||||||
|
// Multi-sfu does not need a preferred foci list. just the focus that is actually used.
|
||||||
|
rtcSession.joinRoomSession(
|
||||||
|
multiSFU ? [] : [transport],
|
||||||
|
multiSFU ? transport : undefined,
|
||||||
|
{
|
||||||
|
notificationType,
|
||||||
|
callIntent,
|
||||||
|
manageMediaKeys: encryptMedia,
|
||||||
|
...(useDeviceSessionMemberEvents !== undefined && {
|
||||||
|
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
|
||||||
|
}),
|
||||||
|
delayedLeaveEventRestartMs:
|
||||||
|
matrixRtcSessionConfig?.delayed_leave_event_restart_ms,
|
||||||
|
delayedLeaveEventDelayMs:
|
||||||
|
matrixRtcSessionConfig?.delayed_leave_event_delay_ms,
|
||||||
|
delayedLeaveEventRestartLocalTimeoutMs:
|
||||||
|
matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms,
|
||||||
|
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
|
||||||
|
makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms,
|
||||||
|
membershipEventExpiryMs:
|
||||||
|
matrixRtcSessionConfig?.membership_event_expiry_ms,
|
||||||
|
useExperimentalToDeviceTransport: true,
|
||||||
|
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (widget) {
|
||||||
|
try {
|
||||||
|
await widget.api.transport.send(ElementWidgetActions.JoinCall, {});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to send join action", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ export class Publisher {
|
|||||||
this.observeMediaDevices(scope, devices, controlledAudioDevices);
|
this.observeMediaDevices(scope, devices, controlledAudioDevices);
|
||||||
|
|
||||||
this.workaroundRestartAudioInputTrackChrome(devices, scope);
|
this.workaroundRestartAudioInputTrackChrome(devices, scope);
|
||||||
|
this.scope.onEnd(() => {
|
||||||
|
this.logger?.info(
|
||||||
|
"[PublishConnection] Scope ended -> stop publishing all tracks",
|
||||||
|
);
|
||||||
|
void this.stopPublishing();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,19 +13,17 @@ import {
|
|||||||
type LivekitTransport,
|
type LivekitTransport,
|
||||||
type CallMembership,
|
type CallMembership,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { combineLatest, filter, fromEvent, map, startWith } from "rxjs";
|
import { combineLatest, filter, map } from "rxjs";
|
||||||
// eslint-disable-next-line rxjs/no-internal
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
import { type NodeStyleEventEmitter } from "rxjs/internal/observable/fromEvent";
|
|
||||||
import { RoomStateEvent, 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, type ObservableScope } from "../../ObservableScope";
|
import { Epoch, type ObservableScope } from "../../ObservableScope";
|
||||||
import { memberDisplaynames$ } from "./displayname";
|
|
||||||
import { type Connection } from "./Connection";
|
import { type Connection } from "./Connection";
|
||||||
import { generateItemsWithEpoch } from "../../../utils/observable";
|
import { generateItemsWithEpoch } from "../../../utils/observable";
|
||||||
|
|
||||||
|
const logger = rootLogger.getChild("MatrixLivekitMembers");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a Matrix call member and their 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
|
||||||
@@ -39,8 +37,6 @@ export interface MatrixLivekitMember {
|
|||||||
LocalLivekitParticipant | RemoteLivekitParticipant | null
|
LocalLivekitParticipant | RemoteLivekitParticipant | null
|
||||||
>;
|
>;
|
||||||
connection$: Behavior<Connection | undefined>;
|
connection$: Behavior<Connection | undefined>;
|
||||||
displayName$: Behavior<string>;
|
|
||||||
mxcAvatarUrl$: Behavior<string | undefined>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -49,16 +45,7 @@ interface Props {
|
|||||||
Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]>
|
Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]>
|
||||||
>;
|
>;
|
||||||
connectionManager: IConnectionManager;
|
connectionManager: IConnectionManager;
|
||||||
// TODO this is too much information for that class,
|
|
||||||
// apparently needed to get a room member to later get the Avatar
|
|
||||||
// => Extract an AvatarService instead?
|
|
||||||
// Better with just `getMember`
|
|
||||||
matrixRoom: Pick<MatrixRoom, "getMember"> & NodeStyleEventEmitter;
|
|
||||||
// roomMember$: Behavior<Pick<RoomMember, "userId" | "getMxcAvatarUrl">>;
|
|
||||||
}
|
}
|
||||||
// Alternative structure idea:
|
|
||||||
// const livekitMatrixMember$ = (callMemberships$,connectionManager,scope): Observable<MatrixLivekitMember[]> => {
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Combines MatrixRTC and Livekit worlds.
|
* Combines MatrixRTC and Livekit worlds.
|
||||||
*
|
*
|
||||||
@@ -73,22 +60,11 @@ export function createMatrixLivekitMembers$({
|
|||||||
scope,
|
scope,
|
||||||
membershipsWithTransport$,
|
membershipsWithTransport$,
|
||||||
connectionManager,
|
connectionManager,
|
||||||
matrixRoom,
|
|
||||||
}: Props): Behavior<Epoch<MatrixLivekitMember[]>> {
|
}: Props): Behavior<Epoch<MatrixLivekitMember[]>> {
|
||||||
/**
|
/**
|
||||||
* Stream of all the call members and their associated livekit data (if available).
|
* Stream of all the call members and their associated livekit data (if available).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const displaynameMap$ = memberDisplaynames$(
|
|
||||||
scope,
|
|
||||||
matrixRoom,
|
|
||||||
scope.behavior(
|
|
||||||
membershipsWithTransport$.pipe(
|
|
||||||
map((ms) => ms.value.map((m) => m.membership)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return scope.behavior(
|
return scope.behavior(
|
||||||
combineLatest([
|
combineLatest([
|
||||||
membershipsWithTransport$,
|
membershipsWithTransport$,
|
||||||
@@ -130,29 +106,15 @@ export function createMatrixLivekitMembers$({
|
|||||||
},
|
},
|
||||||
// Each update where the key of the generator array do not change will result in updates to the `data$` observable in the factory.
|
// Each update where the key of the generator array do not change will result in updates to the `data$` observable in the factory.
|
||||||
(scope, data$, participantId, userId) => {
|
(scope, data$, participantId, userId) => {
|
||||||
const member = matrixRoom.getMember(userId);
|
logger.debug(
|
||||||
|
`Updating data$ for participantId: ${participantId}, userId: ${userId}`,
|
||||||
|
);
|
||||||
// will only get called once per `participantId, userId` pair.
|
// will only get called once per `participantId, userId` pair.
|
||||||
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
|
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
|
||||||
return {
|
return {
|
||||||
participantId,
|
participantId,
|
||||||
userId,
|
userId,
|
||||||
...scope.splitBehavior(data$),
|
...scope.splitBehavior(data$),
|
||||||
displayName$: scope.behavior(
|
|
||||||
displaynameMap$.pipe(
|
|
||||||
map((displayNames) => {
|
|
||||||
const name = displayNames.get(userId) ?? "";
|
|
||||||
if (name === "")
|
|
||||||
logger.warn(`No display name for user ${userId}`);
|
|
||||||
return name;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
mxcAvatarUrl$: scope.behavior(
|
|
||||||
fromEvent(matrixRoom, RoomStateEvent.Members).pipe(
|
|
||||||
startWith(undefined),
|
|
||||||
map(() => member?.getMxcAvatarUrl()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
148
src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts
Normal file
148
src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type RoomMember, RoomStateEvent } from "matrix-js-sdk";
|
||||||
|
import { combineLatest, fromEvent, map } from "rxjs";
|
||||||
|
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { type Room as MatrixRoom } from "matrix-js-sdk/lib/matrix";
|
||||||
|
// eslint-disable-next-line rxjs/no-internal
|
||||||
|
|
||||||
|
import { type ObservableScope } from "../../ObservableScope";
|
||||||
|
import {
|
||||||
|
calculateDisplayName,
|
||||||
|
shouldDisambiguate,
|
||||||
|
} from "../../../utils/displayname";
|
||||||
|
import { type Behavior } from "../../Behavior";
|
||||||
|
|
||||||
|
const logger = rootLogger.getChild("[MatrixMemberMetadata]");
|
||||||
|
|
||||||
|
export type RoomMemberMap = Map<
|
||||||
|
string,
|
||||||
|
Pick<RoomMember, "userId" | "getMxcAvatarUrl" | "rawDisplayName">
|
||||||
|
>;
|
||||||
|
export function roomToMembersMap(matrixRoom: MatrixRoom): RoomMemberMap {
|
||||||
|
return matrixRoom.getMembers().reduce((acc, member) => {
|
||||||
|
acc.set(member.userId, {
|
||||||
|
userId: member.userId,
|
||||||
|
getMxcAvatarUrl: member.getMxcAvatarUrl.bind(member),
|
||||||
|
rawDisplayName: member.rawDisplayName,
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, new Map());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRoomMembers$(
|
||||||
|
scope: ObservableScope,
|
||||||
|
matrixRoom: MatrixRoom,
|
||||||
|
): Behavior<RoomMemberMap> {
|
||||||
|
return scope.behavior(
|
||||||
|
fromEvent(matrixRoom, RoomStateEvent.Members).pipe(
|
||||||
|
map(() => roomToMembersMap(matrixRoom)),
|
||||||
|
),
|
||||||
|
roomToMembersMap(matrixRoom),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Displayname for each member of the call. This will disambiguate
|
||||||
|
* any displayname that clashes with another member. Only members
|
||||||
|
* joined to the call are considered here.
|
||||||
|
*
|
||||||
|
* @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:
|
||||||
|
export const memberDisplaynames$ = (
|
||||||
|
scope: ObservableScope,
|
||||||
|
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
|
||||||
|
roomMembers$: Behavior<RoomMemberMap>,
|
||||||
|
): Behavior<Map<string, string>> => {
|
||||||
|
// This map tracks userIds that at some point needed disambiguation.
|
||||||
|
// This is a memory leak bound to the number of participants.
|
||||||
|
// A call application will always increase the memory if there have been more members in a call.
|
||||||
|
// Its capped by room member participants.
|
||||||
|
const shouldDisambiguateTrackerMap = new Set<string>();
|
||||||
|
return scope.behavior(
|
||||||
|
combineLatest([
|
||||||
|
// Handle call membership changes
|
||||||
|
memberships$,
|
||||||
|
// Additionally handle display name changes (implicitly reacting to them)
|
||||||
|
roomMembers$,
|
||||||
|
// TODO: do we need: pauseWhen(this.pretendToBeDisconnected$),
|
||||||
|
]).pipe(
|
||||||
|
map(([memberships, roomMembers]) => {
|
||||||
|
const displaynameMap = new Map<string, string>();
|
||||||
|
// We only consider RTC members for disambiguation as they are the only visible members.
|
||||||
|
for (const rtcMember of memberships) {
|
||||||
|
const member = roomMembers.get(rtcMember.userId);
|
||||||
|
if (!member) {
|
||||||
|
logger.error(`Could not find member for user ${rtcMember.userId}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const disambiguateComputed = shouldDisambiguate(
|
||||||
|
member,
|
||||||
|
memberships,
|
||||||
|
roomMembers,
|
||||||
|
);
|
||||||
|
|
||||||
|
const disambiguate =
|
||||||
|
shouldDisambiguateTrackerMap.has(rtcMember.userId) ||
|
||||||
|
disambiguateComputed;
|
||||||
|
if (disambiguate) shouldDisambiguateTrackerMap.add(rtcMember.userId);
|
||||||
|
displaynameMap.set(
|
||||||
|
rtcMember.userId,
|
||||||
|
calculateDisplayName(member, disambiguate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return displaynameMap;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMatrixMemberMetadata$ = (
|
||||||
|
scope: ObservableScope,
|
||||||
|
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
|
||||||
|
roomMembers$: Behavior<RoomMemberMap>,
|
||||||
|
): {
|
||||||
|
createDisplayNameBehavior$: (userId: string) => Behavior<string | undefined>;
|
||||||
|
createAvatarUrlBehavior$: (userId: string) => Behavior<string | undefined>;
|
||||||
|
displaynameMap$: Behavior<Map<string, string>>;
|
||||||
|
avatarMap$: Behavior<Map<string, string | undefined>>;
|
||||||
|
} => {
|
||||||
|
const displaynameMap$ = memberDisplaynames$(
|
||||||
|
scope,
|
||||||
|
memberships$,
|
||||||
|
roomMembers$,
|
||||||
|
);
|
||||||
|
const avatarMap$ = scope.behavior(
|
||||||
|
roomMembers$.pipe(
|
||||||
|
map((roomMembers) =>
|
||||||
|
Array.from(roomMembers.keys()).reduce((acc, key) => {
|
||||||
|
acc.set(key, roomMembers.get(key)?.getMxcAvatarUrl());
|
||||||
|
return acc;
|
||||||
|
}, new Map<string, string | undefined>()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
createDisplayNameBehavior$: (userId: string) =>
|
||||||
|
scope.behavior(
|
||||||
|
displaynameMap$.pipe(
|
||||||
|
map((displaynameMap) => displaynameMap.get(userId)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
createAvatarUrlBehavior$: (userId: string) =>
|
||||||
|
scope.behavior(
|
||||||
|
roomMembers$.pipe(
|
||||||
|
map((roomMembers) => roomMembers.get(userId)?.getMxcAvatarUrl()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// mostly for testing purposes
|
||||||
|
displaynameMap$,
|
||||||
|
avatarMap$,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2025 New Vector Ltd.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
||||||
Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { type RoomMember, RoomStateEvent } from "matrix-js-sdk";
|
|
||||||
import { combineLatest, fromEvent, map, startWith } from "rxjs";
|
|
||||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
|
||||||
import { type Room as MatrixRoom } from "matrix-js-sdk/lib/matrix";
|
|
||||||
// eslint-disable-next-line rxjs/no-internal
|
|
||||||
import { type NodeStyleEventEmitter } from "rxjs/internal/observable/fromEvent";
|
|
||||||
|
|
||||||
import { type ObservableScope } from "../../ObservableScope";
|
|
||||||
import {
|
|
||||||
calculateDisplayName,
|
|
||||||
shouldDisambiguate,
|
|
||||||
} from "../../../utils/displayname";
|
|
||||||
import { type Behavior } from "../../Behavior";
|
|
||||||
|
|
||||||
export function createRoomMembers$(
|
|
||||||
scope: ObservableScope,
|
|
||||||
matrixRoom: MatrixRoom,
|
|
||||||
): Behavior<Pick<RoomMember, "userId" | "getMxcAvatarUrl">[]> {
|
|
||||||
return scope.behavior(
|
|
||||||
fromEvent(matrixRoom, RoomStateEvent.Members).pipe(
|
|
||||||
map(() => matrixRoom.getMembers()),
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Displayname for each member of the call. This will disambiguate
|
|
||||||
* any displayname that clashes with another member. Only members
|
|
||||||
* joined to the call are considered here.
|
|
||||||
*
|
|
||||||
* @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:
|
|
||||||
export const memberDisplaynames$ = (
|
|
||||||
scope: ObservableScope,
|
|
||||||
matrixRoom: Pick<MatrixRoom, "getMember"> & NodeStyleEventEmitter,
|
|
||||||
// roomMember$: Behavior<Pick<RoomMember, "userId" | "getMxcAvatarUrl">>;
|
|
||||||
memberships$: Behavior<CallMembership[]>,
|
|
||||||
): Behavior<Map<string, string>> =>
|
|
||||||
scope.behavior(
|
|
||||||
combineLatest([
|
|
||||||
// Handle call membership changes
|
|
||||||
memberships$,
|
|
||||||
// Additionally handle display name changes (implicitly reacting to them)
|
|
||||||
fromEvent(matrixRoom, RoomStateEvent.Members).pipe(startWith(null)),
|
|
||||||
// TODO: do we need: pauseWhen(this.pretendToBeDisconnected$),
|
|
||||||
]).pipe(
|
|
||||||
map(([memberships, _displayNames]) => {
|
|
||||||
const displaynameMap = new Map<string, string>();
|
|
||||||
const room = matrixRoom;
|
|
||||||
|
|
||||||
// We only consider RTC members for disambiguation as they are the only visible members.
|
|
||||||
for (const rtcMember of memberships) {
|
|
||||||
const member = room.getMember(rtcMember.userId);
|
|
||||||
if (member === null) {
|
|
||||||
logger.error(`Could not find member for user ${rtcMember.userId}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const disambiguate = shouldDisambiguate(member, memberships, room);
|
|
||||||
displaynameMap.set(
|
|
||||||
rtcMember.userId,
|
|
||||||
calculateDisplayName(member, disambiguate),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return displaynameMap;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
removeHiddenChars as removeHiddenCharsUncached,
|
removeHiddenChars as removeHiddenCharsUncached,
|
||||||
} from "matrix-js-sdk/lib/utils";
|
} from "matrix-js-sdk/lib/utils";
|
||||||
|
|
||||||
import type { Room } from "matrix-js-sdk";
|
import type { RoomMember } from "matrix-js-sdk";
|
||||||
import type { CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
import type { CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
// Calling removeHiddenChars() can be slow on Safari, so we cache the results.
|
// Calling removeHiddenChars() can be slow on Safari, so we cache the results.
|
||||||
@@ -40,8 +40,8 @@ function removeHiddenChars(str: string): string {
|
|||||||
// Borrowed from https://github.com/matrix-org/matrix-js-sdk/blob/f10deb5ef2e8f061ff005af0476034382ea128ca/src/models/room-member.ts#L409
|
// Borrowed from https://github.com/matrix-org/matrix-js-sdk/blob/f10deb5ef2e8f061ff005af0476034382ea128ca/src/models/room-member.ts#L409
|
||||||
export function shouldDisambiguate(
|
export function shouldDisambiguate(
|
||||||
member: { rawDisplayName?: string; userId: string },
|
member: { rawDisplayName?: string; userId: string },
|
||||||
memberships: CallMembership[],
|
memberships: Pick<CallMembership, "userId">[],
|
||||||
room: Pick<Room, "getMember">,
|
roomMembers: Map<string, Pick<RoomMember, "userId">>,
|
||||||
): boolean {
|
): boolean {
|
||||||
const { rawDisplayName: displayName, userId } = member;
|
const { rawDisplayName: displayName, userId } = member;
|
||||||
if (!displayName || displayName === userId) return false;
|
if (!displayName || displayName === userId) return false;
|
||||||
@@ -65,7 +65,7 @@ export function shouldDisambiguate(
|
|||||||
// displayname, after hidden character removal.
|
// displayname, after hidden character removal.
|
||||||
return (
|
return (
|
||||||
memberships
|
memberships
|
||||||
.map((m) => m.userId && room.getMember(m.userId))
|
.map((m) => m.userId && roomMembers.get(m.userId))
|
||||||
// NOTE: We *should* have a room member for everyone.
|
// NOTE: We *should* have a room member for everyone.
|
||||||
.filter((m) => !!m)
|
.filter((m) => !!m)
|
||||||
.filter((m) => m.userId !== userId)
|
.filter((m) => m.userId !== userId)
|
||||||
|
|||||||
Reference in New Issue
Block a user