Merge pull request #3626 from robintown/non-publishing-participants

Don't show 'waiting for media' on connected participants
This commit is contained in:
Timo
2025-12-16 11:53:06 +01:00
committed by GitHub
14 changed files with 187 additions and 172 deletions

View File

@@ -358,7 +358,7 @@ export class Publisher {
const track$ = scope.behavior( const track$ = scope.behavior(
observeTrackReference$(room.localParticipant, Track.Source.Camera).pipe( observeTrackReference$(room.localParticipant, Track.Source.Camera).pipe(
map((trackRef) => { map((trackRef) => {
const track = trackRef?.publication?.track; const track = trackRef?.publication.track;
return track instanceof LocalVideoTrack ? track : null; return track instanceof LocalVideoTrack ? track : null;
}), }),
), ),

View File

@@ -39,6 +39,7 @@ import {
ElementCallError, ElementCallError,
FailToGetOpenIdToken, FailToGetOpenIdToken,
} from "../../../utils/errors.ts"; } from "../../../utils/errors.ts";
import { mockRemoteParticipant } from "../../../utils/test.ts";
let testScope: ObservableScope; let testScope: ObservableScope;
@@ -376,46 +377,32 @@ describe("Start connection states", () => {
}); });
}); });
function fakeRemoteLivekitParticipant( describe("remote participants", () => {
id: string, it("emits the list of remote participants", () => {
publications: number = 1,
): RemoteParticipant {
return {
identity: id,
getTrackPublications: () => Array(publications),
} as unknown as RemoteParticipant;
}
describe("Publishing participants observations", () => {
it("should emit the list of publishing participants", () => {
setupTest(); setupTest();
const connection = setupRemoteConnection(); const connection = setupRemoteConnection();
const bobIsAPublisher = Promise.withResolvers<void>(); const observedParticipants: RemoteParticipant[][] = [];
const danIsAPublisher = Promise.withResolvers<void>(); const s = connection.remoteParticipants$.subscribe((participants) => {
const observedPublishers: RemoteParticipant[][] = []; observedParticipants.push(participants);
const s = connection.remoteParticipantsWithTracks$.subscribe( });
(publishers) => {
observedPublishers.push(publishers);
if (publishers.some((p) => p.identity === "@bob:example.org:DEV111")) {
bobIsAPublisher.resolve();
}
if (publishers.some((p) => p.identity === "@dan:example.org:DEV333")) {
danIsAPublisher.resolve();
}
},
);
onTestFinished(() => s.unsubscribe()); onTestFinished(() => s.unsubscribe());
// The remoteParticipants$ observable is derived from the current members of the // The remoteParticipants$ observable is derived from the current members of the
// livekitRoom and the rtc membership in order to publish the members that are publishing // livekitRoom and the rtc membership in order to publish the members that are publishing
// on this connection. // on this connection.
let participants: RemoteParticipant[] = [ const participants: RemoteParticipant[] = [
fakeRemoteLivekitParticipant("@alice:example.org:DEV000", 0), mockRemoteParticipant({ identity: "@alice:example.org:DEV000" }),
fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 0), mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
fakeRemoteLivekitParticipant("@carol:example.org:DEV222", 0), mockRemoteParticipant({ identity: "@carol:example.org:DEV222" }),
fakeRemoteLivekitParticipant("@dan:example.org:DEV333", 0), // Mock Dan to have no published tracks. We want him to still show show up
// in the participants list.
mockRemoteParticipant({
identity: "@dan:example.org:DEV333",
getTrackPublication: () => undefined,
getTrackPublications: () => [],
}),
]; ];
// Let's simulate 3 members on the livekitRoom // Let's simulate 3 members on the livekitRoom
@@ -427,21 +414,8 @@ describe("Publishing participants observations", () => {
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p), fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
); );
// At this point there should be no publishers // All remote participants should be present
expect(observedPublishers.pop()!.length).toEqual(0); expect(observedParticipants.pop()!.length).toEqual(4);
participants = [
fakeRemoteLivekitParticipant("@alice:example.org:DEV000", 1),
fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 1),
fakeRemoteLivekitParticipant("@carol:example.org:DEV222", 1),
fakeRemoteLivekitParticipant("@dan:example.org:DEV333", 2),
];
participants.forEach((p) =>
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
);
// At this point there should be no publishers
expect(observedPublishers.pop()!.length).toEqual(4);
}); });
it("should be scoped to parent scope", (): void => { it("should be scoped to parent scope", (): void => {
@@ -449,16 +423,14 @@ describe("Publishing participants observations", () => {
const connection = setupRemoteConnection(); const connection = setupRemoteConnection();
let observedPublishers: RemoteParticipant[][] = []; let observedParticipants: RemoteParticipant[][] = [];
const s = connection.remoteParticipantsWithTracks$.subscribe( const s = connection.remoteParticipants$.subscribe((participants) => {
(publishers) => { observedParticipants.push(participants);
observedPublishers.push(publishers); });
},
);
onTestFinished(() => s.unsubscribe()); onTestFinished(() => s.unsubscribe());
let participants: RemoteParticipant[] = [ let participants: RemoteParticipant[] = [
fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 0), mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
]; ];
// Let's simulate 3 members on the livekitRoom // Let's simulate 3 members on the livekitRoom
@@ -470,35 +442,26 @@ describe("Publishing participants observations", () => {
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, participant); fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, participant);
} }
// At this point there should be no publishers // We should have bob as a participant now
expect(observedPublishers.pop()!.length).toEqual(0); const ps = observedParticipants.pop();
expect(ps?.length).toEqual(1);
participants = [fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 1)]; expect(ps?.[0]?.identity).toEqual("@bob:example.org:DEV111");
for (const participant of participants) {
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, participant);
}
// We should have bob has a publisher now
const publishers = observedPublishers.pop();
expect(publishers?.length).toEqual(1);
expect(publishers?.[0]?.identity).toEqual("@bob:example.org:DEV111");
// end the parent scope // end the parent scope
testScope.end(); testScope.end();
observedPublishers = []; observedParticipants = [];
// SHOULD NOT emit any more publishers as the scope is ended // SHOULD NOT emit any more participants as the scope is ended
participants = participants.filter( participants = participants.filter(
(p) => p.identity !== "@bob:example.org:DEV111", (p) => p.identity !== "@bob:example.org:DEV111",
); );
fakeLivekitRoom.emit( fakeLivekitRoom.emit(
RoomEvent.ParticipantDisconnected, RoomEvent.ParticipantDisconnected,
fakeRemoteLivekitParticipant("@bob:example.org:DEV111"), mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
); );
expect(observedPublishers.length).toEqual(0); expect(observedParticipants.length).toEqual(0);
}); });
}); });

