mirror of
https://github.com/vector-im/element-call.git
synced 2024-11-24 00:38:31 +08:00
Show encryption key status from LiveKit (#2700)
* Refactor to make encryption system available in view models * WIP show encryption errors from LiveKit * Missing CSS * Show encryption status based on LK and RTC * Lint * Lint * Fix tests * Update wording * Refactor * Lint
This commit is contained in:
parent
bc0ab92394
commit
c45f724279
@ -64,6 +64,12 @@
|
|||||||
"crypto_version": "Crypto version: {{version}}",
|
"crypto_version": "Crypto version: {{version}}",
|
||||||
"device_id": "Device ID: {{id}}",
|
"device_id": "Device ID: {{id}}",
|
||||||
"disconnected_banner": "Connectivity to the server has been lost.",
|
"disconnected_banner": "Connectivity to the server has been lost.",
|
||||||
|
"e2ee_encryption_status": {
|
||||||
|
"connecting": "Connecting...",
|
||||||
|
"key_invalid": "The end-to-end encrypted media key for this person is invalid",
|
||||||
|
"key_missing": "You haven't received the current end-to-end encrypted media key for this person yet",
|
||||||
|
"password_invalid": "This person is using a different password so you won't be able to communicate with them"
|
||||||
|
},
|
||||||
"full_screen_view_description": "<0>Submitting debug logs will help us track down the problem.</0>",
|
"full_screen_view_description": "<0>Submitting debug logs will help us track down the problem.</0>",
|
||||||
"full_screen_view_h1": "<0>Oops, something's gone wrong.</0>",
|
"full_screen_view_h1": "<0>Oops, something's gone wrong.</0>",
|
||||||
"group_call_loader": {
|
"group_call_loader": {
|
||||||
|
@ -194,6 +194,15 @@ function withCallViewModel(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const roomEventSelectorSpy = vi
|
||||||
|
.spyOn(ComponentsCore, "roomEventSelector")
|
||||||
|
.mockImplementation((room, eventType) => of());
|
||||||
|
|
||||||
|
const liveKitRoom = mockLivekitRoom(
|
||||||
|
{ localParticipant },
|
||||||
|
{ remoteParticipants },
|
||||||
|
);
|
||||||
|
|
||||||
const vm = new CallViewModel(
|
const vm = new CallViewModel(
|
||||||
mockMatrixRoom({
|
mockMatrixRoom({
|
||||||
client: {
|
client: {
|
||||||
@ -201,7 +210,7 @@ function withCallViewModel(
|
|||||||
} as Partial<MatrixClient> as MatrixClient,
|
} as Partial<MatrixClient> as MatrixClient,
|
||||||
getMember: (userId) => members.get(userId) ?? null,
|
getMember: (userId) => members.get(userId) ?? null,
|
||||||
}),
|
}),
|
||||||
mockLivekitRoom({ localParticipant }),
|
liveKitRoom,
|
||||||
{
|
{
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
@ -213,6 +222,7 @@ function withCallViewModel(
|
|||||||
participantsSpy!.mockRestore();
|
participantsSpy!.mockRestore();
|
||||||
mediaSpy!.mockRestore();
|
mediaSpy!.mockRestore();
|
||||||
eventsSpy!.mockRestore();
|
eventsSpy!.mockRestore();
|
||||||
|
roomEventSelectorSpy!.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
continuation(vm);
|
continuation(vm);
|
||||||
|
@ -226,6 +226,7 @@ class UserMedia {
|
|||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant: LocalParticipant | RemoteParticipant,
|
participant: LocalParticipant | RemoteParticipant,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
|
livekitRoom: LivekitRoom,
|
||||||
) {
|
) {
|
||||||
this.vm = participant.isLocal
|
this.vm = participant.isLocal
|
||||||
? new LocalUserMediaViewModel(
|
? new LocalUserMediaViewModel(
|
||||||
@ -233,12 +234,14 @@ class UserMedia {
|
|||||||
member,
|
member,
|
||||||
participant as LocalParticipant,
|
participant as LocalParticipant,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
|
livekitRoom,
|
||||||
)
|
)
|
||||||
: new RemoteUserMediaViewModel(
|
: new RemoteUserMediaViewModel(
|
||||||
id,
|
id,
|
||||||
member,
|
member,
|
||||||
participant as RemoteParticipant,
|
participant as RemoteParticipant,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
|
livekitRoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.speaker = this.vm.speaking.pipe(
|
this.speaker = this.vm.speaking.pipe(
|
||||||
@ -282,12 +285,14 @@ class ScreenShare {
|
|||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant: LocalParticipant | RemoteParticipant,
|
participant: LocalParticipant | RemoteParticipant,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
|
liveKitRoom: LivekitRoom,
|
||||||
) {
|
) {
|
||||||
this.vm = new ScreenShareViewModel(
|
this.vm = new ScreenShareViewModel(
|
||||||
id,
|
id,
|
||||||
member,
|
member,
|
||||||
participant,
|
participant,
|
||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
|
liveKitRoom,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -428,6 +433,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
member,
|
member,
|
||||||
p,
|
p,
|
||||||
this.encryptionSystem,
|
this.encryptionSystem,
|
||||||
|
this.livekitRoom,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -441,6 +447,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
member,
|
member,
|
||||||
p,
|
p,
|
||||||
this.encryptionSystem,
|
this.encryptionSystem,
|
||||||
|
this.livekitRoom,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ import {
|
|||||||
VideoSource,
|
VideoSource,
|
||||||
observeParticipantEvents,
|
observeParticipantEvents,
|
||||||
observeParticipantMedia,
|
observeParticipantMedia,
|
||||||
|
roomEventSelector,
|
||||||
} from "@livekit/components-core";
|
} from "@livekit/components-core";
|
||||||
import {
|
import {
|
||||||
LocalParticipant,
|
LocalParticipant,
|
||||||
@ -21,6 +22,9 @@ import {
|
|||||||
Track,
|
Track,
|
||||||
TrackEvent,
|
TrackEvent,
|
||||||
facingModeFromLocalTrack,
|
facingModeFromLocalTrack,
|
||||||
|
Room as LivekitRoom,
|
||||||
|
RoomEvent as LivekitRoomEvent,
|
||||||
|
RemoteTrack,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
|
import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
|
||||||
import {
|
import {
|
||||||
@ -28,13 +32,18 @@ import {
|
|||||||
Observable,
|
Observable,
|
||||||
Subject,
|
Subject,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
distinctUntilKeyChanged,
|
distinctUntilKeyChanged,
|
||||||
|
filter,
|
||||||
fromEvent,
|
fromEvent,
|
||||||
|
interval,
|
||||||
map,
|
map,
|
||||||
merge,
|
merge,
|
||||||
of,
|
of,
|
||||||
|
shareReplay,
|
||||||
startWith,
|
startWith,
|
||||||
switchMap,
|
switchMap,
|
||||||
|
throttleTime,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
@ -81,6 +90,115 @@ export function observeTrackReference(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function observeRemoteTrackReceivingOkay(
|
||||||
|
participant: Participant,
|
||||||
|
source: Track.Source,
|
||||||
|
): Observable<boolean | undefined> {
|
||||||
|
let lastStats: {
|
||||||
|
framesDecoded: number | undefined;
|
||||||
|
framesDropped: number | undefined;
|
||||||
|
framesReceived: number | undefined;
|
||||||
|
} = {
|
||||||
|
framesDecoded: undefined,
|
||||||
|
framesDropped: undefined,
|
||||||
|
framesReceived: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return combineLatest([
|
||||||
|
observeTrackReference(participant, source),
|
||||||
|
interval(1000).pipe(startWith(0)),
|
||||||
|
]).pipe(
|
||||||
|
switchMap(async ([trackReference]) => {
|
||||||
|
const track = trackReference.publication?.track;
|
||||||
|
if (!track || !(track instanceof RemoteTrack)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const report = await track.getRTCStatsReport();
|
||||||
|
if (!report) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const v of report.values()) {
|
||||||
|
if (v.type === "inbound-rtp") {
|
||||||
|
const { framesDecoded, framesDropped, framesReceived } =
|
||||||
|
v as RTCInboundRtpStreamStats;
|
||||||
|
return {
|
||||||
|
framesDecoded,
|
||||||
|
framesDropped,
|
||||||
|
framesReceived,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
filter((newStats) => !!newStats),
|
||||||
|
map((newStats): boolean | undefined => {
|
||||||
|
const oldStats = lastStats;
|
||||||
|
lastStats = newStats;
|
||||||
|
if (
|
||||||
|
typeof newStats.framesReceived === "number" &&
|
||||||
|
typeof oldStats.framesReceived === "number" &&
|
||||||
|
typeof newStats.framesDecoded === "number" &&
|
||||||
|
typeof oldStats.framesDecoded === "number"
|
||||||
|
) {
|
||||||
|
const framesReceivedDelta =
|
||||||
|
newStats.framesReceived - oldStats.framesReceived;
|
||||||
|
const framesDecodedDelta =
|
||||||
|
newStats.framesDecoded - oldStats.framesDecoded;
|
||||||
|
|
||||||
|
// if we received >0 frames and managed to decode >0 frames then we treat that as success
|
||||||
|
|
||||||
|
if (framesReceivedDelta > 0) {
|
||||||
|
return framesDecodedDelta > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// no change
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
filter((x) => typeof x === "boolean"),
|
||||||
|
startWith(undefined),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encryptionErrorObservable(
|
||||||
|
room: LivekitRoom,
|
||||||
|
participant: Participant,
|
||||||
|
encryptionSystem: EncryptionSystem,
|
||||||
|
criteria: string,
|
||||||
|
): Observable<boolean> {
|
||||||
|
return roomEventSelector(room, LivekitRoomEvent.EncryptionError).pipe(
|
||||||
|
map((e) => {
|
||||||
|
const [err] = e;
|
||||||
|
if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||||
|
return (
|
||||||
|
// Ideally we would pull the participant identity from the field on the error.
|
||||||
|
// However, it gets lost in the serialization process between workers.
|
||||||
|
// So, instead we do a string match
|
||||||
|
(err?.message.includes(participant.identity) &&
|
||||||
|
err?.message.includes(criteria)) ??
|
||||||
|
false
|
||||||
|
);
|
||||||
|
} else if (encryptionSystem.kind === E2eeType.SHARED_KEY) {
|
||||||
|
return !!err?.message.includes(criteria);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}),
|
||||||
|
throttleTime(1000), // Throttle to avoid spamming the UI
|
||||||
|
startWith(false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum EncryptionStatus {
|
||||||
|
Connecting,
|
||||||
|
Okay,
|
||||||
|
KeyMissing,
|
||||||
|
KeyInvalid,
|
||||||
|
PasswordInvalid,
|
||||||
|
}
|
||||||
|
|
||||||
abstract class BaseMediaViewModel extends ViewModel {
|
abstract class BaseMediaViewModel extends ViewModel {
|
||||||
/**
|
/**
|
||||||
* Whether the media belongs to the local user.
|
* Whether the media belongs to the local user.
|
||||||
@ -95,6 +213,8 @@ abstract class BaseMediaViewModel extends ViewModel {
|
|||||||
*/
|
*/
|
||||||
public readonly unencryptedWarning: Observable<boolean>;
|
public readonly unencryptedWarning: Observable<boolean>;
|
||||||
|
|
||||||
|
public readonly encryptionStatus: Observable<EncryptionStatus>;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
/**
|
/**
|
||||||
* An opaque identifier for this media.
|
* An opaque identifier for this media.
|
||||||
@ -110,6 +230,7 @@ abstract class BaseMediaViewModel extends ViewModel {
|
|||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
audioSource: AudioSource,
|
audioSource: AudioSource,
|
||||||
videoSource: VideoSource,
|
videoSource: VideoSource,
|
||||||
|
livekitRoom: LivekitRoom,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
const audio = observeTrackReference(participant, audioSource).pipe(
|
const audio = observeTrackReference(participant, audioSource).pipe(
|
||||||
@ -124,7 +245,64 @@ abstract class BaseMediaViewModel extends ViewModel {
|
|||||||
encryptionSystem.kind !== E2eeType.NONE &&
|
encryptionSystem.kind !== E2eeType.NONE &&
|
||||||
(a.publication?.isEncrypted === false ||
|
(a.publication?.isEncrypted === false ||
|
||||||
v.publication?.isEncrypted === false),
|
v.publication?.isEncrypted === false),
|
||||||
).pipe(this.scope.state());
|
).pipe(distinctUntilChanged(), shareReplay(1));
|
||||||
|
|
||||||
|
if (participant.isLocal || encryptionSystem.kind === E2eeType.NONE) {
|
||||||
|
this.encryptionStatus = of(EncryptionStatus.Okay).pipe(
|
||||||
|
this.scope.state(),
|
||||||
|
);
|
||||||
|
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||||
|
this.encryptionStatus = combineLatest([
|
||||||
|
encryptionErrorObservable(
|
||||||
|
livekitRoom,
|
||||||
|
participant,
|
||||||
|
encryptionSystem,
|
||||||
|
"MissingKey",
|
||||||
|
),
|
||||||
|
encryptionErrorObservable(
|
||||||
|
livekitRoom,
|
||||||
|
participant,
|
||||||
|
encryptionSystem,
|
||||||
|
"InvalidKey",
|
||||||
|
),
|
||||||
|
observeRemoteTrackReceivingOkay(participant, audioSource),
|
||||||
|
observeRemoteTrackReceivingOkay(participant, videoSource),
|
||||||
|
]).pipe(
|
||||||
|
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
|
||||||
|
if (keyMissing) return EncryptionStatus.KeyMissing;
|
||||||
|
if (keyInvalid) return EncryptionStatus.KeyInvalid;
|
||||||
|
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||||
|
return undefined; // no change
|
||||||
|
}),
|
||||||
|
filter((x) => !!x),
|
||||||
|
startWith(EncryptionStatus.Connecting),
|
||||||
|
this.scope.state(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.encryptionStatus = combineLatest([
|
||||||
|
encryptionErrorObservable(
|
||||||
|
livekitRoom,
|
||||||
|
participant,
|
||||||
|
encryptionSystem,
|
||||||
|
"InvalidKey",
|
||||||
|
),
|
||||||
|
observeRemoteTrackReceivingOkay(participant, audioSource),
|
||||||
|
observeRemoteTrackReceivingOkay(participant, videoSource),
|
||||||
|
]).pipe(
|
||||||
|
map(
|
||||||
|
([keyInvalid, audioOkay, videoOkay]):
|
||||||
|
| EncryptionStatus
|
||||||
|
| undefined => {
|
||||||
|
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
|
||||||
|
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||||
|
return undefined; // no change
|
||||||
|
},
|
||||||
|
),
|
||||||
|
filter((x) => !!x),
|
||||||
|
startWith(EncryptionStatus.Connecting),
|
||||||
|
this.scope.state(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -171,6 +349,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
|||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant: LocalParticipant | RemoteParticipant,
|
participant: LocalParticipant | RemoteParticipant,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
|
livekitRoom: LivekitRoom,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
id,
|
id,
|
||||||
@ -179,6 +358,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
|||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
Track.Source.Microphone,
|
Track.Source.Microphone,
|
||||||
Track.Source.Camera,
|
Track.Source.Camera,
|
||||||
|
livekitRoom,
|
||||||
);
|
);
|
||||||
|
|
||||||
const media = observeParticipantMedia(participant).pipe(this.scope.state());
|
const media = observeParticipantMedia(participant).pipe(this.scope.state());
|
||||||
@ -228,8 +408,9 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant: LocalParticipant,
|
participant: LocalParticipant,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
|
livekitRoom: LivekitRoom,
|
||||||
) {
|
) {
|
||||||
super(id, member, participant, encryptionSystem);
|
super(id, member, participant, encryptionSystem, livekitRoom);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -288,8 +469,9 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant: RemoteParticipant,
|
participant: RemoteParticipant,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
|
livekitRoom: LivekitRoom,
|
||||||
) {
|
) {
|
||||||
super(id, member, participant, encryptionSystem);
|
super(id, member, participant, encryptionSystem, livekitRoom);
|
||||||
|
|
||||||
// Sync the local volume with LiveKit
|
// Sync the local volume with LiveKit
|
||||||
this.localVolume
|
this.localVolume
|
||||||
@ -321,6 +503,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
|
|||||||
member: RoomMember | undefined,
|
member: RoomMember | undefined,
|
||||||
participant: LocalParticipant | RemoteParticipant,
|
participant: LocalParticipant | RemoteParticipant,
|
||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
|
livekitRoom: LivekitRoom,
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
id,
|
id,
|
||||||
@ -329,6 +512,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
|
|||||||
encryptionSystem,
|
encryptionSystem,
|
||||||
Track.Source.ScreenShareAudio,
|
Track.Source.ScreenShareAudio,
|
||||||
Track.Source.ScreenShare,
|
Track.Source.ScreenShare,
|
||||||
|
livekitRoom,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -81,6 +81,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const video = useObservableEagerState(vm.video);
|
const video = useObservableEagerState(vm.video);
|
||||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
||||||
|
const encryptionStatus = useObservableEagerState(vm.encryptionStatus);
|
||||||
const audioEnabled = useObservableEagerState(vm.audioEnabled);
|
const audioEnabled = useObservableEagerState(vm.audioEnabled);
|
||||||
const videoEnabled = useObservableEagerState(vm.videoEnabled);
|
const videoEnabled = useObservableEagerState(vm.videoEnabled);
|
||||||
const speaking = useObservableEagerState(vm.speaking);
|
const speaking = useObservableEagerState(vm.speaking);
|
||||||
@ -122,6 +123,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
|||||||
video={video}
|
video={video}
|
||||||
member={vm.member}
|
member={vm.member}
|
||||||
unencryptedWarning={unencryptedWarning}
|
unencryptedWarning={unencryptedWarning}
|
||||||
|
encryptionStatus={encryptionStatus}
|
||||||
videoEnabled={videoEnabled}
|
videoEnabled={videoEnabled}
|
||||||
videoFit={cropVideo ? "cover" : "contain"}
|
videoFit={cropVideo ? "cover" : "contain"}
|
||||||
className={classNames(className, styles.tile, {
|
className={classNames(className, styles.tile, {
|
||||||
|
@ -85,7 +85,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;
|
||||||
}
|
}
|
||||||
@ -106,6 +106,25 @@ unconditionally select the container so we can use cqmin units */
|
|||||||
max-inline-size: 100%;
|
max-inline-size: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
grid-area: status;
|
||||||
|
justify-self: center;
|
||||||
|
align-self: start;
|
||||||
|
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%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.nameTag > svg,
|
.nameTag > svg,
|
||||||
.nameTag > span {
|
.nameTag > span {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
@ -17,6 +17,7 @@ import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
|||||||
|
|
||||||
import styles from "./MediaView.module.css";
|
import styles from "./MediaView.module.css";
|
||||||
import { Avatar } from "../Avatar";
|
import { Avatar } from "../Avatar";
|
||||||
|
import { EncryptionStatus } from "../state/MediaViewModel";
|
||||||
import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator";
|
import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator";
|
||||||
import { showHandRaisedTimer, useSetting } from "../settings/settings";
|
import { showHandRaisedTimer, useSetting } from "../settings/settings";
|
||||||
|
|
||||||
@ -31,6 +32,7 @@ interface Props extends ComponentProps<typeof animated.div> {
|
|||||||
member: RoomMember | undefined;
|
member: RoomMember | undefined;
|
||||||
videoEnabled: boolean;
|
videoEnabled: boolean;
|
||||||
unencryptedWarning: boolean;
|
unencryptedWarning: boolean;
|
||||||
|
encryptionStatus: EncryptionStatus;
|
||||||
nameTagLeadingIcon?: ReactNode;
|
nameTagLeadingIcon?: ReactNode;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
primaryButton?: ReactNode;
|
primaryButton?: ReactNode;
|
||||||
@ -54,6 +56,7 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
|||||||
nameTagLeadingIcon,
|
nameTagLeadingIcon,
|
||||||
displayName,
|
displayName,
|
||||||
primaryButton,
|
primaryButton,
|
||||||
|
encryptionStatus,
|
||||||
raisedHandTime,
|
raisedHandTime,
|
||||||
raisedHandOnClick,
|
raisedHandOnClick,
|
||||||
...props
|
...props
|
||||||
@ -69,7 +72,11 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
|||||||
<animated.div
|
<animated.div
|
||||||
className={classNames(styles.media, className, {
|
className={classNames(styles.media, className, {
|
||||||
[styles.mirror]: mirror,
|
[styles.mirror]: mirror,
|
||||||
[styles.videoMuted]: !videoEnabled,
|
[styles.videoMuted]:
|
||||||
|
!videoEnabled ||
|
||||||
|
![EncryptionStatus.Connecting, EncryptionStatus.Okay].includes(
|
||||||
|
encryptionStatus,
|
||||||
|
),
|
||||||
})}
|
})}
|
||||||
style={style}
|
style={style}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@ -95,6 +102,20 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.fg}>
|
<div className={styles.fg}>
|
||||||
|
{encryptionStatus !== EncryptionStatus.Okay && (
|
||||||
|
<div className={styles.status}>
|
||||||
|
<Text as="span" size="sm" weight="medium" className={styles.name}>
|
||||||
|
{encryptionStatus === EncryptionStatus.Connecting &&
|
||||||
|
t("e2ee_encryption_status.connecting")}
|
||||||
|
{encryptionStatus === EncryptionStatus.KeyMissing &&
|
||||||
|
t("e2ee_encryption_status.key_missing")}
|
||||||
|
{encryptionStatus === EncryptionStatus.KeyInvalid &&
|
||||||
|
t("e2ee_encryption_status.key_invalid")}
|
||||||
|
{encryptionStatus === EncryptionStatus.PasswordInvalid &&
|
||||||
|
t("e2ee_encryption_status.password_invalid")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<RaisedHandIndicator
|
<RaisedHandIndicator
|
||||||
raisedHandTime={raisedHandTime}
|
raisedHandTime={raisedHandTime}
|
||||||
minature={avatarSize < 96}
|
minature={avatarSize < 96}
|
||||||
|
@ -31,6 +31,7 @@ import { RoomMember } from "matrix-js-sdk/src/matrix";
|
|||||||
import { MediaView } from "./MediaView";
|
import { MediaView } from "./MediaView";
|
||||||
import styles from "./SpotlightTile.module.css";
|
import styles from "./SpotlightTile.module.css";
|
||||||
import {
|
import {
|
||||||
|
EncryptionStatus,
|
||||||
LocalUserMediaViewModel,
|
LocalUserMediaViewModel,
|
||||||
MediaViewModel,
|
MediaViewModel,
|
||||||
ScreenShareViewModel,
|
ScreenShareViewModel,
|
||||||
@ -51,6 +52,7 @@ interface SpotlightItemBaseProps {
|
|||||||
video: TrackReferenceOrPlaceholder;
|
video: TrackReferenceOrPlaceholder;
|
||||||
member: RoomMember | undefined;
|
member: RoomMember | undefined;
|
||||||
unencryptedWarning: boolean;
|
unencryptedWarning: boolean;
|
||||||
|
encryptionStatus: EncryptionStatus;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
"aria-hidden"?: boolean;
|
"aria-hidden"?: boolean;
|
||||||
}
|
}
|
||||||
@ -132,6 +134,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
|||||||
const displayName = useDisplayName(vm);
|
const displayName = useDisplayName(vm);
|
||||||
const video = useObservableEagerState(vm.video);
|
const video = useObservableEagerState(vm.video);
|
||||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
||||||
|
const encryptionStatus = useObservableEagerState(vm.encryptionStatus);
|
||||||
|
|
||||||
// Hook this item up to the intersection observer
|
// Hook this item up to the intersection observer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -158,6 +161,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
|||||||
member: vm.member,
|
member: vm.member,
|
||||||
unencryptedWarning,
|
unencryptedWarning,
|
||||||
displayName,
|
displayName,
|
||||||
|
encryptionStatus,
|
||||||
"aria-hidden": ariaHidden,
|
"aria-hidden": ariaHidden,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ Copyright 2023, 2024 New Vector Ltd.
|
|||||||
SPDX-License-Identifier: AGPL-3.0-only
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
import { map } from "rxjs";
|
import { map, Observable, of } from "rxjs";
|
||||||
import { RunHelpers, TestScheduler } from "rxjs/testing";
|
import { RunHelpers, TestScheduler } from "rxjs/testing";
|
||||||
import { expect, vi } from "vitest";
|
import { expect, vi } from "vitest";
|
||||||
import { RoomMember, Room as MatrixRoom } from "matrix-js-sdk/src/matrix";
|
import { RoomMember, Room as MatrixRoom } from "matrix-js-sdk/src/matrix";
|
||||||
@ -99,8 +99,27 @@ export function mockMatrixRoom(room: Partial<MatrixRoom>): MatrixRoom {
|
|||||||
return { ...mockEmitter(), ...room } as Partial<MatrixRoom> as MatrixRoom;
|
return { ...mockEmitter(), ...room } as Partial<MatrixRoom> as MatrixRoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockLivekitRoom(room: Partial<LivekitRoom>): LivekitRoom {
|
export function mockLivekitRoom(
|
||||||
return { ...mockEmitter(), ...room } as Partial<LivekitRoom> as LivekitRoom;
|
room: Partial<LivekitRoom>,
|
||||||
|
{
|
||||||
|
remoteParticipants,
|
||||||
|
}: { remoteParticipants?: Observable<RemoteParticipant[]> } = {},
|
||||||
|
): LivekitRoom {
|
||||||
|
const livekitRoom = {
|
||||||
|
...mockEmitter(),
|
||||||
|
...room,
|
||||||
|
} as Partial<LivekitRoom> as LivekitRoom;
|
||||||
|
if (remoteParticipants) {
|
||||||
|
livekitRoom.remoteParticipants = new Map();
|
||||||
|
remoteParticipants.subscribe((newRemoteParticipants) => {
|
||||||
|
livekitRoom.remoteParticipants.clear();
|
||||||
|
newRemoteParticipants.forEach((p) => {
|
||||||
|
livekitRoom.remoteParticipants.set(p.identity, p);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return livekitRoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockLocalParticipant(
|
export function mockLocalParticipant(
|
||||||
@ -119,13 +138,15 @@ export async function withLocalMedia(
|
|||||||
member: Partial<RoomMember>,
|
member: Partial<RoomMember>,
|
||||||
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
|
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const localParticipant = mockLocalParticipant({});
|
||||||
const vm = new LocalUserMediaViewModel(
|
const vm = new LocalUserMediaViewModel(
|
||||||
"local",
|
"local",
|
||||||
mockMember(member),
|
mockMember(member),
|
||||||
mockLocalParticipant({}),
|
localParticipant,
|
||||||
{
|
{
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
|
mockLivekitRoom({ localParticipant }),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await continuation(vm);
|
await continuation(vm);
|
||||||
@ -152,13 +173,15 @@ export async function withRemoteMedia(
|
|||||||
participant: Partial<RemoteParticipant>,
|
participant: Partial<RemoteParticipant>,
|
||||||
continuation: (vm: RemoteUserMediaViewModel) => void | Promise<void>,
|
continuation: (vm: RemoteUserMediaViewModel) => void | Promise<void>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const remoteParticipant = mockRemoteParticipant(participant);
|
||||||
const vm = new RemoteUserMediaViewModel(
|
const vm = new RemoteUserMediaViewModel(
|
||||||
"remote",
|
"remote",
|
||||||
mockMember(member),
|
mockMember(member),
|
||||||
mockRemoteParticipant(participant),
|
remoteParticipant,
|
||||||
{
|
{
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
|
mockLivekitRoom({}, { remoteParticipants: of([remoteParticipant]) }),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await continuation(vm);
|
await continuation(vm);
|
||||||
|
Loading…
Reference in New Issue
Block a user