From cb0490e3f031a45ede05ce53e5cc92d16c4b6f51 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 14 Sep 2026 10:30:45 +0200 Subject: [PATCH] Render tiles for unmappable LiveKit identities --- locales/en/app.json | 1 + src/state/CallViewModel/CallViewModel.test.ts | 150 +++++++++++++++++- src/state/CallViewModel/CallViewModel.ts | 111 ++++++++++--- src/state/TileStore.ts | 25 +-- src/state/TileViewModel.ts | 10 +- src/state/layout-types.ts | 11 +- .../media/UnknownParticipantMediaViewModel.ts | 57 +++++++ src/tile/GridTile.stories.tsx | 98 ++++++++++++ src/tile/GridTile.test.tsx | 31 ++++ src/tile/GridTile.tsx | 48 ++++++ src/tile/MediaView.tsx | 26 +-- 11 files changed, 515 insertions(+), 53 deletions(-) create mode 100644 src/state/media/UnknownParticipantMediaViewModel.ts create mode 100644 src/tile/GridTile.stories.tsx diff --git a/locales/en/app.json b/locales/en/app.json index f3d568bb8..415d7109a 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -273,6 +273,7 @@ "mute_for_me": "Mute for me", "muted_for_me": "Muted for me", "screen_share_volume": "Screen share volume", + "unknown_participant": "Unknown participant", "volume": "Volume", "waiting_for_media": "Waiting for media..." } diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index c7783d152..83aa42357 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -52,8 +52,10 @@ import { aliceRtcMember, aliceUserId, bob, + bobDeviceId, bobId, bobRtcMember, + bobUserId, local, localId, localRtcMember, @@ -118,6 +120,19 @@ const daveId = `${dave.userId}:${daveRtcMember.deviceId}`; const bobParticipant = mockRemoteParticipant({ identity: bobId }); const daveParticipant = mockRemoteParticipant({ identity: daveId }); +// A LiveKit participant that no MatrixRTC membership accounts for +const rogueParticipant = mockRemoteParticipant({ identity: "rogue" }); +const rogueId = `unknown:${exampleTransport.livekit_service_url}:rogue`; + +const otherTransport: LivekitTransport = { + type: "livekit", + livekit_service_url: "https://lk.other.example.org", + livekit_alias: "!alias:other.example.org", +}; +const bobOnOtherFocusRtcMember = mockRtcMembership(bobUserId, bobDeviceId, { + fociPreferred: [otherTransport], +}); + export interface GridLayoutSummary { type: "grid"; spotlight?: string[]; @@ -450,6 +465,113 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => { }); }); + test("LiveKit participants without a membership get an unknown participant tile", () => { + withTestScheduler(({ behavior, expectObservable }) => { + // A participant nobody's membership accounts for connects on frame 1 and + // disconnects on frame 2 + const participantInputMarbles = "aba"; + // It gets a tile at the end of the grid for as long as it is connected + const expectedLayoutMarbles = " aba"; + + withCallViewModel( + { + remoteParticipants$: behavior(participantInputMarbles, { + a: [aliceParticipant, bobParticipant], + b: [aliceParticipant, bobParticipant, rogueParticipant], + }), + rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]), + }, + (vm) => { + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "grid", + spotlight: undefined, + grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], + }, + b: { + type: "grid", + spotlight: undefined, + grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`, rogueId], + }, + }, + ); + }, + ); + }); + }); + + test("an unknown participant takes the call out of one-on-one layout", () => { + withTestScheduler(({ behavior, expectObservable }) => { + // A participant nobody's membership accounts for connects on frame 1 and + // disconnects on frame 2 + const participantInputMarbles = "aba"; + // The one-on-one layout would hide the unknown participant, so we must + // fall back to the grid while they are present + const expectedLayoutMarbles = " aba"; + + withCallViewModel( + { + remoteParticipants$: behavior(participantInputMarbles, { + a: [aliceParticipant], + b: [aliceParticipant, rogueParticipant], + }), + rtcMembers$: constant([localRtcMember, aliceRtcMember]), + }, + (vm) => { + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "one-on-one-desktop", + pip: `${localId}:0`, + spotlight: `${aliceId}:0`, + }, + b: { + type: "grid", + spotlight: undefined, + grid: [`${localId}:0`, `${aliceId}:0`, rogueId], + }, + }, + ); + }, + ); + }); + }); + + test("a member seen on a focus other than their own is not an unknown participant", () => { + withTestScheduler(({ expectObservable }) => { + // Bob publishes to another focus, but also connects to ours to subscribe + // (the mock reports the same participants on every connection). Matching + // by identity rather than per transport keeps him from being flagged. + const expectedLayoutMarbles = "a"; + + withCallViewModel( + { + remoteParticipants$: constant([aliceParticipant, bobParticipant]), + rtcMembers$: constant([ + localRtcMember, + aliceRtcMember, + bobOnOtherFocusRtcMember, + ]), + }, + (vm) => { + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "grid", + spotlight: undefined, + grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`], + }, + }, + ); + }, + ); + }); + }); + test("one-on-one mobile layout shows local tile when video is enabled", () => { withTestScheduler(({ behavior, schedule, expectObservable }) => { // Local participant enables their video, then disables it @@ -1211,15 +1333,18 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => { }); }); - test("participants must have a MatrixRTCSession to be visible", () => { + test("participants without a MatrixRTC membership are shown as unknown, without media", () => { withTestScheduler(({ behavior, expectObservable }) => { // iterate through a number of combinations of participants and MatrixRTC memberships // Bob never has an MatrixRTC membership const participantInputMarbles = "abcd-c"; // Bob even tries to share his screen at the end const bobSharingInputMarbles = " n---yn"; - // Bob should never be visible - const expectedLayoutMarbles = " a-bc-b"; + // Bob gets an unknown participant tile (MSC4143) rather than a tile of + // his own, and his screen share never shows up. His presence also keeps + // the call out of the one-on-one layout, which would hide him. + const expectedLayoutMarbles = " abcd-c"; + const bobUnknownId = `unknown:${exampleTransport.livekit_service_url}:${bobId}`; withCallViewModel( { @@ -1251,14 +1376,25 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => { grid: [`${localId}:0`], }, b: { - type: "one-on-one-desktop", - pip: `${localId}:0`, - spotlight: `${aliceId}:0`, + type: "grid", + spotlight: undefined, + grid: [`${localId}:0`, bobUnknownId], }, + // Tiles keep their positions, so Bob stays ahead of the others c: { type: "grid", spotlight: undefined, - grid: [`${localId}:0`, `${aliceId}:0`, `${daveId}:0`], + grid: [`${localId}:0`, bobUnknownId, `${aliceId}:0`], + }, + d: { + type: "grid", + spotlight: undefined, + grid: [ + `${localId}:0`, + bobUnknownId, + `${aliceId}:0`, + `${daveId}:0`, + ], }, }, ); diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 957a56fad..0e0a69d1b 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -95,6 +95,7 @@ import { layoutShallowEquals, type Alignment, type GridLayoutMedia, + type GridMediaViewModel, type Layout, type LayoutMedia, type OneOnOneDesktopLayoutMedia, @@ -162,6 +163,10 @@ import { createRingingMedia, type RingingMediaViewModel, } from "../media/RingingMediaViewModel.ts"; +import { + createUnknownParticipantMedia, + type UnknownParticipantMediaViewModel, +} from "../media/UnknownParticipantMediaViewModel.ts"; import { type GridTileViewModel } from "../TileViewModel.ts"; //TODO @@ -869,6 +874,61 @@ export function createCallViewModel$( ), ); + /** + * Media for LiveKit participants that cannot be mapped to any MatrixRTC + * membership. MSC4143 asks us to surface these rather than ignore them, as + * they can signal an eavesdropping or impersonation attack. + * + * Participants are matched to memberships by identity alone rather than per + * transport, since a member legitimately shows up as a subscribe-only + * participant on every focus other than the one they publish to. + */ + const unknownParticipantMedia$ = scope.behavior< + UnknownParticipantMediaViewModel[] + >( + combineLatest([ + memberships$, + connectionManager.connectionManagerData$, + ]).pipe( + // Memberships and connections derive from the same epoch of session + // state; comparing them across epochs would flag members whose + // connection hasn't caught up yet. + filter(([memberships, data]) => memberships.epoch === data.epoch), + map(([memberships, data]) => { + const knownIdentities = new Set( + memberships.value.map((m) => m.rtcBackendIdentity), + ); + return data.value.getConnections().flatMap((connection) => + data.value + .getParticipantsForTransport(connection.transport) + .filter((p) => !knownIdentities.has(p.identity)) + .map((p) => ({ + focusUrl: connection.transport.livekit_service_url, + identity: p.identity, + })), + ); + }), + generateItems( + "CallViewModel unknownParticipantMedia$", + function* (unknownParticipants) { + for (const { focusUrl, identity } of unknownParticipants) + yield { keys: [focusUrl, identity], data: undefined }; + }, + (_scope, _data$, focusUrl, identity) => { + logger.warn( + `LiveKit participant ${identity} on ${focusUrl} matches no MatrixRTC membership`, + ); + return createUnknownParticipantMedia({ + id: `unknown:${focusUrl}:${identity}`, + rtcBackendIdentity: identity, + focusUrl, + }); + }, + ), + ), + [], + ); + const ringingMedia$ = scope.behavior( ringAttempts$.pipe( switchMap(({ intent, recipient, outcome$ }) => @@ -1015,21 +1075,29 @@ export function createCallViewModel$( ), ); - const grid$ = scope.behavior( - userMedia$.pipe( - switchMap((mediaItems) => { - const bins = mediaItems.map((m) => - m.bin$.pipe(map((bin) => [m, bin] as const)), - ); - // Sort the media by bin order and generate a tile for each one - return bins.length === 0 - ? of([]) - : combineLatest(bins, (...bins) => - bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m), - ); - }), - distinctUntilChanged(shallowArrayEquals), - ), + const sortedUserMedia$: Observable = userMedia$.pipe( + switchMap((mediaItems) => { + const bins = mediaItems.map((m) => + m.bin$.pipe(map((bin) => [m, bin] as const)), + ); + // Sort the media by bin order and generate a tile for each one + return bins.length === 0 + ? of([]) + : combineLatest(bins, (...bins) => + bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m), + ); + }), + ); + + const grid$ = scope.behavior( + combineLatest( + [sortedUserMedia$, unknownParticipantMedia$], + // Unknown participants have no media to sort by, so they go last + (userMedia, unknownParticipants) => [ + ...userMedia, + ...unknownParticipants, + ], + ).pipe(distinctUntilChanged(shallowArrayEquals)), ); /** @@ -1189,10 +1257,15 @@ export function createCallViewModel$( local: LocalUserMediaViewModel; remote: UserMediaViewModel | RingingMediaViewModel; } | null> = scope.behavior( - combineLatest([userMedia$, screenShares$]).pipe( - switchMap(([userMedia, screenShares]) => { - // One-on-one layout only supports 2 user media, no screen shares - if (userMedia.length <= 2 && screenShares.length === 0) { + combineLatest([userMedia$, screenShares$, unknownParticipantMedia$]).pipe( + switchMap(([userMedia, screenShares, unknownParticipants]) => { + // One-on-one layout only supports 2 user media, no screen shares, and + // must never hide an unknown participant + if ( + userMedia.length <= 2 && + screenShares.length === 0 && + unknownParticipants.length === 0 + ) { const local = userMedia.find( (vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel => vm.type === "user" && vm.local, diff --git a/src/state/TileStore.ts b/src/state/TileStore.ts index 132d1b946..80f7551ed 100644 --- a/src/state/TileStore.ts +++ b/src/state/TileStore.ts @@ -8,12 +8,15 @@ Please see LICENSE in the repository root for full details. import { BehaviorSubject } from "rxjs"; import { logger } from "matrix-js-sdk/lib/logger"; -import { GridTileViewModel, SpotlightTileViewModel } from "./TileViewModel"; +import { + type GridTileMediaViewModel, + GridTileViewModel, + SpotlightTileViewModel, +} from "./TileViewModel"; import { fillGaps } from "../utils/iter"; import { debugTileLayout } from "../settings/settings"; import { type MediaViewModel } from "./media/MediaViewModel"; import { type UserMediaViewModel } from "./media/UserMediaViewModel"; -import { type RingingMediaViewModel } from "./media/RingingMediaViewModel"; type SpotlightBackground = "solid" | "transparent"; @@ -68,10 +71,8 @@ class SpotlightTileData { } class GridTileData { - private readonly media$: BehaviorSubject< - UserMediaViewModel | RingingMediaViewModel - >; - public get media(): UserMediaViewModel | RingingMediaViewModel { + private readonly media$: BehaviorSubject; + public get media(): GridTileMediaViewModel { return this.media$.value; } public set media(value: UserMediaViewModel) { @@ -80,7 +81,7 @@ class GridTileData { public readonly vm: GridTileViewModel; - public constructor(media: UserMediaViewModel | RingingMediaViewModel) { + public constructor(media: GridTileMediaViewModel) { this.media$ = new BehaviorSubject(media); this.vm = new GridTileViewModel(this.media$); } @@ -140,7 +141,7 @@ export class TileStoreBuilder { : null; private readonly prevGridByMedia: Map< - MediaViewModel, + GridTileMediaViewModel, [GridTileData, number] > = new Map( this.prevGrid.map((entry, i) => [entry.media, [entry, i]] as const), @@ -205,9 +206,7 @@ export class TileStoreBuilder { * Sets up a grid tile for the given media. If this is never called for some * media, then that media will have no grid tile. */ - public registerGridTile( - media: UserMediaViewModel | RingingMediaViewModel, - ): void { + public registerGridTile(media: GridTileMediaViewModel): void { if (DEBUG_ENABLED) logger.debug( `[TileStore, ${this.generation}] register grid tile: ${media.displayName$.value}`, @@ -215,8 +214,10 @@ export class TileStoreBuilder { if (this.spotlight !== null) { // We actually *don't* want spotlight speakers to appear in both the - // spotlight and the grid, so they're filtered out here + // spotlight and the grid, so they're filtered out here. (Unknown + // participants never make it into the spotlight.) if ( + media.type !== "unknown participant" && !(media.type === "user" && media.local) && this.spotlight.media.includes(media) ) diff --git a/src/state/TileViewModel.ts b/src/state/TileViewModel.ts index 6a5d9175d..4a046f322 100644 --- a/src/state/TileViewModel.ts +++ b/src/state/TileViewModel.ts @@ -10,8 +10,14 @@ import { BehaviorSubject } from "rxjs"; import { type Behavior } from "./Behavior"; import { type MediaViewModel } from "./media/MediaViewModel"; import { type RingingMediaViewModel } from "./media/RingingMediaViewModel"; +import { type UnknownParticipantMediaViewModel } from "./media/UnknownParticipantMediaViewModel"; import { type UserMediaViewModel } from "./media/UserMediaViewModel"; +export type GridTileMediaViewModel = + | UserMediaViewModel + | RingingMediaViewModel + | UnknownParticipantMediaViewModel; + let nextId = 0; function createId(): string { return (nextId++).toString(); @@ -23,9 +29,7 @@ export class GridTileViewModel { public readonly showOutline$: Behavior = this._showOutline$; public constructor( - public readonly media$: Behavior< - UserMediaViewModel | RingingMediaViewModel - >, + public readonly media$: Behavior, ) {} public setShowOutline(value: boolean): void { diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index be86f2a36..6a48bfdff 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -10,6 +10,7 @@ import { type BehaviorSubject } from "rxjs"; import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts"; import { type MediaViewModel } from "./media/MediaViewModel.ts"; import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts"; +import { type UnknownParticipantMediaViewModel } from "./media/UnknownParticipantMediaViewModel.ts"; import { type UserMediaViewModel } from "./media/UserMediaViewModel.ts"; import { type GridTileViewModel, @@ -18,25 +19,29 @@ import { import { type Behavior } from "./Behavior.ts"; import { shallowEquals as arrayShallowEquals } from "../utils/array.ts"; +export type GridMediaViewModel = + | UserMediaViewModel + | UnknownParticipantMediaViewModel; + export interface GridLayoutMedia { type: "grid"; edgeToEdge: false; spotlight?: MediaViewModel[]; - grid: UserMediaViewModel[]; + grid: GridMediaViewModel[]; } export interface SpotlightLandscapeLayoutMedia { type: "spotlight-landscape"; edgeToEdge: boolean; spotlight: MediaViewModel[]; - grid: UserMediaViewModel[]; + grid: GridMediaViewModel[]; } export interface SpotlightPortraitLayoutMedia { type: "spotlight-portrait"; edgeToEdge: false; spotlight: MediaViewModel[]; - grid: UserMediaViewModel[]; + grid: GridMediaViewModel[]; } export interface SpotlightExpandedLayoutMedia { diff --git a/src/state/media/UnknownParticipantMediaViewModel.ts b/src/state/media/UnknownParticipantMediaViewModel.ts new file mode 100644 index 000000000..7576d980e --- /dev/null +++ b/src/state/media/UnknownParticipantMediaViewModel.ts @@ -0,0 +1,57 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { constant } from "../Behavior"; +import { type BaseMediaViewModel } from "./MediaViewModel"; + +/** + * Media representing a LiveKit participant that cannot be mapped to any + * MatrixRTC member of the session. + * + * MSC4143 asks clients to surface such streams rather than silently ignore + * them, because they can signal an eavesdropping or impersonation attack. We + * therefore give them a tile, but never render their audio or video. + * + * There is no Matrix user behind this media, so `userId` and `displayName$` + * carry the LiveKit identity instead, purely so that debugging output stays + * meaningful. + */ +export interface UnknownParticipantMediaViewModel extends BaseMediaViewModel { + type: "unknown participant"; + /** + * The LiveKit identity under which the participant connected. Exposed for + * debugging. + */ + rtcBackendIdentity: string; + /** + * The URL of the LiveKit focus on which the participant was seen. Exposed for + * debugging. + */ + focusUrl: string; +} + +export interface UnknownParticipantMediaInputs { + id: string; + rtcBackendIdentity: string; + focusUrl: string; +} + +export function createUnknownParticipantMedia({ + id, + rtcBackendIdentity, + focusUrl, +}: UnknownParticipantMediaInputs): UnknownParticipantMediaViewModel { + return { + type: "unknown participant", + id, + userId: rtcBackendIdentity, + displayName$: constant(rtcBackendIdentity), + mxcAvatarUrl$: constant(undefined), + rtcBackendIdentity, + focusUrl, + }; +} diff --git a/src/tile/GridTile.stories.tsx b/src/tile/GridTile.stories.tsx new file mode 100644 index 000000000..ee1ea69c6 --- /dev/null +++ b/src/tile/GridTile.stories.tsx @@ -0,0 +1,98 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { expect, within } from "storybook/test"; +import { type JSX, useMemo } from "react"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { GridTile } from "./GridTile"; +import { GridTileViewModel } from "../state/TileViewModel"; +import { constant } from "../state/Behavior"; +import { createUnknownParticipantMedia } from "../state/media/UnknownParticipantMediaViewModel"; + +interface UnknownParticipantTileStoryProps { + width: number; + height: number; + rtcBackendIdentity: string; + showNameTags: boolean; + showOutline: boolean; +} + +/** + * Renders a GridTile for a LiveKit participant that maps to no MatrixRTC + * member, driven by primitive props so that Storybook can document them. + */ +function UnknownParticipantTile({ + width, + height, + rtcBackendIdentity, + ...props +}: UnknownParticipantTileStoryProps): JSX.Element { + const vm = useMemo( + () => + new GridTileViewModel( + constant( + createUnknownParticipantMedia({ + id: `unknown:https://rtc.example.org:${rtcBackendIdentity}`, + rtcBackendIdentity, + focusUrl: "https://rtc.example.org", + }), + ), + ), + [rtcBackendIdentity], + ); + return ( + + ); +} + +const meta = { + component: UnknownParticipantTile, + argTypes: { + width: { control: { type: "range", min: 80, max: 800, step: 10 } }, + height: { control: { type: "range", min: 80, max: 600, step: 10 } }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const UnknownParticipant: Story = { + args: { + width: 400, + height: 300, + rtcBackendIdentity: "@rogue:example.org:DEVICE", + showNameTags: true, + showOutline: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Unknown participant")).toBeInTheDocument(); + await expect( + canvas.queryByText("@rogue:example.org:DEVICE"), + ).not.toBeInTheDocument(); + }, +}; + +/** Narrow tiles hide the name tag, so the label goes with it. */ +export const UnknownParticipantNarrow: Story = { + args: { + ...UnknownParticipant.args, + width: 90, + height: 90, + }, +}; diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index 60fbc303c..131b015c8 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -32,6 +32,7 @@ import { createRingingMedia, type RingingMediaViewModel, } from "../state/media/RingingMediaViewModel"; +import { createUnknownParticipantMedia } from "../state/media/UnknownParticipantMediaViewModel"; global.IntersectionObserver = class MockIntersectionObserver { public observe(): void {} @@ -165,3 +166,33 @@ test("GridTile displays ringing media", async () => { act(() => pickupState$.next("decline")); screen.getByText("Call ended"); }); + +test("GridTile displays an unknown participant", async () => { + const vm = createUnknownParticipantMedia({ + id: "unknown:https://rtc-example.org:rogue", + rtcBackendIdentity: "rogue", + focusUrl: "https://rtc-example.org", + }); + + const { container } = render( + + {}} + targetWidth={300} + targetHeight={200} + showSpeakingIndicators + showNameTags + showRingingStatus + showOutline + focusable + /> + , + ); + expect(await axe(container)).toHaveNoViolations(); + // The tile is labelled as unknown rather than after its LiveKit identity + screen.getByText("Unknown participant"); + expect(screen.queryByText("rogue")).toBeNull(); + // There is no user to show an avatar for + expect(container.querySelector("[data-style]")).toBeNull(); +}); diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 1334e210c..560d484c4 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -51,6 +51,7 @@ import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewM import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel"; import { type UserMediaViewModel } from "../state/media/UserMediaViewModel"; import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; +import { type UnknownParticipantMediaViewModel } from "../state/media/UnknownParticipantMediaViewModel"; import { RingingStatus } from "./RingingStatus"; interface TileProps { @@ -97,6 +98,44 @@ const RingingMediaTile: FC = ({ ); }; +interface UnknownParticipantTileProps extends Omit< + TileProps, + "displayName" | "mxcAvatarUrl" +> { + vm: UnknownParticipantMediaViewModel; +} + +/** + * A blank tile standing in for a LiveKit participant that cannot be mapped to + * any MatrixRTC member. MSC4143 asks clients to surface such streams rather + * than ignore them. No media is rendered for them. + */ +const UnknownParticipantTile: FC = ({ + vm, + className, + ...props +}) => { + const { t } = useTranslation(); + return ( + + ); +}; + +UnknownParticipantTile.displayName = "UnknownParticipantTile"; + interface UserMediaTileProps extends TileProps { vm: UserMediaViewModel; showSpeakingIndicators: boolean; @@ -454,6 +493,15 @@ export const GridTile: FC = ({ {...props} /> ); + } else if (media.type === "unknown participant") { + return ( + + ); } else if (media.local) { return ( { displayName: string; mxcAvatarUrl: string | undefined; avatarStyle?: "solid" | "translucent"; + /** + * Whether to show an avatar when there is no video. Off for media that has no + * Matrix user behind it. + */ + showAvatar?: boolean; background?: "solid" | "transparent"; focusable: boolean; primaryButton?: ReactNode; @@ -95,6 +100,7 @@ export const MediaView: FC = ({ displayName, mxcAvatarUrl, avatarStyle = "solid", + showAvatar = true, background = "solid", focusable, primaryButton, @@ -176,15 +182,17 @@ export const MediaView: FC = ({
)} - + {showAvatar && ( + + )} {video?.publication !== undefined && (