diff --git a/src/rtcSessionHelpers.ts b/src/rtcSessionHelpers.ts deleted file mode 100644 index a53418f7..00000000 --- a/src/rtcSessionHelpers.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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); - } - } -} diff --git a/src/state/Behavior.ts b/src/state/Behavior.ts index 71b18a55..3c88dc00 100644 --- a/src/state/Behavior.ts +++ b/src/state/Behavior.ts @@ -18,10 +18,6 @@ import { BehaviorSubject } from "rxjs"; */ export type Behavior = Omit, "next" | "observers">; -export type BehaviorWithEpoch = Behavior & { - pipeEpoch(): Behavior<{ value: T; epoch: number }>; -}; - /** * Creates a Behavior which never changes in value. */ diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index b508ff80..76eeaeac 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -109,7 +109,10 @@ import { createReceivedDecline$, createSentCallNotification$, } from "./CallNotificationLifecycle.ts"; -import { createRoomMembers$ } from "./remoteMembers/displayname.ts"; +import { + createMatrixMemberMetadata$, + createRoomMembers$, +} from "./remoteMembers/MatrixMemberMetadata.ts"; const logger = rootLogger.getChild("[CallViewModel]"); //TODO @@ -240,7 +243,6 @@ export class CallViewModel { membershipsWithTransport$: this.membershipsAndTransports.membershipsWithTransport$, connectionManager: this.connectionManager, - matrixRoom: this.matrixRoom, }); private connectOptions$ = this.scope.behavior( @@ -280,11 +282,9 @@ export class CallViewModel { options: this.options, 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). * This is a fatal error that prevents the call from being created/joined. @@ -305,14 +305,6 @@ export class CallViewModel { "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 * 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. */ @@ -400,20 +400,10 @@ export class CallViewModel { userId, participant$, connection$, - displayName$, - mxcAvatarUrl$, } of matrixLivekitMembers) for (let dup = 0; dup < 1 + duplicateTiles; dup++) yield { - keys: [ - dup, - participantId, - userId, - participant$, - connection$, - displayName$, - mxcAvatarUrl$, - ], + keys: [dup, participantId, userId, participant$, connection$], data: undefined, }; }, @@ -425,8 +415,6 @@ export class CallViewModel { userId, participant$, connection$, - displayName$, - mxcAvatarUrl$, ) => { const livekitRoom$ = scope.behavior( connection$.pipe(map((c) => c?.livekitRoom)), @@ -434,6 +422,11 @@ export class CallViewModel { const focusUrl$ = scope.behavior( 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( scope, @@ -446,7 +439,7 @@ export class CallViewModel { this.mediaDevices, this.pretendToBeDisconnected$, displayName$, - mxcAvatarUrl$, + this.matrixMemberMetadataStore.createAvatarUrlBehavior$(userId), this.handsRaised$.pipe(map((v) => v[participantId]?.time ?? null)), this.reactions$.pipe(map((v) => v[participantId] ?? undefined)), ); diff --git a/src/state/CallViewModel/localMember/LocalMembership.ts b/src/state/CallViewModel/localMember/LocalMembership.ts index c6b8b170..df4d3b6b 100644 --- a/src/state/CallViewModel/localMember/LocalMembership.ts +++ b/src/state/CallViewModel/localMember/LocalMembership.ts @@ -43,14 +43,17 @@ import { type MuteStates } from "../../MuteStates"; import { type ProcessorState } from "../../../livekit/TrackProcessorContext"; import { type MediaDevices } from "../../MediaDevices"; import { and$ } from "../../../utils/observable"; -import { - enterRTCSession, - type EnterRTCSessionOptions, -} from "../../../rtcSessionHelpers"; 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 { 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 { Uninitialized = "uninitialized", @@ -535,3 +538,65 @@ export function observeSharingScreen$(p: Participant): Observable { ParticipantEvent.LocalTrackUnpublished, ).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 { + 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); + } + } +} diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index c10201bf..1c436397 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -74,6 +74,12 @@ export class Publisher { this.observeMediaDevices(scope, devices, controlledAudioDevices); this.workaroundRestartAudioInputTrackChrome(devices, scope); + this.scope.onEnd(() => { + this.logger?.info( + "[PublishConnection] Scope ended -> stop publishing all tracks", + ); + void this.stopPublishing(); + }); } /** diff --git a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts index fbfd0563..326eb0f6 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts @@ -13,19 +13,17 @@ import { type LivekitTransport, type CallMembership, } from "matrix-js-sdk/lib/matrixrtc"; -import { combineLatest, filter, fromEvent, map, startWith } from "rxjs"; -// eslint-disable-next-line rxjs/no-internal -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 { combineLatest, filter, map } from "rxjs"; +import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { type Behavior } from "../../Behavior"; import { type IConnectionManager } from "./ConnectionManager"; import { Epoch, type ObservableScope } from "../../ObservableScope"; -import { memberDisplaynames$ } from "./displayname"; import { type Connection } from "./Connection"; import { generateItemsWithEpoch } from "../../../utils/observable"; +const logger = rootLogger.getChild("MatrixLivekitMembers"); + /** * 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 @@ -39,8 +37,6 @@ export interface MatrixLivekitMember { LocalLivekitParticipant | RemoteLivekitParticipant | null >; connection$: Behavior; - displayName$: Behavior; - mxcAvatarUrl$: Behavior; } interface Props { @@ -49,16 +45,7 @@ interface Props { Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]> >; 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 & NodeStyleEventEmitter; - // roomMember$: Behavior>; } -// Alternative structure idea: -// const livekitMatrixMember$ = (callMemberships$,connectionManager,scope): Observable => { - /** * Combines MatrixRTC and Livekit worlds. * @@ -73,22 +60,11 @@ export function createMatrixLivekitMembers$({ scope, membershipsWithTransport$, connectionManager, - matrixRoom, }: Props): Behavior> { /** * 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( combineLatest([ 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. (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. // updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent. return { participantId, userId, ...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()), - ), - ), }; }, ), diff --git a/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts b/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts new file mode 100644 index 00000000..ad603708 --- /dev/null +++ b/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts @@ -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 +>; +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 { + 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 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[]>, + roomMembers$: Behavior, +): Behavior> => { + // 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(); + 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(); + // 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[]>, + roomMembers$: Behavior, +): { + createDisplayNameBehavior$: (userId: string) => Behavior; + createAvatarUrlBehavior$: (userId: string) => Behavior; + displaynameMap$: Behavior>; + avatarMap$: Behavior>; +} => { + 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()), + ), + ), + ); + 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$, + }; +}; diff --git a/src/state/CallViewModel/remoteMembers/displayname.ts b/src/state/CallViewModel/remoteMembers/displayname.ts deleted file mode 100644 index 901f3613..00000000 --- a/src/state/CallViewModel/remoteMembers/displayname.ts +++ /dev/null @@ -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[]> { - 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 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 & NodeStyleEventEmitter, - // roomMember$: Behavior>; - memberships$: Behavior, -): Behavior> => - 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(); - 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; - }), - ), - ); diff --git a/src/utils/displayname.ts b/src/utils/displayname.ts index 1e141255..5ab5de9b 100644 --- a/src/utils/displayname.ts +++ b/src/utils/displayname.ts @@ -10,7 +10,7 @@ import { removeHiddenChars as removeHiddenCharsUncached, } 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"; // 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 export function shouldDisambiguate( member: { rawDisplayName?: string; userId: string }, - memberships: CallMembership[], - room: Pick, + memberships: Pick[], + roomMembers: Map>, ): boolean { const { rawDisplayName: displayName, userId } = member; if (!displayName || displayName === userId) return false; @@ -65,7 +65,7 @@ export function shouldDisambiguate( // displayname, after hidden character removal. return ( 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. .filter((m) => !!m) .filter((m) => m.userId !== userId)