Always show all call memberships from the room state

This commit is contained in:
Hugh Nimmo-Smith
2024-08-05 14:56:50 +01:00
parent eaf3fb13c1
commit 30253b0c19
13 changed files with 316 additions and 133 deletions

View File

@@ -102,6 +102,7 @@
"microphone_off": "Microphone off", "microphone_off": "Microphone off",
"microphone_on": "Microphone on", "microphone_on": "Microphone on",
"mute_microphone_button_label": "Mute microphone", "mute_microphone_button_label": "Mute microphone",
"no_media_available": "No media available",
"participant_count_one": "{{count, number}}", "participant_count_one": "{{count, number}}",
"participant_count_other": "{{count, number}}", "participant_count_other": "{{count, number}}",
"rageshake_button_error_caption": "Retry sending logs", "rageshake_button_error_caption": "Retry sending logs",
@@ -126,6 +127,7 @@
"return_home_button": "Return to home screen", "return_home_button": "Return to home screen",
"room_auth_view_eula_caption": "By clicking \"Join call now\", you agree to our <2>End User Licensing Agreement (EULA)</2>", "room_auth_view_eula_caption": "By clicking \"Join call now\", you agree to our <2>End User Licensing Agreement (EULA)</2>",
"room_auth_view_join_button": "Join call now", "room_auth_view_join_button": "Join call now",
"room_member_not_found": "Profile information for this participant is not available",
"screenshare_button_label": "Share screen", "screenshare_button_label": "Share screen",
"select_input_unset_button": "Select an option", "select_input_unset_button": "Select an option",
"settings": { "settings": {

View File

@@ -15,11 +15,12 @@ limitations under the License.
*/ */
import classNames from "classnames"; import classNames from "classnames";
import { FC, HTMLAttributes, ReactNode, forwardRef } from "react"; import { FC, HTMLAttributes, ReactNode, forwardRef, useMemo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Heading, Text } from "@vector-im/compound-web"; import { Heading, Text, Tooltip } from "@vector-im/compound-web";
import { UserProfileIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { UserProfileIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { CallMembership } from "matrix-js-sdk/src/matrixrtc";
import styles from "./Header.module.css"; import styles from "./Header.module.css";
import Logo from "./icons/Logo.svg?react"; import Logo from "./icons/Logo.svg?react";
@@ -125,7 +126,7 @@ interface RoomHeaderInfoProps {
name: string; name: string;
avatarUrl: string | null; avatarUrl: string | null;
encrypted: boolean; encrypted: boolean;
participantCount: number | null; memberships: CallMembership[] | null;
} }
export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
@@ -133,11 +134,18 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
name, name,
avatarUrl, avatarUrl,
encrypted, encrypted,
participantCount, memberships,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const size = useMediaQuery("(max-width: 550px)") ? "sm" : "lg"; const size = useMediaQuery("(max-width: 550px)") ? "sm" : "lg";
// Count each member only once, regardless of how many devices they use
const uniqueUsers = useMemo(
() =>
memberships ? new Set<string>(memberships.map((m) => m.sender!)) : null,
[memberships],
);
return ( return (
<div className={styles.roomHeaderInfo} data-size={size}> <div className={styles.roomHeaderInfo} data-size={size}>
<Avatar <Avatar
@@ -158,17 +166,21 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
</Heading> </Heading>
<EncryptionLock encrypted={encrypted} /> <EncryptionLock encrypted={encrypted} />
</div> </div>
{(participantCount ?? 0) > 0 && ( {uniqueUsers && uniqueUsers.size > 0 && (
<div className={styles.participantsLine}> <Tooltip
<UserProfileIcon label={`${uniqueUsers.size} users with ${memberships?.length} devices:\n${[...uniqueUsers.values()].join(", ")}`}
width={20} >
height={20} <div className={styles.participantsLine}>
aria-label={t("header_participants_label")} <UserProfileIcon
/> width={20}
<Text as="span" size="sm" weight="medium"> height={20}
{t("participant_count", { count: participantCount ?? 0 })} aria-label={t("header_participants_label")}
</Text> />
</div> <Text as="span" size="sm" weight="medium">
{t("participant_count", { count: uniqueUsers.size })}
</Text>
</div>
</Tooltip>
)} )}
</div> </div>
); );

View File

@@ -53,7 +53,7 @@ export interface CallLayoutInputs {
export interface GridTileModel { export interface GridTileModel {
type: "grid"; type: "grid";
vm: UserMediaViewModel; vm: MediaViewModel;
} }
export interface SpotlightTileModel { export interface SpotlightTileModel {

View File

@@ -121,12 +121,6 @@ export const GroupCallView: FC<Props> = ({
e2eeSystem, e2eeSystem,
]); ]);
// Count each member only once, regardless of how many devices they use
const participantCount = useMemo(
() => new Set<string>(memberships.map((m) => m.sender!)).size,
[memberships],
);
const deviceContext = useMediaDevices(); const deviceContext = useMediaDevices();
const latestDevices = useRef<MediaDevices>(); const latestDevices = useRef<MediaDevices>();
latestDevices.current = deviceContext; latestDevices.current = deviceContext;
@@ -319,7 +313,7 @@ export const GroupCallView: FC<Props> = ({
onEnter={() => void enterRTCSession(rtcSession, perParticipantE2EE)} onEnter={() => void enterRTCSession(rtcSession, perParticipantE2EE)}
confineToRoom={confineToRoom} confineToRoom={confineToRoom}
hideHeader={hideHeader} hideHeader={hideHeader}
participantCount={participantCount} memberships={memberships}
onShareClick={onShareClick} onShareClick={onShareClick}
/> />
</> </>
@@ -333,7 +327,6 @@ export const GroupCallView: FC<Props> = ({
client={client} client={client}
matrixInfo={matrixInfo} matrixInfo={matrixInfo}
rtcSession={rtcSession} rtcSession={rtcSession}
participantCount={participantCount}
onLeave={onLeave} onLeave={onLeave}
hideHeader={hideHeader} hideHeader={hideHeader}
muteStates={muteStates} muteStates={muteStates}

View File

@@ -124,7 +124,6 @@ export interface InCallViewProps {
rtcSession: MatrixRTCSession; rtcSession: MatrixRTCSession;
livekitRoom: Room; livekitRoom: Room;
muteStates: MuteStates; muteStates: MuteStates;
participantCount: number;
onLeave: (error?: Error) => void; onLeave: (error?: Error) => void;
hideHeader: boolean; hideHeader: boolean;
otelGroupCallMembership?: OTelGroupCallMembership; otelGroupCallMembership?: OTelGroupCallMembership;
@@ -138,7 +137,6 @@ export const InCallView: FC<InCallViewProps> = ({
rtcSession, rtcSession,
livekitRoom, livekitRoom,
muteStates, muteStates,
participantCount,
onLeave, onLeave,
hideHeader, hideHeader,
connState, connState,
@@ -190,7 +188,7 @@ export const InCallView: FC<InCallViewProps> = ({
const noControls = reducedControls && bounds.height <= 400; const noControls = reducedControls && bounds.height <= 400;
const vm = useCallViewModel( const vm = useCallViewModel(
rtcSession.room, rtcSession,
livekitRoom, livekitRoom,
matrixInfo.e2eeSystem.kind !== E2eeType.NONE, matrixInfo.e2eeSystem.kind !== E2eeType.NONE,
connState, connState,
@@ -513,8 +511,8 @@ export const InCallView: FC<InCallViewProps> = ({
name={matrixInfo.roomName} name={matrixInfo.roomName}
avatarUrl={matrixInfo.roomAvatar} avatarUrl={matrixInfo.roomAvatar}
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE} encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
participantCount={participantCount} memberships={rtcSession.memberships}
/> />
</LeftNav> </LeftNav>
<RightNav> <RightNav>
{!reducedControls && showControls && onShareClick !== null && ( {!reducedControls && showControls && onShareClick !== null && (

View File

@@ -20,6 +20,7 @@ import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { Button, Link } from "@vector-im/compound-web"; import { Button, Link } from "@vector-im/compound-web";
import classNames from "classnames"; import classNames from "classnames";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { CallMembership } from "matrix-js-sdk/src/matrixrtc";
import inCallStyles from "./InCallView.module.css"; import inCallStyles from "./InCallView.module.css";
import styles from "./LobbyView.module.css"; import styles from "./LobbyView.module.css";
@@ -46,7 +47,7 @@ interface Props {
enterLabel?: JSX.Element | string; enterLabel?: JSX.Element | string;
confineToRoom: boolean; confineToRoom: boolean;
hideHeader: boolean; hideHeader: boolean;
participantCount: number | null; memberships: CallMembership[] | null;
onShareClick: (() => void) | null; onShareClick: (() => void) | null;
waitingForInvite?: boolean; waitingForInvite?: boolean;
} }
@@ -59,7 +60,7 @@ export const LobbyView: FC<Props> = ({
enterLabel, enterLabel,
confineToRoom, confineToRoom,
hideHeader, hideHeader,
participantCount, memberships,
onShareClick, onShareClick,
waitingForInvite, waitingForInvite,
}) => { }) => {
@@ -110,7 +111,7 @@ export const LobbyView: FC<Props> = ({
name={matrixInfo.roomName} name={matrixInfo.roomName}
avatarUrl={matrixInfo.roomAvatar} avatarUrl={matrixInfo.roomAvatar}
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE} encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
participantCount={participantCount} memberships={memberships}
/> />
</LeftNav> </LeftNav>
<RightNav> <RightNav>

View File

@@ -142,7 +142,7 @@ export const RoomPage: FC = () => {
waitingForInvite={groupCallState.kind === "waitForInvite"} waitingForInvite={groupCallState.kind === "waitForInvite"}
confineToRoom={confineToRoom} confineToRoom={confineToRoom}
hideHeader={hideHeader} hideHeader={hideHeader}
participantCount={null} memberships={null}
muteStates={muteStates} muteStates={muteStates}
onShareClick={null} onShareClick={null}
/> />

View File

@@ -52,6 +52,7 @@ import {
zip, zip,
} from "rxjs"; } from "rxjs";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { MatrixRTCSession, CallMembership } from "matrix-js-sdk/src/matrixrtc";
import { ViewModel } from "./ViewModel"; import { ViewModel } from "./ViewModel";
import { useObservable } from "./useObservable"; import { useObservable } from "./useObservable";
@@ -63,6 +64,7 @@ import { usePrevious } from "../usePrevious";
import { import {
LocalUserMediaViewModel, LocalUserMediaViewModel,
MediaViewModel, MediaViewModel,
MembershipOnlyViewModel,
RemoteUserMediaViewModel, RemoteUserMediaViewModel,
ScreenShareViewModel, ScreenShareViewModel,
UserMediaViewModel, UserMediaViewModel,
@@ -78,7 +80,7 @@ const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
export interface GridLayout { export interface GridLayout {
type: "grid"; type: "grid";
spotlight?: MediaViewModel[]; spotlight?: MediaViewModel[];
grid: UserMediaViewModel[]; grid: MediaViewModel[];
} }
export interface SpotlightLandscapeLayout { export interface SpotlightLandscapeLayout {
@@ -102,7 +104,7 @@ export interface SpotlightExpandedLayout {
export interface OneOnOneLayout { export interface OneOnOneLayout {
type: "one-on-one"; type: "one-on-one";
local: LocalUserMediaViewModel; local: LocalUserMediaViewModel;
remote: RemoteUserMediaViewModel; remote: UserMediaViewModel;
} }
export interface PipLayout { export interface PipLayout {
@@ -163,15 +165,35 @@ class UserMedia {
public readonly presenter: Observable<boolean>; public readonly presenter: Observable<boolean>;
public constructor( public constructor(
public readonly id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant, participant: LocalParticipant | RemoteParticipant,
callEncrypted: boolean, callEncrypted: boolean,
);
public constructor(
id: string,
member: RoomMember | undefined,
callMembership: CallMembership,
);
public constructor(
public readonly id: string,
public readonly member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant | CallMembership,
callEncrypted: boolean = false,
) { ) {
this.vm = if (participant instanceof CallMembership) {
participant instanceof LocalParticipant this.vm = new MembershipOnlyViewModel(participant, member);
? new LocalUserMediaViewModel(id, member, participant, callEncrypted) } else {
: new RemoteUserMediaViewModel(id, member, participant, callEncrypted); this.vm =
participant instanceof LocalParticipant
? new LocalUserMediaViewModel(id, member, participant, callEncrypted)
: new RemoteUserMediaViewModel(
id,
member,
participant,
callEncrypted,
);
}
this.speaker = this.vm.speaking.pipe( this.speaker = this.vm.speaking.pipe(
// Require 1 s of continuous speaking to become a speaker, and 60 s of // Require 1 s of continuous speaking to become a speaker, and 60 s of
@@ -192,13 +214,17 @@ class UserMedia {
shareReplay(1), shareReplay(1),
); );
this.presenter = observeParticipantEvents( if (participant instanceof CallMembership) {
participant, this.presenter = of(false);
ParticipantEvent.TrackPublished, } else {
ParticipantEvent.TrackUnpublished, this.presenter = observeParticipantEvents(
ParticipantEvent.LocalTrackPublished, participant,
ParticipantEvent.LocalTrackUnpublished, ParticipantEvent.TrackPublished,
).pipe(map((p) => p.isScreenShareEnabled)); ParticipantEvent.TrackUnpublished,
ParticipantEvent.LocalTrackPublished,
ParticipantEvent.LocalTrackUnpublished,
).pipe(map((p) => p.isScreenShareEnabled));
}
} }
public destroy(): void { public destroy(): void {
@@ -226,6 +252,22 @@ class ScreenShare {
type MediaItem = UserMedia | ScreenShare; type MediaItem = UserMedia | ScreenShare;
function matrixUserIdFromParticipantId(id: string): string | undefined {
if (!id) return undefined;
const parts = id.split(":");
// must be at least 3 parts because we know the first part is a userId which must necessarily contain a colon
if (parts.length < 3) {
logger.warn(
`Livekit participants ID doesn't look like a userId:deviceId combination: ${id}`,
);
return undefined;
}
parts.pop();
const userId = parts.join(":");
return userId;
}
function findMatrixMember( function findMatrixMember(
room: MatrixRoom, room: MatrixRoom,
id: string, id: string,
@@ -233,18 +275,12 @@ function findMatrixMember(
if (id === "local") if (id === "local")
return room.getMember(room.client.getUserId()!) ?? undefined; return room.getMember(room.client.getUserId()!) ?? undefined;
const parts = id.split(":"); const userId = matrixUserIdFromParticipantId(id);
// must be at least 3 parts because we know the first part is a userId which must necessarily contain a colon
if (parts.length < 3) { if (!userId) {
logger.warn(
"Livekit participants ID doesn't look like a userId:deviceId combination",
);
return undefined; return undefined;
} }
parts.pop();
const userId = parts.join(":");
return room.getMember(userId) ?? undefined; return room.getMember(userId) ?? undefined;
} }
@@ -322,43 +358,88 @@ export class CallViewModel extends ViewModel {
}, },
); );
private readonly membershipsWithoutParticipant = combineLatest([
of(this.rtcSession.memberships),
this.remoteParticipants,
of(this.livekitRoom.localParticipant),
]).pipe(
scan((prev, [memberships, remoteParticipants, localParticipant]) => {
const participantIds = new Set(
remoteParticipants.map((p) =>
matrixUserIdFromParticipantId(p.identity),
),
);
participantIds.add(
matrixUserIdFromParticipantId(localParticipant.identity),
);
return memberships.filter((m) => !participantIds.has(m.sender ?? ""));
}, [] as CallMembership[]),
);
private readonly mediaItems: Observable<MediaItem[]> = combineLatest([ private readonly mediaItems: Observable<MediaItem[]> = combineLatest([
this.remoteParticipants, this.remoteParticipants,
observeParticipantMedia(this.livekitRoom.localParticipant), observeParticipantMedia(this.livekitRoom.localParticipant),
duplicateTiles.value, duplicateTiles.value,
this.membershipsWithoutParticipant,
]).pipe( ]).pipe(
scan( scan(
( (
prevItems, prevItems,
[remoteParticipants, { participant: localParticipant }, duplicateTiles], [
remoteParticipants,
{ participant: localParticipant },
duplicateTiles,
membershipsWithoutParticipant,
],
) => { ) => {
const newItems = new Map( const newItems = new Map(
function* (this: CallViewModel): Iterable<[string, MediaItem]> { function* (this: CallViewModel): Iterable<[string, MediaItem]> {
for (const p of [localParticipant, ...remoteParticipants]) { for (const p of [
const userMediaId = p === localParticipant ? "local" : p.identity; localParticipant,
const member = findMatrixMember(this.matrixRoom, userMediaId); ...remoteParticipants,
if (member === undefined) ...membershipsWithoutParticipant,
logger.warn( ]) {
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`, if (p instanceof CallMembership) {
); const member =
this.matrixRoom.getMember(p.sender!) ?? undefined;
// Create as many tiles for this participant as called for by
// the duplicateTiles option
for (let i = 0; i < 1 + duplicateTiles; i++) {
const userMediaId = `${p.identity}:${i}`;
yield [ yield [
userMediaId, p.sender!,
prevItems.get(userMediaId) ?? prevItems.get(p.sender!) ??
new UserMedia(userMediaId, member, p, this.encrypted), new UserMedia(p.sender!, member, p),
]; ];
} else {
const member = findMatrixMember(
this.matrixRoom,
p === localParticipant ? "local" : p.identity,
);
if (member === undefined)
logger.warn(
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`,
);
if (p.isScreenShareEnabled) { // Create as many tiles for this participant as called for by
const screenShareId = `${userMediaId}:screen-share`; // the duplicateTiles option
for (let i = 0; i < 1; i++) {
const userMediaId = `${p.identity}:${i}`;
yield [ yield [
screenShareId, userMediaId,
prevItems.get(screenShareId) ?? prevItems.get(userMediaId) ??
new ScreenShare(screenShareId, member, p, this.encrypted), new UserMedia(userMediaId, member, p, this.encrypted),
]; ];
if (p.isScreenShareEnabled) {
const screenShareId = `${userMediaId}:screen-share`;
yield [
screenShareId,
prevItems.get(screenShareId) ??
new ScreenShare(
screenShareId,
member,
p,
this.encrypted,
),
];
}
} }
} }
} }
@@ -565,7 +646,7 @@ export class CallViewModel extends ViewModel {
switchMap((windowMode) => { switchMap((windowMode) => {
const spotlightLandscapeLayout = combineLatest( const spotlightLandscapeLayout = combineLatest(
[this.grid, this.spotlight], [this.grid, this.spotlight],
(grid, spotlight): Layout => ({ (grid, spotlight): SpotlightLandscapeLayout => ({
type: "spotlight-landscape", type: "spotlight-landscape",
spotlight, spotlight,
grid, grid,
@@ -573,7 +654,7 @@ export class CallViewModel extends ViewModel {
); );
const spotlightExpandedLayout = combineLatest( const spotlightExpandedLayout = combineLatest(
[this.spotlight, this.pip], [this.spotlight, this.pip],
(spotlight, pip): Layout => ({ (spotlight, pip): SpotlightExpandedLayout => ({
type: "spotlight-expanded", type: "spotlight-expanded",
spotlight, spotlight,
pip: pip ?? undefined, pip: pip ?? undefined,
@@ -625,7 +706,7 @@ export class CallViewModel extends ViewModel {
case "narrow": case "narrow":
return combineLatest( return combineLatest(
[this.grid, this.spotlight], [this.grid, this.spotlight],
(grid, spotlight): Layout => ({ (grid, spotlight): SpotlightPortraitLayout => ({
type: "spotlight-portrait", type: "spotlight-portrait",
spotlight, spotlight,
grid, grid,
@@ -646,7 +727,7 @@ export class CallViewModel extends ViewModel {
); );
case "pip": case "pip":
return this.spotlight.pipe( return this.spotlight.pipe(
map((spotlight): Layout => ({ type: "pip", spotlight })), map((spotlight): PipLayout => ({ type: "pip", spotlight })),
); );
} }
}), }),
@@ -684,9 +765,13 @@ export class CallViewModel extends ViewModel {
shareReplay(1), shareReplay(1),
); );
private get matrixRoom(): MatrixRoom {
return this.rtcSession.room;
}
public constructor( public constructor(
// A call is permanently tied to a single Matrix room and LiveKit room // A call is permanently tied to a single Matrix room and LiveKit room
private readonly matrixRoom: MatrixRoom, private readonly rtcSession: MatrixRTCSession,
private readonly livekitRoom: LivekitRoom, private readonly livekitRoom: LivekitRoom,
private readonly encrypted: boolean, private readonly encrypted: boolean,
private readonly connectionState: Observable<ECConnectionState>, private readonly connectionState: Observable<ECConnectionState>,
@@ -696,25 +781,25 @@ export class CallViewModel extends ViewModel {
} }
export function useCallViewModel( export function useCallViewModel(
matrixRoom: MatrixRoom, rtcSession: MatrixRTCSession,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
encrypted: boolean, encrypted: boolean,
connectionState: ECConnectionState, connectionState: ECConnectionState,
): CallViewModel { ): CallViewModel {
const prevMatrixRoom = usePrevious(matrixRoom); const prevRTCSession = usePrevious(rtcSession);
const prevLivekitRoom = usePrevious(livekitRoom); const prevLivekitRoom = usePrevious(livekitRoom);
const prevEncrypted = usePrevious(encrypted); const prevEncrypted = usePrevious(encrypted);
const connectionStateObservable = useObservable(connectionState); const connectionStateObservable = useObservable(connectionState);
const vm = useRef<CallViewModel>(); const vm = useRef<CallViewModel>();
if ( if (
matrixRoom !== prevMatrixRoom || rtcSession !== prevRTCSession ||
livekitRoom !== prevLivekitRoom || livekitRoom !== prevLivekitRoom ||
encrypted !== prevEncrypted encrypted !== prevEncrypted
) { ) {
vm.current?.destroy(); vm.current?.destroy();
vm.current = new CallViewModel( vm.current = new CallViewModel(
matrixRoom, rtcSession,
livekitRoom, livekitRoom,
encrypted, encrypted,
connectionStateObservable, connectionStateObservable,

View File

@@ -46,6 +46,7 @@ import {
switchMap, switchMap,
} from "rxjs"; } from "rxjs";
import { useEffect } from "react"; import { useEffect } from "react";
import { CallMembership } from "matrix-js-sdk/src/matrixrtc";
import { ViewModel } from "./ViewModel"; import { ViewModel } from "./ViewModel";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
@@ -92,11 +93,14 @@ abstract class BaseMediaViewModel extends ViewModel {
/** /**
* Whether the media belongs to the local user. * Whether the media belongs to the local user.
*/ */
public readonly local = this.participant.isLocal; public get local(): boolean {
return this.participant?.isLocal ?? false;
}
/** /**
* The LiveKit video track for this media. * The LiveKit video track for this media.
*/ */
public readonly video: Observable<TrackReferenceOrPlaceholder>; public readonly video: Observable<TrackReferenceOrPlaceholder | undefined>;
/** /**
* Whether there should be a warning that this media is unencrypted. * Whether there should be a warning that this media is unencrypted.
*/ */
@@ -113,21 +117,29 @@ abstract class BaseMediaViewModel extends ViewModel {
// TODO: Fully separate the data layer from the UI layer by keeping the // TODO: Fully separate the data layer from the UI layer by keeping the
// member object internal // member object internal
public readonly member: RoomMember | undefined, public readonly member: RoomMember | undefined,
protected readonly participant: LocalParticipant | RemoteParticipant, protected readonly participant:
| LocalParticipant
| RemoteParticipant
| undefined,
callEncrypted: boolean, callEncrypted: boolean,
audioSource: AudioSource, audioSource: AudioSource,
videoSource: VideoSource, videoSource: VideoSource,
) { ) {
super(); super();
const audio = observeTrackReference(participant, audioSource); if (participant) {
this.video = observeTrackReference(participant, videoSource); const audio = observeTrackReference(participant, audioSource);
this.unencryptedWarning = combineLatest( this.video = observeTrackReference(participant, videoSource);
[audio, this.video], this.unencryptedWarning = combineLatest(
(a, v) => [audio, this.video],
callEncrypted && (a, v) =>
(a.publication?.isEncrypted === false || callEncrypted &&
v.publication?.isEncrypted === false), (a.publication?.isEncrypted === false ||
).pipe(distinctUntilChanged(), shareReplay(1)); v?.publication?.isEncrypted === false),
).pipe(distinctUntilChanged(), shareReplay(1));
} else {
this.video = of(undefined);
this.unencryptedWarning = of(false);
}
} }
} }
@@ -137,7 +149,8 @@ abstract class BaseMediaViewModel extends ViewModel {
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel; export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
export type UserMediaViewModel = export type UserMediaViewModel =
| LocalUserMediaViewModel | LocalUserMediaViewModel
| RemoteUserMediaViewModel; | RemoteUserMediaViewModel
| MembershipOnlyViewModel;
/** /**
* Some participant's user media. * Some participant's user media.
@@ -146,13 +159,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
/** /**
* Whether the participant is speaking. * Whether the participant is speaking.
*/ */
public readonly speaking = observeParticipantEvents( public readonly speaking: Observable<boolean>;
this.participant,
ParticipantEvent.IsSpeakingChanged,
).pipe(
map((p) => p.isSpeaking),
shareReplay(1),
);
/** /**
* Whether this participant is sending audio (i.e. is unmuted on their side). * Whether this participant is sending audio (i.e. is unmuted on their side).
@@ -172,7 +179,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant, participant: LocalParticipant | RemoteParticipant | undefined,
callEncrypted: boolean, callEncrypted: boolean,
) { ) {
super( super(
@@ -184,13 +191,26 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
Track.Source.Camera, Track.Source.Camera,
); );
const media = observeParticipantMedia(participant).pipe(shareReplay(1)); if (participant) {
this.audioEnabled = media.pipe( this.speaking = observeParticipantEvents(
map((m) => m.microphoneTrack?.isMuted === false), participant,
); ParticipantEvent.IsSpeakingChanged,
this.videoEnabled = media.pipe( ).pipe(
map((m) => m.cameraTrack?.isMuted === false), map((p) => p.isSpeaking),
); shareReplay(1),
);
const media = observeParticipantMedia(participant).pipe(shareReplay(1));
this.audioEnabled = media.pipe(
map((m) => m.microphoneTrack?.isMuted === false),
);
this.videoEnabled = media.pipe(
map((m) => m.cameraTrack?.isMuted === false),
);
} else {
this.speaking = of(false);
this.audioEnabled = of(false);
this.videoEnabled = of(false);
}
} }
public toggleFitContain(): void { public toggleFitContain(): void {
@@ -207,7 +227,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
*/ */
public readonly mirror = this.video.pipe( public readonly mirror = this.video.pipe(
switchMap((v) => { switchMap((v) => {
const track = v.publication?.track; const track = v?.publication?.track;
if (!(track instanceof LocalTrack)) return of(false); if (!(track instanceof LocalTrack)) return of(false);
// Watch for track restarts, because they indicate a camera switch // Watch for track restarts, because they indicate a camera switch
return fromEvent(track, TrackEvent.Restarted).pipe( return fromEvent(track, TrackEvent.Restarted).pipe(
@@ -300,3 +320,20 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
); );
} }
} }
/**
* Placeholder for a call membership that does not have a LiveKit participant associated with it.
*/
export class MembershipOnlyViewModel extends BaseUserMediaViewModel {
public constructor(
public readonly callMembership: CallMembership,
member: RoomMember | undefined,
) {
super(
callMembership.sender ?? callMembership.membershipID,
member,
undefined,
false,
);
}
}

View File

@@ -49,6 +49,8 @@ import {
useDisplayName, useDisplayName,
LocalUserMediaViewModel, LocalUserMediaViewModel,
RemoteUserMediaViewModel, RemoteUserMediaViewModel,
MembershipOnlyViewModel,
MediaViewModel,
} from "../state/MediaViewModel"; } from "../state/MediaViewModel";
import { Slider } from "../Slider"; import { Slider } from "../Slider";
import { MediaView } from "./MediaView"; import { MediaView } from "./MediaView";
@@ -272,8 +274,30 @@ const RemoteUserMediaTile = forwardRef<
RemoteUserMediaTile.displayName = "RemoteUserMediaTile"; RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
interface MembershipOnlyTileProps extends TileProps {
vm: MembershipOnlyViewModel;
}
const MembershipOnlyTile = forwardRef<HTMLDivElement, MembershipOnlyTileProps>(
({ vm, ...props }, ref) => {
return (
<MediaView
mirror={false}
ref={ref}
member={vm.member}
unencryptedWarning={false}
videoEnabled={false}
videoFit="contain"
{...props}
/>
);
},
);
MembershipOnlyTile.displayName = "MembershipOnlyTile";
interface GridTileProps { interface GridTileProps {
vm: UserMediaViewModel; vm: MediaViewModel;
onOpenProfile: () => void; onOpenProfile: () => void;
targetWidth: number; targetWidth: number;
targetHeight: number; targetHeight: number;
@@ -296,15 +320,10 @@ export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
{...props} {...props}
/> />
); );
} else { } else if (vm instanceof RemoteUserMediaViewModel) {
return ( return <RemoteUserMediaTile ref={ref} vm={vm} displayName={displayName} {...props} />;
<RemoteUserMediaTile } else if (vm instanceof MembershipOnlyViewModel) {
ref={ref} return <MembershipOnlyTile ref={ref} vm={vm} displayName={displayName} {...props} />;
vm={vm}
displayName={displayName}
{...props}
/>
);
} }
}, },
); );

View File

@@ -94,7 +94,7 @@ unconditionally select the container so we can use cqmin units */
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
grid-template-rows: 1fr auto; grid-template-rows: 1fr auto;
grid-template-areas: ". ." "nameTag button"; grid-template-areas: "status status" "nameTag button";
gap: var(--cpd-space-1x); gap: var(--cpd-space-1x);
place-items: start; place-items: start;
} }
@@ -177,3 +177,27 @@ unconditionally select the container so we can use cqmin units */
.fg > button:first-of-type { .fg > button:first-of-type {
grid-area: button; grid-area: button;
} }
.status {
grid-area: status;
padding: var(--cpd-space-1x);
padding-block: var(--cpd-space-1x);
color: var(--cpd-color-text-primary);
background-color: var(--cpd-color-bg-canvas-default);
display: flex;
align-items: center;
border-radius: var(--cpd-radius-pill-effect);
user-select: none;
overflow: hidden;
box-shadow: var(--small-drop-shadow);
box-sizing: border-box;
max-inline-size: 100%;
}
.status > span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
padding-inline: var(--cpd-space-2x);
flex-shrink: 1;
}

View File

@@ -32,7 +32,7 @@ interface Props extends ComponentProps<typeof animated.div> {
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number; targetWidth: number;
targetHeight: number; targetHeight: number;
video: TrackReferenceOrPlaceholder; video?: TrackReferenceOrPlaceholder;
videoFit: "cover" | "contain"; videoFit: "cover" | "contain";
mirror: boolean; mirror: boolean;
member: RoomMember | undefined; member: RoomMember | undefined;
@@ -85,21 +85,33 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
src={member?.getMxcAvatarUrl()} src={member?.getMxcAvatarUrl()}
className={styles.avatar} className={styles.avatar}
/> />
{video.publication !== undefined && ( {video?.publication ? (
<VideoTrack <VideoTrack
trackRef={video} trackRef={video}
// There's no reason for this to be focusable // There's no reason for this to be focusable
tabIndex={-1} tabIndex={-1}
disablePictureInPicture disablePictureInPicture
/> />
)} ) : null}
</div> </div>
<div className={styles.fg}> <div className={styles.fg}>
{!video ? (
<div className={styles.status}>
<span>{t("no_media_available")}</span>
</div>
) : null}
{!member ? (
<div className={styles.status}>
<span>{t("room_member_not_found")}</span>
</div>
) : null}
<div className={styles.nameTag}> <div className={styles.nameTag}>
{nameTagLeadingIcon} {nameTagLeadingIcon}
<Text as="span" size="sm" weight="medium" className={styles.name}> <Tooltip label={member?.userId ?? t("room_member_not_found")}>
{displayName} <Text as="span" size="sm" weight="medium" className={styles.name}>
</Text> {displayName}
</Text>
</Tooltip>
{unencryptedWarning && ( {unencryptedWarning && (
<Tooltip <Tooltip
label={t("common.unencrypted")} label={t("common.unencrypted")}

View File

@@ -57,7 +57,7 @@ interface SpotlightItemBaseProps {
"data-id": string; "data-id": string;
targetWidth: number; targetWidth: number;
targetHeight: number; targetHeight: number;
video: TrackReferenceOrPlaceholder; video: TrackReferenceOrPlaceholder | undefined;
member: RoomMember | undefined; member: RoomMember | undefined;
unencryptedWarning: boolean; unencryptedWarning: boolean;
displayName: string; displayName: string;