mirror of
https://github.com/vector-im/element-call.git
synced 2026-04-25 09:35:33 +00:00
Always show all call memberships from the room state
This commit is contained in:
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user