View File

@@ -14,7 +14,6 @@ import {
ConnectionError, ConnectionError,
type Room as LivekitRoom, type Room as LivekitRoom,
type RemoteParticipant, type RemoteParticipant,
RoomEvent,
} from "livekit-client"; } from "livekit-client";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, map } from "rxjs"; import { BehaviorSubject, map } from "rxjs";
@@ -96,11 +95,13 @@ export class Connection {
private scope: ObservableScope; private scope: ObservableScope;
/** /**
* An observable of the participants that are publishing on this connection. (Excluding our local participant) * The remote LiveKit participants that are visible on this connection.
* This is derived from `participantsIncludingSubscribers$` and `remoteTransports$`. *
* It filters the participants to only those that are associated with a membership that claims to publish on this connection. * Note that this may include participants that are connected only to
* subscribe, or publishers that are otherwise unattested in MatrixRTC state.
* It is therefore more low-level than what should be presented to the user.
*/ */
public readonly remoteParticipantsWithTracks$: Behavior<RemoteParticipant[]>; public readonly remoteParticipants$: Behavior<RemoteParticipant[]>;
/** /**
* Whether the connection has been stopped. * Whether the connection has been stopped.
@@ -231,23 +232,9 @@ export class Connection {
this.transport = transport; this.transport = transport;
this.client = client; this.client = client;
// REMOTE participants with track!!! this.remoteParticipants$ = scope.behavior(
// this.remoteParticipantsWithTracks$ // Only tracks remote participants
this.remoteParticipantsWithTracks$ = scope.behavior( connectedParticipantsObserver(this.livekitRoom),
// only tracks remote participants
connectedParticipantsObserver(this.livekitRoom, {
additionalRoomEvents: [
RoomEvent.TrackPublished,
RoomEvent.TrackUnpublished,
],
}).pipe(
map((participants) => {
return participants.filter(
(participant) => participant.getTrackPublications().length > 0,
);
}),
),
[],
); );
scope.onEnd(() => { scope.onEnd(() => {

View File

@@ -52,7 +52,7 @@ beforeEach(() => {
(transport: LivekitTransport, scope: ObservableScope) => { (transport: LivekitTransport, scope: ObservableScope) => {
const mockConnection = { const mockConnection = {
transport, transport,
remoteParticipantsWithTracks$: new BehaviorSubject([]), remoteParticipants$: new BehaviorSubject([]),
} as unknown as Connection; } as unknown as Connection;
vi.mocked(mockConnection).start = vi.fn(); vi.mocked(mockConnection).start = vi.fn();
vi.mocked(mockConnection).stop = vi.fn(); vi.mocked(mockConnection).stop = vi.fn();
@@ -200,7 +200,7 @@ describe("connections$ stream", () => {
}); });
describe("connectionManagerData$ stream", () => { describe("connectionManagerData$ stream", () => {
// Used in test to control fake connections' remoteParticipantsWithTracks$ streams // Used in test to control fake connections' remoteParticipants$ streams
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>; let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
function keyForTransport(transport: LivekitTransport): string { function keyForTransport(transport: LivekitTransport): string {
@@ -229,7 +229,7 @@ describe("connectionManagerData$ stream", () => {
>([]); >([]);
const mockConnection = { const mockConnection = {
transport, transport,
remoteParticipantsWithTracks$: getRemoteParticipantsFor(transport), remoteParticipants$: getRemoteParticipantsFor(transport),
} as unknown as Connection; } as unknown as Connection;
vi.mocked(mockConnection).start = vi.fn(); vi.mocked(mockConnection).start = vi.fn();
vi.mocked(mockConnection).stop = vi.fn(); vi.mocked(mockConnection).stop = vi.fn();

View File

@@ -6,10 +6,7 @@ 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 { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
type LivekitTransport,
type ParticipantId,
} from "matrix-js-sdk/lib/matrixrtc";
import { combineLatest, map, of, switchMap, tap } from "rxjs"; import { combineLatest, map, of, switchMap, tap } from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
import { type RemoteParticipant } from "livekit-client"; import { type RemoteParticipant } from "livekit-client";
@@ -57,34 +54,20 @@ export class ConnectionManagerData {
const key = transport.livekit_service_url + "|" + transport.livekit_alias; const key = transport.livekit_service_url + "|" + transport.livekit_alias;
return this.store.get(key)?.[1] ?? []; return this.store.get(key)?.[1] ?? [];
} }
/**
* Get all connections where the given participant is publishing.
* In theory, there could be several connections where the same participant is publishing but with
* only well behaving clients a participant should only be publishing on a single connection.
* @param participantId
*/
public getConnectionsForParticipant(
participantId: ParticipantId,
): Connection[] {
const connections: Connection[] = [];
for (const [connection, participants] of this.store.values()) {
if (participants.some((p) => p.identity === participantId)) {
connections.push(connection);
}
}
return connections;
}
} }
interface Props { interface Props {
scope: ObservableScope; scope: ObservableScope;
connectionFactory: ConnectionFactory; connectionFactory: ConnectionFactory;
inputTransports$: Behavior<Epoch<LivekitTransport[]>>; inputTransports$: Behavior<Epoch<LivekitTransport[]>>;
logger: Logger; logger: Logger;
} }
// TODO - write test for scopes (do we really need to bind scope) // TODO - write test for scopes (do we really need to bind scope)
export interface IConnectionManager { export interface IConnectionManager {
connectionManagerData$: Behavior<Epoch<ConnectionManagerData>>; connectionManagerData$: Behavior<Epoch<ConnectionManagerData>>;
} }
/** /**
* Crete a `ConnectionManager` * Crete a `ConnectionManager`
* @param scope the observable scope used by this object. * @param scope the observable scope used by this object.
@@ -169,7 +152,7 @@ export function createConnectionManager$({
// Map the connections to list of {connection, participants}[] // Map the connections to list of {connection, participants}[]
const listOfConnectionsWithRemoteParticipants = connections.value.map( const listOfConnectionsWithRemoteParticipants = connections.value.map(
(connection) => { (connection) => {
return connection.remoteParticipantsWithTracks$.pipe( return connection.remoteParticipants$.pipe(
map((participants) => ({ map((participants) => ({
connection, connection,
participants, participants,

View File

@@ -20,6 +20,7 @@ import {
createLocalMedia, createLocalMedia,
createRemoteMedia, createRemoteMedia,
withTestScheduler, withTestScheduler,
mockRemoteParticipant,
} from "../utils/test"; } from "../utils/test";
import { getValue } from "../utils/observable"; import { getValue } from "../utils/observable";
import { constant } from "./Behavior"; import { constant } from "./Behavior";
@@ -44,7 +45,11 @@ const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
test("control a participant's volume", () => { test("control a participant's volume", () => {
const setVolumeSpy = vi.fn(); const setVolumeSpy = vi.fn();
const vm = createRemoteMedia(rtcMembership, {}, { setVolume: setVolumeSpy }); const vm = createRemoteMedia(
rtcMembership,
{},
mockRemoteParticipant({ setVolume: setVolumeSpy }),
);
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
schedule("-ab---c---d|", { schedule("-ab---c---d|", {
a() { a() {
@@ -88,7 +93,7 @@ test("control a participant's volume", () => {
}); });
test("toggle fit/contain for a participant's video", () => { test("toggle fit/contain for a participant's video", () => {
const vm = createRemoteMedia(rtcMembership, {}, {}); const vm = createRemoteMedia(rtcMembership, {}, mockRemoteParticipant({}));
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
schedule("-ab|", { schedule("-ab|", {
a: () => vm.toggleFitContain(), a: () => vm.toggleFitContain(),
@@ -199,3 +204,35 @@ test("switch cameras", async () => {
}); });
expect(deviceId).toBe("front camera"); expect(deviceId).toBe("front camera");
}); });
test("remote media is in waiting state when participant has not yet connected", () => {
const vm = createRemoteMedia(rtcMembership, {}, null); // null participant
expect(vm.waitingForMedia$.value).toBe(true);
});
test("remote media is not in waiting state when participant is connected", () => {
const vm = createRemoteMedia(rtcMembership, {}, mockRemoteParticipant({}));
expect(vm.waitingForMedia$.value).toBe(false);
});
test("remote media is not in waiting state when participant is connected with no publications", () => {
const vm = createRemoteMedia(
rtcMembership,
{},
mockRemoteParticipant({
getTrackPublication: () => undefined,
getTrackPublications: () => [],
}),
);
expect(vm.waitingForMedia$.value).toBe(false);
});
test("remote media is not in waiting state when user does not intend to publish anywhere", () => {
const vm = createRemoteMedia(
rtcMembership,
{},
mockRemoteParticipant({}),
undefined, // No room (no advertised transport)
);
expect(vm.waitingForMedia$.value).toBe(false);
});

View File

@@ -7,8 +7,8 @@ Please see LICENSE in the repository root for full details.
import { import {
type AudioSource, type AudioSource,
type TrackReferenceOrPlaceholder,
type VideoSource, type VideoSource,
type TrackReference,
observeParticipantEvents, observeParticipantEvents,
observeParticipantMedia, observeParticipantMedia,
roomEventSelector, roomEventSelector,
@@ -33,7 +33,6 @@ import {
type Observable, type Observable,
Subject, Subject,
combineLatest, combineLatest,
distinctUntilKeyChanged,
filter, filter,
fromEvent, fromEvent,
interval, interval,
@@ -60,14 +59,11 @@ import { type ObservableScope } from "./ObservableScope";
export function observeTrackReference$( export function observeTrackReference$(
participant: Participant, participant: Participant,
source: Track.Source, source: Track.Source,
): Observable<TrackReferenceOrPlaceholder> { ): Observable<TrackReference | undefined> {
return observeParticipantMedia(participant).pipe( return observeParticipantMedia(participant).pipe(
map(() => ({ map(() => participant.getTrackPublication(source)),
participant: participant, distinctUntilChanged(),
publication: participant.getTrackPublication(source), map((publication) => publication && { participant, publication, source }),
source,
})),
distinctUntilKeyChanged("publication"),
); );
} }
@@ -226,7 +222,7 @@ abstract class BaseMediaViewModel {
/** /**
* The LiveKit video track for this media. * The LiveKit video track for this media.
*/ */
public readonly video$: Behavior<TrackReferenceOrPlaceholder | null>; public readonly video$: Behavior<TrackReference | undefined>;
/** /**
* Whether there should be a warning that this media is unencrypted. * Whether there should be a warning that this media is unencrypted.
*/ */
@@ -241,10 +237,12 @@ abstract class BaseMediaViewModel {
private observeTrackReference$( private observeTrackReference$(
source: Track.Source, source: Track.Source,
): Behavior<TrackReferenceOrPlaceholder | null> { ): Behavior<TrackReference | undefined> {
return this.scope.behavior( return this.scope.behavior(
this.participant$.pipe( this.participant$.pipe(
switchMap((p) => (!p ? of(null) : observeTrackReference$(p, source))), switchMap((p) =>
!p ? of(undefined) : observeTrackReference$(p, source),
),
), ),
); );
} }
@@ -268,7 +266,7 @@ abstract class BaseMediaViewModel {
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
audioSource: AudioSource, audioSource: AudioSource,
videoSource: VideoSource, videoSource: VideoSource,
livekitRoom$: Behavior<LivekitRoom | undefined>, protected readonly livekitRoom$: Behavior<LivekitRoom | undefined>,
public readonly focusUrl$: Behavior<string | undefined>, public readonly focusUrl$: Behavior<string | undefined>,
public readonly displayName$: Behavior<string>, public readonly displayName$: Behavior<string>,
public readonly mxcAvatarUrl$: Behavior<string | undefined>, public readonly mxcAvatarUrl$: Behavior<string | undefined>,
@@ -281,8 +279,8 @@ abstract class BaseMediaViewModel {
[audio$, this.video$], [audio$, this.video$],
(a, v) => (a, v) =>
encryptionSystem.kind !== E2eeType.NONE && encryptionSystem.kind !== E2eeType.NONE &&
(a?.publication?.isEncrypted === false || (a?.publication.isEncrypted === false ||
v?.publication?.isEncrypted === false), v?.publication.isEncrypted === false),
), ),
); );
@@ -471,7 +469,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
private readonly videoTrack$: Observable<LocalVideoTrack | null> = private readonly videoTrack$: Observable<LocalVideoTrack | null> =
this.video$.pipe( this.video$.pipe(
switchMap((v) => { switchMap((v) => {
const track = v?.publication?.track; const track = v?.publication.track;
if (!(track instanceof LocalVideoTrack)) return of(null); if (!(track instanceof LocalVideoTrack)) return of(null);
return merge( return merge(
// Watch for track restarts because they indicate a camera switch. // Watch for track restarts because they indicate a camera switch.
@@ -596,6 +594,21 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
* A remote participant's user media. * A remote participant's user media.
*/ */
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel { export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
/**
* Whether we are waiting for this user's LiveKit participant to exist. This
* could be because either we or the remote party are still connecting.
*/
public readonly waitingForMedia$ = this.scope.behavior<boolean>(
combineLatest(
[this.livekitRoom$, this.participant$],
(livekitRoom, participant) =>
// If livekitRoom is undefined, the user is not attempting to publish on
// any transport and so we shouldn't expect a participant. (They might
// be a subscribe-only bot for example.)
livekitRoom !== undefined && participant === null,
),
);
// This private field is used to override the value from the superclass // This private field is used to override the value from the superclass
private __speaking$: Behavior<boolean>; private __speaking$: Behavior<boolean>;
public get speaking$(): Behavior<boolean> { public get speaking$(): Behavior<boolean> {

View File

@@ -12,7 +12,11 @@ import { axe } from "vitest-axe";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { GridTile } from "./GridTile"; import { GridTile } from "./GridTile";
import { mockRtcMembership, createRemoteMedia } from "../utils/test"; import {
mockRtcMembership,
createRemoteMedia,
mockRemoteParticipant,
} from "../utils/test";
import { GridTileViewModel } from "../state/TileViewModel"; import { GridTileViewModel } from "../state/TileViewModel";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel"; import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
@@ -31,11 +35,11 @@ test("GridTile is accessible", async () => {
rawDisplayName: "Alice", rawDisplayName: "Alice",
getMxcAvatarUrl: () => "mxc://adfsg", getMxcAvatarUrl: () => "mxc://adfsg",
}, },
{ mockRemoteParticipant({
setVolume() {}, setVolume() {},
getTrackPublication: () => getTrackPublication: () =>
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication, ({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
}, }),
); );
const fakeRtcSession = { const fakeRtcSession = {

View File

@@ -69,6 +69,7 @@ interface UserMediaTileProps extends TileProps {
vm: UserMediaViewModel; vm: UserMediaViewModel;
mirror: boolean; mirror: boolean;
locallyMuted: boolean; locallyMuted: boolean;
waitingForMedia?: boolean;
primaryButton?: ReactNode; primaryButton?: ReactNode;
menuStart?: ReactNode; menuStart?: ReactNode;
menuEnd?: ReactNode; menuEnd?: ReactNode;
@@ -79,6 +80,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
vm, vm,
showSpeakingIndicators, showSpeakingIndicators,
locallyMuted, locallyMuted,
waitingForMedia,
primaryButton, primaryButton,
menuStart, menuStart,
menuEnd, menuEnd,
@@ -148,7 +150,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
const tile = ( const tile = (
<MediaView <MediaView
ref={ref} ref={ref}
video={video ?? undefined} video={video}
userId={vm.userId} userId={vm.userId}
unencryptedWarning={unencryptedWarning} unencryptedWarning={unencryptedWarning}
encryptionStatus={encryptionStatus} encryptionStatus={encryptionStatus}
@@ -194,7 +196,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
raisedHandTime={handRaised ?? undefined} raisedHandTime={handRaised ?? undefined}
currentReaction={reaction ?? undefined} currentReaction={reaction ?? undefined}
raisedHandOnClick={raisedHandOnClick} raisedHandOnClick={raisedHandOnClick}
localParticipant={vm.local} waitingForMedia={waitingForMedia}
focusUrl={focusUrl} focusUrl={focusUrl}
audioStreamStats={audioStreamStats} audioStreamStats={audioStreamStats}
videoStreamStats={videoStreamStats} videoStreamStats={videoStreamStats}
@@ -290,6 +292,7 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
...props ...props
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const waitingForMedia = useBehavior(vm.waitingForMedia$);
const locallyMuted = useBehavior(vm.locallyMuted$); const locallyMuted = useBehavior(vm.locallyMuted$);
const localVolume = useBehavior(vm.localVolume$); const localVolume = useBehavior(vm.localVolume$);
const onSelectMute = useCallback( const onSelectMute = useCallback(
@@ -311,6 +314,7 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
<UserMediaTile <UserMediaTile
ref={ref} ref={ref}
vm={vm} vm={vm}
waitingForMedia={waitingForMedia}
locallyMuted={locallyMuted} locallyMuted={locallyMuted}
mirror={false} mirror={false}
menuStart={ menuStart={

View File

@@ -47,7 +47,6 @@ describe("MediaView", () => {
video: trackReference, video: trackReference,
userId: "@alice:example.com", userId: "@alice:example.com",
mxcAvatarUrl: undefined, mxcAvatarUrl: undefined,
localParticipant: false,
focusable: true, focusable: true,
}; };
@@ -66,24 +65,13 @@ describe("MediaView", () => {
}); });
}); });
describe("with no participant", () => { describe("with no video", () => {
it("shows avatar for local user", () => { it("shows avatar", () => {
render( render(<MediaView {...baseProps} video={undefined} />);
<MediaView {...baseProps} video={undefined} localParticipant={true} />,
);
expect( expect(
screen.getByRole("img", { name: "@alice:example.com" }), screen.getByRole("img", { name: "@alice:example.com" }),
).toBeVisible(); ).toBeVisible();
expect(screen.queryAllByText("Waiting for media...").length).toBe(0); expect(screen.queryByTestId("video")).toBe(null);
});
it("shows avatar and label for remote user", () => {
render(
<MediaView {...baseProps} video={undefined} localParticipant={false} />,
);
expect(
screen.getByRole("img", { name: "@alice:example.com" }),
).toBeVisible();
expect(screen.getByText("Waiting for media...")).toBeVisible();
}); });
}); });
@@ -94,6 +82,22 @@ describe("MediaView", () => {
}); });
}); });
describe("waitingForMedia", () => {
test("defaults to false", () => {
render(<MediaView {...baseProps} />);
expect(screen.queryAllByText("Waiting for media...").length).toBe(0);
});
test("shows and is accessible", async () => {
const { container } = render(
<TooltipProvider>
<MediaView {...baseProps} waitingForMedia={true} />
</TooltipProvider>,
);
expect(await axe(container)).toHaveNoViolations();
expect(screen.getByText("Waiting for media...")).toBeVisible();
});
});
describe("unencryptedWarning", () => { describe("unencryptedWarning", () => {
test("is shown and accessible", async () => { test("is shown and accessible", async () => {
const { container } = render( const { container } = render(

View File

@@ -43,7 +43,7 @@ interface Props extends ComponentProps<typeof animated.div> {
raisedHandTime?: Date; raisedHandTime?: Date;
currentReaction?: ReactionOption; currentReaction?: ReactionOption;
raisedHandOnClick?: () => void; raisedHandOnClick?: () => void;
localParticipant: boolean; waitingForMedia?: boolean;
audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats; audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats; videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
// The focus url, mainly for debugging purposes // The focus url, mainly for debugging purposes
@@ -71,7 +71,7 @@ export const MediaView: FC<Props> = ({
raisedHandTime, raisedHandTime,
currentReaction, currentReaction,
raisedHandOnClick, raisedHandOnClick,
localParticipant, waitingForMedia,
audioStreamStats, audioStreamStats,
videoStreamStats, videoStreamStats,
focusUrl, focusUrl,
@@ -129,7 +129,7 @@ export const MediaView: FC<Props> = ({
/> />
)} )}
</div> </div>
{!video && !localParticipant && ( {waitingForMedia && (
<div className={styles.status}> <div className={styles.status}>
{t("video_tile.waiting_for_media")} {t("video_tile.waiting_for_media")}
</div> </div>

View File

@@ -17,6 +17,7 @@ import {
mockRtcMembership, mockRtcMembership,
createLocalMedia, createLocalMedia,
createRemoteMedia, createRemoteMedia,
mockRemoteParticipant,
} from "../utils/test"; } from "../utils/test";
import { SpotlightTileViewModel } from "../state/TileViewModel"; import { SpotlightTileViewModel } from "../state/TileViewModel";
import { constant } from "../state/Behavior"; import { constant } from "../state/Behavior";
@@ -33,7 +34,7 @@ test("SpotlightTile is accessible", async () => {
rawDisplayName: "Alice", rawDisplayName: "Alice",
getMxcAvatarUrl: () => "mxc://adfsg", getMxcAvatarUrl: () => "mxc://adfsg",
}, },
{}, mockRemoteParticipant({}),
); );
const vm2 = createLocalMedia( const vm2 = createLocalMedia(

View File

@@ -38,6 +38,7 @@ import {
type MediaViewModel, type MediaViewModel,
ScreenShareViewModel, ScreenShareViewModel,
type UserMediaViewModel, type UserMediaViewModel,
type RemoteUserMediaViewModel,
} from "../state/MediaViewModel"; } from "../state/MediaViewModel";
import { useInitial } from "../useInitial"; import { useInitial } from "../useInitial";
import { useMergedRefs } from "../useMergedRefs"; import { useMergedRefs } from "../useMergedRefs";
@@ -84,6 +85,21 @@ const SpotlightLocalUserMediaItem: FC<SpotlightLocalUserMediaItemProps> = ({
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem"; SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
interface SpotlightRemoteUserMediaItemProps
extends SpotlightUserMediaItemBaseProps {
vm: RemoteUserMediaViewModel;
}
const SpotlightRemoteUserMediaItem: FC<SpotlightRemoteUserMediaItemProps> = ({
vm,
...props
}) => {
const waitingForMedia = useBehavior(vm.waitingForMedia$);
return (
<MediaView waitingForMedia={waitingForMedia} mirror={false} {...props} />
);
};
interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps { interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps {
vm: UserMediaViewModel; vm: UserMediaViewModel;
} }
@@ -103,7 +119,7 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
return vm instanceof LocalUserMediaViewModel ? ( return vm instanceof LocalUserMediaViewModel ? (
<SpotlightLocalUserMediaItem vm={vm} {...baseProps} /> <SpotlightLocalUserMediaItem vm={vm} {...baseProps} />
) : ( ) : (
<MediaView mirror={false} {...baseProps} /> <SpotlightRemoteUserMediaItem vm={vm} {...baseProps} />
); );
}; };

View File

@@ -319,12 +319,12 @@ export function mockLocalParticipant(
} }
export function createLocalMedia( export function createLocalMedia(
localRtcMember: CallMembership, rtcMember: CallMembership,
roomMember: Partial<RoomMember>, roomMember: Partial<RoomMember>,
localParticipant: LocalParticipant, localParticipant: LocalParticipant,
mediaDevices: MediaDevices, mediaDevices: MediaDevices,
): LocalUserMediaViewModel { ): LocalUserMediaViewModel {
const member = mockMatrixRoomMember(localRtcMember, roomMember); const member = mockMatrixRoomMember(rtcMember, roomMember);
return new LocalUserMediaViewModel( return new LocalUserMediaViewModel(
testScope(), testScope(),
"local", "local",
@@ -359,23 +359,26 @@ export function mockRemoteParticipant(
} }
export function createRemoteMedia( export function createRemoteMedia(
localRtcMember: CallMembership, rtcMember: CallMembership,
roomMember: Partial<RoomMember>, roomMember: Partial<RoomMember>,
participant: Partial<RemoteParticipant>, participant: RemoteParticipant | null,
livekitRoom: LivekitRoom | undefined = mockLivekitRoom(
{},
{
remoteParticipants$: of(participant ? [participant] : []),
},
),
): RemoteUserMediaViewModel { ): RemoteUserMediaViewModel {
const member = mockMatrixRoomMember(localRtcMember, roomMember); const member = mockMatrixRoomMember(rtcMember, roomMember);
const remoteParticipant = mockRemoteParticipant(participant);
return new RemoteUserMediaViewModel( return new RemoteUserMediaViewModel(
testScope(), testScope(),
"remote", "remote",
member.userId, member.userId,
of(remoteParticipant), constant(participant),
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
constant( constant(livekitRoom),
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
),
constant("https://rtc-example.org"), constant("https://rtc-example.org"),
constant(false), constant(false),
constant(member.rawDisplayName ?? "nodisplayname"), constant(member.rawDisplayName ?? "nodisplayname"),