From 30253b0c19a645e8445de6eff5b9869fd64b0b9c Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Mon, 5 Aug 2024 14:56:50 +0100 Subject: [PATCH] Always show all call memberships from the room state --- public/locales/en-GB/app.json | 2 + src/Header.tsx | 42 +++++--- src/grid/CallLayout.ts | 2 +- src/room/GroupCallView.tsx | 9 +- src/room/InCallView.tsx | 8 +- src/room/LobbyView.tsx | 7 +- src/room/RoomPage.tsx | 2 +- src/state/CallViewModel.ts | 191 ++++++++++++++++++++++++---------- src/state/MediaViewModel.ts | 95 +++++++++++------ src/tile/GridTile.tsx | 39 +++++-- src/tile/MediaView.module.css | 26 ++++- src/tile/MediaView.tsx | 24 +++-- src/tile/SpotlightTile.tsx | 2 +- 13 files changed, 316 insertions(+), 133 deletions(-) diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index 58c05f2b..a6886ee4 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -102,6 +102,7 @@ "microphone_off": "Microphone off", "microphone_on": "Microphone on", "mute_microphone_button_label": "Mute microphone", + "no_media_available": "No media available", "participant_count_one": "{{count, number}}", "participant_count_other": "{{count, number}}", "rageshake_button_error_caption": "Retry sending logs", @@ -126,6 +127,7 @@ "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)", "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", "select_input_unset_button": "Select an option", "settings": { diff --git a/src/Header.tsx b/src/Header.tsx index 1929bcf5..b480ccf0 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -15,11 +15,12 @@ limitations under the License. */ 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 { 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 { CallMembership } from "matrix-js-sdk/src/matrixrtc"; import styles from "./Header.module.css"; import Logo from "./icons/Logo.svg?react"; @@ -125,7 +126,7 @@ interface RoomHeaderInfoProps { name: string; avatarUrl: string | null; encrypted: boolean; - participantCount: number | null; + memberships: CallMembership[] | null; } export const RoomHeaderInfo: FC = ({ @@ -133,11 +134,18 @@ export const RoomHeaderInfo: FC = ({ name, avatarUrl, encrypted, - participantCount, + memberships, }) => { const { t } = useTranslation(); 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(memberships.map((m) => m.sender!)) : null, + [memberships], + ); + return (
= ({
- {(participantCount ?? 0) > 0 && ( -
- - - {t("participant_count", { count: participantCount ?? 0 })} - -
+ {uniqueUsers && uniqueUsers.size > 0 && ( + +
+ + + {t("participant_count", { count: uniqueUsers.size })} + +
+
)} ); diff --git a/src/grid/CallLayout.ts b/src/grid/CallLayout.ts index 119ed956..4ac5b9b8 100644 --- a/src/grid/CallLayout.ts +++ b/src/grid/CallLayout.ts @@ -53,7 +53,7 @@ export interface CallLayoutInputs { export interface GridTileModel { type: "grid"; - vm: UserMediaViewModel; + vm: MediaViewModel; } export interface SpotlightTileModel { diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 6d1438b0..3553f860 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -121,12 +121,6 @@ export const GroupCallView: FC = ({ e2eeSystem, ]); - // Count each member only once, regardless of how many devices they use - const participantCount = useMemo( - () => new Set(memberships.map((m) => m.sender!)).size, - [memberships], - ); - const deviceContext = useMediaDevices(); const latestDevices = useRef(); latestDevices.current = deviceContext; @@ -319,7 +313,7 @@ export const GroupCallView: FC = ({ onEnter={() => void enterRTCSession(rtcSession, perParticipantE2EE)} confineToRoom={confineToRoom} hideHeader={hideHeader} - participantCount={participantCount} + memberships={memberships} onShareClick={onShareClick} /> @@ -333,7 +327,6 @@ export const GroupCallView: FC = ({ client={client} matrixInfo={matrixInfo} rtcSession={rtcSession} - participantCount={participantCount} onLeave={onLeave} hideHeader={hideHeader} muteStates={muteStates} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index ddb046a0..eb17fa59 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -124,7 +124,6 @@ export interface InCallViewProps { rtcSession: MatrixRTCSession; livekitRoom: Room; muteStates: MuteStates; - participantCount: number; onLeave: (error?: Error) => void; hideHeader: boolean; otelGroupCallMembership?: OTelGroupCallMembership; @@ -138,7 +137,6 @@ export const InCallView: FC = ({ rtcSession, livekitRoom, muteStates, - participantCount, onLeave, hideHeader, connState, @@ -190,7 +188,7 @@ export const InCallView: FC = ({ const noControls = reducedControls && bounds.height <= 400; const vm = useCallViewModel( - rtcSession.room, + rtcSession, livekitRoom, matrixInfo.e2eeSystem.kind !== E2eeType.NONE, connState, @@ -513,8 +511,8 @@ export const InCallView: FC = ({ name={matrixInfo.roomName} avatarUrl={matrixInfo.roomAvatar} encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE} - participantCount={participantCount} - /> + memberships={rtcSession.memberships} + /> {!reducedControls && showControls && onShareClick !== null && ( diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx index 14ff4bc1..1da4cfa9 100644 --- a/src/room/LobbyView.tsx +++ b/src/room/LobbyView.tsx @@ -20,6 +20,7 @@ import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { Button, Link } from "@vector-im/compound-web"; import classNames from "classnames"; import { useHistory } from "react-router-dom"; +import { CallMembership } from "matrix-js-sdk/src/matrixrtc"; import inCallStyles from "./InCallView.module.css"; import styles from "./LobbyView.module.css"; @@ -46,7 +47,7 @@ interface Props { enterLabel?: JSX.Element | string; confineToRoom: boolean; hideHeader: boolean; - participantCount: number | null; + memberships: CallMembership[] | null; onShareClick: (() => void) | null; waitingForInvite?: boolean; } @@ -59,7 +60,7 @@ export const LobbyView: FC = ({ enterLabel, confineToRoom, hideHeader, - participantCount, + memberships, onShareClick, waitingForInvite, }) => { @@ -110,7 +111,7 @@ export const LobbyView: FC = ({ name={matrixInfo.roomName} avatarUrl={matrixInfo.roomAvatar} encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE} - participantCount={participantCount} + memberships={memberships} /> diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index bfcf94fd..5220acc9 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -142,7 +142,7 @@ export const RoomPage: FC = () => { waitingForInvite={groupCallState.kind === "waitForInvite"} confineToRoom={confineToRoom} hideHeader={hideHeader} - participantCount={null} + memberships={null} muteStates={muteStates} onShareClick={null} /> diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index c5d7af81..284b0fe3 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -52,6 +52,7 @@ import { zip, } from "rxjs"; import { logger } from "matrix-js-sdk/src/logger"; +import { MatrixRTCSession, CallMembership } from "matrix-js-sdk/src/matrixrtc"; import { ViewModel } from "./ViewModel"; import { useObservable } from "./useObservable"; @@ -63,6 +64,7 @@ import { usePrevious } from "../usePrevious"; import { LocalUserMediaViewModel, MediaViewModel, + MembershipOnlyViewModel, RemoteUserMediaViewModel, ScreenShareViewModel, UserMediaViewModel, @@ -78,7 +80,7 @@ const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000; export interface GridLayout { type: "grid"; spotlight?: MediaViewModel[]; - grid: UserMediaViewModel[]; + grid: MediaViewModel[]; } export interface SpotlightLandscapeLayout { @@ -102,7 +104,7 @@ export interface SpotlightExpandedLayout { export interface OneOnOneLayout { type: "one-on-one"; local: LocalUserMediaViewModel; - remote: RemoteUserMediaViewModel; + remote: UserMediaViewModel; } export interface PipLayout { @@ -163,15 +165,35 @@ class UserMedia { public readonly presenter: Observable; public constructor( - public readonly id: string, + id: string, member: RoomMember | undefined, participant: LocalParticipant | RemoteParticipant, 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 = - participant instanceof LocalParticipant - ? new LocalUserMediaViewModel(id, member, participant, callEncrypted) - : new RemoteUserMediaViewModel(id, member, participant, callEncrypted); + if (participant instanceof CallMembership) { + this.vm = new MembershipOnlyViewModel(participant, member); + } else { + this.vm = + participant instanceof LocalParticipant + ? new LocalUserMediaViewModel(id, member, participant, callEncrypted) + : new RemoteUserMediaViewModel( + id, + member, + participant, + callEncrypted, + ); + } this.speaker = this.vm.speaking.pipe( // Require 1 s of continuous speaking to become a speaker, and 60 s of @@ -192,13 +214,17 @@ class UserMedia { shareReplay(1), ); - this.presenter = observeParticipantEvents( - participant, - ParticipantEvent.TrackPublished, - ParticipantEvent.TrackUnpublished, - ParticipantEvent.LocalTrackPublished, - ParticipantEvent.LocalTrackUnpublished, - ).pipe(map((p) => p.isScreenShareEnabled)); + if (participant instanceof CallMembership) { + this.presenter = of(false); + } else { + this.presenter = observeParticipantEvents( + participant, + ParticipantEvent.TrackPublished, + ParticipantEvent.TrackUnpublished, + ParticipantEvent.LocalTrackPublished, + ParticipantEvent.LocalTrackUnpublished, + ).pipe(map((p) => p.isScreenShareEnabled)); + } } public destroy(): void { @@ -226,6 +252,22 @@ class 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( room: MatrixRoom, id: string, @@ -233,18 +275,12 @@ function findMatrixMember( if (id === "local") return room.getMember(room.client.getUserId()!) ?? 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", - ); + const userId = matrixUserIdFromParticipantId(id); + + if (!userId) { return undefined; } - parts.pop(); - const userId = parts.join(":"); - 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 = combineLatest([ this.remoteParticipants, observeParticipantMedia(this.livekitRoom.localParticipant), duplicateTiles.value, + this.membershipsWithoutParticipant, ]).pipe( scan( ( prevItems, - [remoteParticipants, { participant: localParticipant }, duplicateTiles], + [ + remoteParticipants, + { participant: localParticipant }, + duplicateTiles, + membershipsWithoutParticipant, + ], ) => { const newItems = new Map( function* (this: CallViewModel): Iterable<[string, MediaItem]> { - for (const p of [localParticipant, ...remoteParticipants]) { - const userMediaId = p === localParticipant ? "local" : p.identity; - const member = findMatrixMember(this.matrixRoom, userMediaId); - if (member === undefined) - logger.warn( - `Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`, - ); - - // 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}`; + for (const p of [ + localParticipant, + ...remoteParticipants, + ...membershipsWithoutParticipant, + ]) { + if (p instanceof CallMembership) { + const member = + this.matrixRoom.getMember(p.sender!) ?? undefined; yield [ - userMediaId, - prevItems.get(userMediaId) ?? - new UserMedia(userMediaId, member, p, this.encrypted), + p.sender!, + prevItems.get(p.sender!) ?? + 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) { - const screenShareId = `${userMediaId}:screen-share`; + // Create as many tiles for this participant as called for by + // the duplicateTiles option + for (let i = 0; i < 1; i++) { + const userMediaId = `${p.identity}:${i}`; yield [ - screenShareId, - prevItems.get(screenShareId) ?? - new ScreenShare(screenShareId, member, p, this.encrypted), + userMediaId, + prevItems.get(userMediaId) ?? + 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) => { const spotlightLandscapeLayout = combineLatest( [this.grid, this.spotlight], - (grid, spotlight): Layout => ({ + (grid, spotlight): SpotlightLandscapeLayout => ({ type: "spotlight-landscape", spotlight, grid, @@ -573,7 +654,7 @@ export class CallViewModel extends ViewModel { ); const spotlightExpandedLayout = combineLatest( [this.spotlight, this.pip], - (spotlight, pip): Layout => ({ + (spotlight, pip): SpotlightExpandedLayout => ({ type: "spotlight-expanded", spotlight, pip: pip ?? undefined, @@ -625,7 +706,7 @@ export class CallViewModel extends ViewModel { case "narrow": return combineLatest( [this.grid, this.spotlight], - (grid, spotlight): Layout => ({ + (grid, spotlight): SpotlightPortraitLayout => ({ type: "spotlight-portrait", spotlight, grid, @@ -646,7 +727,7 @@ export class CallViewModel extends ViewModel { ); case "pip": 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), ); + private get matrixRoom(): MatrixRoom { + return this.rtcSession.room; + } + public constructor( // 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 encrypted: boolean, private readonly connectionState: Observable, @@ -696,25 +781,25 @@ export class CallViewModel extends ViewModel { } export function useCallViewModel( - matrixRoom: MatrixRoom, + rtcSession: MatrixRTCSession, livekitRoom: LivekitRoom, encrypted: boolean, connectionState: ECConnectionState, ): CallViewModel { - const prevMatrixRoom = usePrevious(matrixRoom); + const prevRTCSession = usePrevious(rtcSession); const prevLivekitRoom = usePrevious(livekitRoom); const prevEncrypted = usePrevious(encrypted); const connectionStateObservable = useObservable(connectionState); const vm = useRef(); if ( - matrixRoom !== prevMatrixRoom || + rtcSession !== prevRTCSession || livekitRoom !== prevLivekitRoom || encrypted !== prevEncrypted ) { vm.current?.destroy(); vm.current = new CallViewModel( - matrixRoom, + rtcSession, livekitRoom, encrypted, connectionStateObservable, diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index 197f0341..4b1b86b5 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -46,6 +46,7 @@ import { switchMap, } from "rxjs"; import { useEffect } from "react"; +import { CallMembership } from "matrix-js-sdk/src/matrixrtc"; import { ViewModel } from "./ViewModel"; import { useReactiveState } from "../useReactiveState"; @@ -92,11 +93,14 @@ abstract class BaseMediaViewModel extends ViewModel { /** * 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. */ - public readonly video: Observable; + public readonly video: Observable; /** * 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 // member object internal public readonly member: RoomMember | undefined, - protected readonly participant: LocalParticipant | RemoteParticipant, + protected readonly participant: + | LocalParticipant + | RemoteParticipant + | undefined, callEncrypted: boolean, audioSource: AudioSource, videoSource: VideoSource, ) { super(); - const audio = observeTrackReference(participant, audioSource); - this.video = observeTrackReference(participant, videoSource); - this.unencryptedWarning = combineLatest( - [audio, this.video], - (a, v) => - callEncrypted && - (a.publication?.isEncrypted === false || - v.publication?.isEncrypted === false), - ).pipe(distinctUntilChanged(), shareReplay(1)); + if (participant) { + const audio = observeTrackReference(participant, audioSource); + this.video = observeTrackReference(participant, videoSource); + this.unencryptedWarning = combineLatest( + [audio, this.video], + (a, v) => + callEncrypted && + (a.publication?.isEncrypted === false || + 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 UserMediaViewModel = | LocalUserMediaViewModel - | RemoteUserMediaViewModel; + | RemoteUserMediaViewModel + | MembershipOnlyViewModel; /** * Some participant's user media. @@ -146,13 +159,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { /** * Whether the participant is speaking. */ - public readonly speaking = observeParticipantEvents( - this.participant, - ParticipantEvent.IsSpeakingChanged, - ).pipe( - map((p) => p.isSpeaking), - shareReplay(1), - ); + public readonly speaking: Observable; /** * Whether this participant is sending audio (i.e. is unmuted on their side). @@ -172,7 +179,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { public constructor( id: string, member: RoomMember | undefined, - participant: LocalParticipant | RemoteParticipant, + participant: LocalParticipant | RemoteParticipant | undefined, callEncrypted: boolean, ) { super( @@ -184,13 +191,26 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { Track.Source.Camera, ); - 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), - ); + if (participant) { + this.speaking = observeParticipantEvents( + participant, + ParticipantEvent.IsSpeakingChanged, + ).pipe( + 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 { @@ -207,7 +227,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel { */ public readonly mirror = this.video.pipe( switchMap((v) => { - const track = v.publication?.track; + const track = v?.publication?.track; if (!(track instanceof LocalTrack)) return of(false); // Watch for track restarts, because they indicate a camera switch 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, + ); + } +} diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index f1e0c72b..a7310913 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -49,6 +49,8 @@ import { useDisplayName, LocalUserMediaViewModel, RemoteUserMediaViewModel, + MembershipOnlyViewModel, + MediaViewModel, } from "../state/MediaViewModel"; import { Slider } from "../Slider"; import { MediaView } from "./MediaView"; @@ -272,8 +274,30 @@ const RemoteUserMediaTile = forwardRef< RemoteUserMediaTile.displayName = "RemoteUserMediaTile"; +interface MembershipOnlyTileProps extends TileProps { + vm: MembershipOnlyViewModel; +} + +const MembershipOnlyTile = forwardRef( + ({ vm, ...props }, ref) => { + return ( + + ); + }, +); + +MembershipOnlyTile.displayName = "MembershipOnlyTile"; + interface GridTileProps { - vm: UserMediaViewModel; + vm: MediaViewModel; onOpenProfile: () => void; targetWidth: number; targetHeight: number; @@ -296,15 +320,10 @@ export const GridTile = forwardRef( {...props} /> ); - } else { - return ( - - ); + } else if (vm instanceof RemoteUserMediaViewModel) { + return ; + } else if (vm instanceof MembershipOnlyViewModel) { + return ; } }, ); diff --git a/src/tile/MediaView.module.css b/src/tile/MediaView.module.css index e3622f4d..472395db 100644 --- a/src/tile/MediaView.module.css +++ b/src/tile/MediaView.module.css @@ -94,7 +94,7 @@ unconditionally select the container so we can use cqmin units */ display: grid; grid-template-columns: 1fr auto; grid-template-rows: 1fr auto; - grid-template-areas: ". ." "nameTag button"; + grid-template-areas: "status status" "nameTag button"; gap: var(--cpd-space-1x); place-items: start; } @@ -177,3 +177,27 @@ unconditionally select the container so we can use cqmin units */ .fg > button:first-of-type { 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; +} diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index 4d073092..a256aff9 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -32,7 +32,7 @@ interface Props extends ComponentProps { style?: ComponentProps["style"]; targetWidth: number; targetHeight: number; - video: TrackReferenceOrPlaceholder; + video?: TrackReferenceOrPlaceholder; videoFit: "cover" | "contain"; mirror: boolean; member: RoomMember | undefined; @@ -85,21 +85,33 @@ export const MediaView = forwardRef( src={member?.getMxcAvatarUrl()} className={styles.avatar} /> - {video.publication !== undefined && ( + {video?.publication ? ( - )} + ) : null}
+ {!video ? ( +
+ {t("no_media_available")} +
+ ) : null} + {!member ? ( +
+ {t("room_member_not_found")} +
+ ) : null}
{nameTagLeadingIcon} - - {displayName} - + + + {displayName} + + {unencryptedWarning && (