mirror of
https://github.com/vector-im/element-call.git
synced 2026-03-07 05:47:03 +00:00
Merge pull request #3626 from robintown/non-publishing-participants
Don't show 'waiting for media' on connected participants
This commit is contained in:
@@ -358,7 +358,7 @@ export class Publisher {
|
||||
const track$ = scope.behavior(
|
||||
observeTrackReference$(room.localParticipant, Track.Source.Camera).pipe(
|
||||
map((trackRef) => {
|
||||
const track = trackRef?.publication?.track;
|
||||
const track = trackRef?.publication.track;
|
||||
return track instanceof LocalVideoTrack ? track : null;
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
ElementCallError,
|
||||
FailToGetOpenIdToken,
|
||||
} from "../../../utils/errors.ts";
|
||||
import { mockRemoteParticipant } from "../../../utils/test.ts";
|
||||
|
||||
let testScope: ObservableScope;
|
||||
|
||||
@@ -376,46 +377,32 @@ describe("Start connection states", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function fakeRemoteLivekitParticipant(
|
||||
id: string,
|
||||
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", () => {
|
||||
describe("remote participants", () => {
|
||||
it("emits the list of remote participants", () => {
|
||||
setupTest();
|
||||
|
||||
const connection = setupRemoteConnection();
|
||||
|
||||
const bobIsAPublisher = Promise.withResolvers<void>();
|
||||
const danIsAPublisher = Promise.withResolvers<void>();
|
||||
const observedPublishers: RemoteParticipant[][] = [];
|
||||
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();
|
||||
}
|
||||
},
|
||||
);
|
||||
const observedParticipants: RemoteParticipant[][] = [];
|
||||
const s = connection.remoteParticipants$.subscribe((participants) => {
|
||||
observedParticipants.push(participants);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
// 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
|
||||
// on this connection.
|
||||
|
||||
let participants: RemoteParticipant[] = [
|
||||
fakeRemoteLivekitParticipant("@alice:example.org:DEV000", 0),
|
||||
fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 0),
|
||||
fakeRemoteLivekitParticipant("@carol:example.org:DEV222", 0),
|
||||
fakeRemoteLivekitParticipant("@dan:example.org:DEV333", 0),
|
||||
const participants: RemoteParticipant[] = [
|
||||
mockRemoteParticipant({ identity: "@alice:example.org:DEV000" }),
|
||||
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||
mockRemoteParticipant({ identity: "@carol:example.org:DEV222" }),
|
||||
// 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
|
||||
@@ -427,21 +414,8 @@ describe("Publishing participants observations", () => {
|
||||
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
|
||||
);
|
||||
|
||||
// At this point there should be no publishers
|
||||
expect(observedPublishers.pop()!.length).toEqual(0);
|
||||
|
||||
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);
|
||||
// All remote participants should be present
|
||||
expect(observedParticipants.pop()!.length).toEqual(4);
|
||||
});
|
||||
|
||||
it("should be scoped to parent scope", (): void => {
|
||||
@@ -449,16 +423,14 @@ describe("Publishing participants observations", () => {
|
||||
|
||||
const connection = setupRemoteConnection();
|
||||
|
||||
let observedPublishers: RemoteParticipant[][] = [];
|
||||
const s = connection.remoteParticipantsWithTracks$.subscribe(
|
||||
(publishers) => {
|
||||
observedPublishers.push(publishers);
|
||||
},
|
||||
);
|
||||
let observedParticipants: RemoteParticipant[][] = [];
|
||||
const s = connection.remoteParticipants$.subscribe((participants) => {
|
||||
observedParticipants.push(participants);
|
||||
});
|
||||
onTestFinished(() => s.unsubscribe());
|
||||
|
||||
let participants: RemoteParticipant[] = [
|
||||
fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 0),
|
||||
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||
];
|
||||
|
||||
// Let's simulate 3 members on the livekitRoom
|
||||
@@ -470,35 +442,26 @@ describe("Publishing participants observations", () => {
|
||||
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, participant);
|
||||
}
|
||||
|
||||
// At this point there should be no publishers
|
||||
expect(observedPublishers.pop()!.length).toEqual(0);
|
||||
|
||||
participants = [fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 1)];
|
||||
|
||||
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");
|
||||
// We should have bob as a participant now
|
||||
const ps = observedParticipants.pop();
|
||||
expect(ps?.length).toEqual(1);
|
||||
expect(ps?.[0]?.identity).toEqual("@bob:example.org:DEV111");
|
||||
|
||||
// end the parent scope
|
||||
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(
|
||||
(p) => p.identity !== "@bob:example.org:DEV111",
|
||||
);
|
||||
|
||||
fakeLivekitRoom.emit(
|
||||
RoomEvent.ParticipantDisconnected,
|
||||
fakeRemoteLivekitParticipant("@bob:example.org:DEV111"),
|
||||
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||
);
|
||||
|
||||
expect(observedPublishers.length).toEqual(0);
|
||||
expect(observedParticipants.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
ConnectionError,
|
||||
type Room as LivekitRoom,
|
||||
type RemoteParticipant,
|
||||
RoomEvent,
|
||||
} from "livekit-client";
|
||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
@@ -96,11 +95,13 @@ export class Connection {
|
||||
private scope: ObservableScope;
|
||||
|
||||
/**
|
||||
* An observable of the participants that are publishing on this connection. (Excluding our local participant)
|
||||
* 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.
|
||||
* The remote LiveKit participants that are visible 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.
|
||||
@@ -231,23 +232,9 @@ export class Connection {
|
||||
this.transport = transport;
|
||||
this.client = client;
|
||||
|
||||
// REMOTE participants with track!!!
|
||||
// this.remoteParticipantsWithTracks$
|
||||
this.remoteParticipantsWithTracks$ = scope.behavior(
|
||||
// only tracks remote participants
|
||||
connectedParticipantsObserver(this.livekitRoom, {
|
||||
additionalRoomEvents: [
|
||||
RoomEvent.TrackPublished,
|
||||
RoomEvent.TrackUnpublished,
|
||||
],
|
||||
}).pipe(
|
||||
map((participants) => {
|
||||
return participants.filter(
|
||||
(participant) => participant.getTrackPublications().length > 0,
|
||||
);
|
||||
}),
|
||||
),
|
||||
[],
|
||||
this.remoteParticipants$ = scope.behavior(
|
||||
// Only tracks remote participants
|
||||
connectedParticipantsObserver(this.livekitRoom),
|
||||
);
|
||||
|
||||
scope.onEnd(() => {
|
||||
|
||||
@@ -52,7 +52,7 @@ beforeEach(() => {
|
||||
(transport: LivekitTransport, scope: ObservableScope) => {
|
||||
const mockConnection = {
|
||||
transport,
|
||||
remoteParticipantsWithTracks$: new BehaviorSubject([]),
|
||||
remoteParticipants$: new BehaviorSubject([]),
|
||||
} as unknown as Connection;
|
||||
vi.mocked(mockConnection).start = vi.fn();
|
||||
vi.mocked(mockConnection).stop = vi.fn();
|
||||
@@ -200,7 +200,7 @@ describe("connections$ 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[]>>;
|
||||
|
||||
function keyForTransport(transport: LivekitTransport): string {
|
||||
@@ -229,7 +229,7 @@ describe("connectionManagerData$ stream", () => {
|
||||
>([]);
|
||||
const mockConnection = {
|
||||
transport,
|
||||
remoteParticipantsWithTracks$: getRemoteParticipantsFor(transport),
|
||||
remoteParticipants$: getRemoteParticipantsFor(transport),
|
||||
} as unknown as Connection;
|
||||
vi.mocked(mockConnection).start = vi.fn();
|
||||
vi.mocked(mockConnection).stop = vi.fn();
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import {
|
||||
type LivekitTransport,
|
||||
type ParticipantId,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { combineLatest, map, of, switchMap, tap } from "rxjs";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type RemoteParticipant } from "livekit-client";
|
||||
@@ -57,34 +54,20 @@ export class ConnectionManagerData {
|
||||
const key = transport.livekit_service_url + "|" + transport.livekit_alias;
|
||||
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 {
|
||||
scope: ObservableScope;
|
||||
connectionFactory: ConnectionFactory;
|
||||
inputTransports$: Behavior<Epoch<LivekitTransport[]>>;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
// TODO - write test for scopes (do we really need to bind scope)
|
||||
export interface IConnectionManager {
|
||||
connectionManagerData$: Behavior<Epoch<ConnectionManagerData>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crete a `ConnectionManager`
|
||||
* @param scope the observable scope used by this object.
|
||||
@@ -169,7 +152,7 @@ export function createConnectionManager$({
|
||||
// Map the connections to list of {connection, participants}[]
|
||||
const listOfConnectionsWithRemoteParticipants = connections.value.map(
|
||||
(connection) => {
|
||||
return connection.remoteParticipantsWithTracks$.pipe(
|
||||
return connection.remoteParticipants$.pipe(
|
||||
map((participants) => ({
|
||||
connection,
|
||||
participants,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
createLocalMedia,
|
||||
createRemoteMedia,
|
||||
withTestScheduler,
|
||||
mockRemoteParticipant,
|
||||
} from "../utils/test";
|
||||
import { getValue } from "../utils/observable";
|
||||
import { constant } from "./Behavior";
|
||||
@@ -44,7 +45,11 @@ const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
|
||||
|
||||
test("control a participant's volume", () => {
|
||||
const setVolumeSpy = vi.fn();
|
||||
const vm = createRemoteMedia(rtcMembership, {}, { setVolume: setVolumeSpy });
|
||||
const vm = createRemoteMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockRemoteParticipant({ setVolume: setVolumeSpy }),
|
||||
);
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-ab---c---d|", {
|
||||
a() {
|
||||
@@ -88,7 +93,7 @@ test("control a participant's volume", () => {
|
||||
});
|
||||
|
||||
test("toggle fit/contain for a participant's video", () => {
|
||||
const vm = createRemoteMedia(rtcMembership, {}, {});
|
||||
const vm = createRemoteMedia(rtcMembership, {}, mockRemoteParticipant({}));
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-ab|", {
|
||||
a: () => vm.toggleFitContain(),
|
||||
@@ -199,3 +204,35 @@ test("switch cameras", async () => {
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
type AudioSource,
|
||||
type TrackReferenceOrPlaceholder,
|
||||
type VideoSource,
|
||||
type TrackReference,
|
||||
observeParticipantEvents,
|
||||
observeParticipantMedia,
|
||||
roomEventSelector,
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
type Observable,
|
||||
Subject,
|
||||
combineLatest,
|
||||
distinctUntilKeyChanged,
|
||||
filter,
|
||||
fromEvent,
|
||||
interval,
|
||||
@@ -60,14 +59,11 @@ import { type ObservableScope } from "./ObservableScope";
|
||||
export function observeTrackReference$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<TrackReferenceOrPlaceholder> {
|
||||
): Observable<TrackReference | undefined> {
|
||||
return observeParticipantMedia(participant).pipe(
|
||||
map(() => ({
|
||||
participant: participant,
|
||||
publication: participant.getTrackPublication(source),
|
||||
source,
|
||||
})),
|
||||
distinctUntilKeyChanged("publication"),
|
||||
map(() => participant.getTrackPublication(source)),
|
||||
distinctUntilChanged(),
|
||||
map((publication) => publication && { participant, publication, source }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -226,7 +222,7 @@ abstract class BaseMediaViewModel {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -241,10 +237,12 @@ abstract class BaseMediaViewModel {
|
||||
|
||||
private observeTrackReference$(
|
||||
source: Track.Source,
|
||||
): Behavior<TrackReferenceOrPlaceholder | null> {
|
||||
): Behavior<TrackReference | undefined> {
|
||||
return this.scope.behavior(
|
||||
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,
|
||||
audioSource: AudioSource,
|
||||
videoSource: VideoSource,
|
||||
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||
protected readonly livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||
public readonly focusUrl$: Behavior<string | undefined>,
|
||||
public readonly displayName$: Behavior<string>,
|
||||
public readonly mxcAvatarUrl$: Behavior<string | undefined>,
|
||||
@@ -281,8 +279,8 @@ abstract class BaseMediaViewModel {
|
||||
[audio$, this.video$],
|
||||
(a, v) =>
|
||||
encryptionSystem.kind !== E2eeType.NONE &&
|
||||
(a?.publication?.isEncrypted === false ||
|
||||
v?.publication?.isEncrypted === false),
|
||||
(a?.publication.isEncrypted === false ||
|
||||
v?.publication.isEncrypted === false),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -471,7 +469,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
private readonly videoTrack$: Observable<LocalVideoTrack | null> =
|
||||
this.video$.pipe(
|
||||
switchMap((v) => {
|
||||
const track = v?.publication?.track;
|
||||
const track = v?.publication.track;
|
||||
if (!(track instanceof LocalVideoTrack)) return of(null);
|
||||
return merge(
|
||||
// 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.
|
||||
*/
|
||||
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
|
||||
private __speaking$: Behavior<boolean>;
|
||||
public get speaking$(): Behavior<boolean> {
|
||||
|
||||
@@ -12,7 +12,11 @@ import { axe } from "vitest-axe";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { GridTile } from "./GridTile";
|
||||
import { mockRtcMembership, createRemoteMedia } from "../utils/test";
|
||||
import {
|
||||
mockRtcMembership,
|
||||
createRemoteMedia,
|
||||
mockRemoteParticipant,
|
||||
} from "../utils/test";
|
||||
import { GridTileViewModel } from "../state/TileViewModel";
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
@@ -31,11 +35,11 @@ test("GridTile is accessible", async () => {
|
||||
rawDisplayName: "Alice",
|
||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||
},
|
||||
{
|
||||
mockRemoteParticipant({
|
||||
setVolume() {},
|
||||
getTrackPublication: () =>
|
||||
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const fakeRtcSession = {
|
||||
|
||||
@@ -69,6 +69,7 @@ interface UserMediaTileProps extends TileProps {
|
||||
vm: UserMediaViewModel;
|
||||
mirror: boolean;
|
||||
locallyMuted: boolean;
|
||||
waitingForMedia?: boolean;
|
||||
primaryButton?: ReactNode;
|
||||
menuStart?: ReactNode;
|
||||
menuEnd?: ReactNode;
|
||||
@@ -79,6 +80,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
vm,
|
||||
showSpeakingIndicators,
|
||||
locallyMuted,
|
||||
waitingForMedia,
|
||||
primaryButton,
|
||||
menuStart,
|
||||
menuEnd,
|
||||
@@ -148,7 +150,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
const tile = (
|
||||
<MediaView
|
||||
ref={ref}
|
||||
video={video ?? undefined}
|
||||
video={video}
|
||||
userId={vm.userId}
|
||||
unencryptedWarning={unencryptedWarning}
|
||||
encryptionStatus={encryptionStatus}
|
||||
@@ -194,7 +196,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
raisedHandTime={handRaised ?? undefined}
|
||||
currentReaction={reaction ?? undefined}
|
||||
raisedHandOnClick={raisedHandOnClick}
|
||||
localParticipant={vm.local}
|
||||
waitingForMedia={waitingForMedia}
|
||||
focusUrl={focusUrl}
|
||||
audioStreamStats={audioStreamStats}
|
||||
videoStreamStats={videoStreamStats}
|
||||
@@ -290,6 +292,7 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const waitingForMedia = useBehavior(vm.waitingForMedia$);
|
||||
const locallyMuted = useBehavior(vm.locallyMuted$);
|
||||
const localVolume = useBehavior(vm.localVolume$);
|
||||
const onSelectMute = useCallback(
|
||||
@@ -311,6 +314,7 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
||||
<UserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
waitingForMedia={waitingForMedia}
|
||||
locallyMuted={locallyMuted}
|
||||
mirror={false}
|
||||
menuStart={
|
||||
|
||||
@@ -47,7 +47,6 @@ describe("MediaView", () => {
|
||||
video: trackReference,
|
||||
userId: "@alice:example.com",
|
||||
mxcAvatarUrl: undefined,
|
||||
localParticipant: false,
|
||||
focusable: true,
|
||||
};
|
||||
|
||||
@@ -66,24 +65,13 @@ describe("MediaView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("with no participant", () => {
|
||||
it("shows avatar for local user", () => {
|
||||
render(
|
||||
<MediaView {...baseProps} video={undefined} localParticipant={true} />,
|
||||
);
|
||||
describe("with no video", () => {
|
||||
it("shows avatar", () => {
|
||||
render(<MediaView {...baseProps} video={undefined} />);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "@alice:example.com" }),
|
||||
).toBeVisible();
|
||||
expect(screen.queryAllByText("Waiting for media...").length).toBe(0);
|
||||
});
|
||||
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();
|
||||
expect(screen.queryByTestId("video")).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
test("is shown and accessible", async () => {
|
||||
const { container } = render(
|
||||
|
||||
@@ -43,7 +43,7 @@ interface Props extends ComponentProps<typeof animated.div> {
|
||||
raisedHandTime?: Date;
|
||||
currentReaction?: ReactionOption;
|
||||
raisedHandOnClick?: () => void;
|
||||
localParticipant: boolean;
|
||||
waitingForMedia?: boolean;
|
||||
audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||
// The focus url, mainly for debugging purposes
|
||||
@@ -71,7 +71,7 @@ export const MediaView: FC<Props> = ({
|
||||
raisedHandTime,
|
||||
currentReaction,
|
||||
raisedHandOnClick,
|
||||
localParticipant,
|
||||
waitingForMedia,
|
||||
audioStreamStats,
|
||||
videoStreamStats,
|
||||
focusUrl,
|
||||
@@ -129,7 +129,7 @@ export const MediaView: FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!video && !localParticipant && (
|
||||
{waitingForMedia && (
|
||||
<div className={styles.status}>
|
||||
{t("video_tile.waiting_for_media")}
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
mockRtcMembership,
|
||||
createLocalMedia,
|
||||
createRemoteMedia,
|
||||
mockRemoteParticipant,
|
||||
} from "../utils/test";
|
||||
import { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
import { constant } from "../state/Behavior";
|
||||
@@ -33,7 +34,7 @@ test("SpotlightTile is accessible", async () => {
|
||||
rawDisplayName: "Alice",
|
||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||
},
|
||||
{},
|
||||
mockRemoteParticipant({}),
|
||||
);
|
||||
|
||||
const vm2 = createLocalMedia(
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
type MediaViewModel,
|
||||
ScreenShareViewModel,
|
||||
type UserMediaViewModel,
|
||||
type RemoteUserMediaViewModel,
|
||||
} from "../state/MediaViewModel";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
@@ -84,6 +85,21 @@ const SpotlightLocalUserMediaItem: FC<SpotlightLocalUserMediaItemProps> = ({
|
||||
|
||||
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 {
|
||||
vm: UserMediaViewModel;
|
||||
}
|
||||
@@ -103,7 +119,7 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
||||
return vm instanceof LocalUserMediaViewModel ? (
|
||||
<SpotlightLocalUserMediaItem vm={vm} {...baseProps} />
|
||||
) : (
|
||||
<MediaView mirror={false} {...baseProps} />
|
||||
<SpotlightRemoteUserMediaItem vm={vm} {...baseProps} />
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -319,12 +319,12 @@ export function mockLocalParticipant(
|
||||
}
|
||||
|
||||
export function createLocalMedia(
|
||||
localRtcMember: CallMembership,
|
||||
rtcMember: CallMembership,
|
||||
roomMember: Partial<RoomMember>,
|
||||
localParticipant: LocalParticipant,
|
||||
mediaDevices: MediaDevices,
|
||||
): LocalUserMediaViewModel {
|
||||
const member = mockMatrixRoomMember(localRtcMember, roomMember);
|
||||
const member = mockMatrixRoomMember(rtcMember, roomMember);
|
||||
return new LocalUserMediaViewModel(
|
||||
testScope(),
|
||||
"local",
|
||||
@@ -359,23 +359,26 @@ export function mockRemoteParticipant(
|
||||
}
|
||||
|
||||
export function createRemoteMedia(
|
||||
localRtcMember: CallMembership,
|
||||
rtcMember: CallMembership,
|
||||
roomMember: Partial<RoomMember>,
|
||||
participant: Partial<RemoteParticipant>,
|
||||
participant: RemoteParticipant | null,
|
||||
livekitRoom: LivekitRoom | undefined = mockLivekitRoom(
|
||||
{},
|
||||
{
|
||||
remoteParticipants$: of(participant ? [participant] : []),
|
||||
},
|
||||
),
|
||||
): RemoteUserMediaViewModel {
|
||||
const member = mockMatrixRoomMember(localRtcMember, roomMember);
|
||||
const remoteParticipant = mockRemoteParticipant(participant);
|
||||
const member = mockMatrixRoomMember(rtcMember, roomMember);
|
||||
return new RemoteUserMediaViewModel(
|
||||
testScope(),
|
||||
"remote",
|
||||
member.userId,
|
||||
of(remoteParticipant),
|
||||
constant(participant),
|
||||
{
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
},
|
||||
constant(
|
||||
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
|
||||
),
|
||||
constant(livekitRoom),
|
||||
constant("https://rtc-example.org"),
|
||||
constant(false),
|
||||
constant(member.rawDisplayName ?? "nodisplayname"),
|
||||
|
||||
Reference in New Issue
Block a user