Show decryption errors as status overlay on video tiles

This commit is contained in:
Hugh Nimmo-Smith
2024-07-15 14:32:16 +01:00
parent 30253b0c19
commit 14e017f60e
5 changed files with 70 additions and 5 deletions

View File

@@ -18,5 +18,8 @@
"[javascript]": { "[javascript]": {
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
} },
"i18n-ally.localesPaths": [
"public/locales"
]
} }

View File

@@ -60,7 +60,7 @@
"i18next": "^23.0.0", "i18next": "^23.0.0",
"i18next-browser-languagedetector": "^8.0.0", "i18next-browser-languagedetector": "^8.0.0",
"i18next-http-backend": "^2.0.0", "i18next-http-backend": "^2.0.0",
"livekit-client": "^2.0.2", "livekit-client": "github:hughns/client-sdk-js#4c7c7e72c3a980347a0bacf479b4c0ba39f8b19d",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#0fe53876ec555482bbeb086fd4dec8de7b2e21eb", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#0fe53876ec555482bbeb086fd4dec8de7b2e21eb",
"matrix-widget-api": "^1.8.2", "matrix-widget-api": "^1.8.2",
@@ -128,4 +128,4 @@
"vite-plugin-svgr": "^4.0.0", "vite-plugin-svgr": "^4.0.0",
"vitest": "^2.0.0" "vitest": "^2.0.0"
} }
} }

View File

@@ -241,8 +241,15 @@ class ScreenShare {
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant, participant: LocalParticipant | RemoteParticipant,
callEncrypted: boolean, callEncrypted: boolean,
livekitRoom: LivekitRoom,
) { ) {
this.vm = new ScreenShareViewModel(id, member, participant, callEncrypted); this.vm = new ScreenShareViewModel(
id,
member,
participant,
callEncrypted,
livekitRoom,
);
} }
public destroy(): void { public destroy(): void {
@@ -437,6 +444,7 @@ export class CallViewModel extends ViewModel {
member, member,
p, p,
this.encrypted, this.encrypted,
this.livekitRoom,
), ),
]; ];
} }

View File

@@ -20,6 +20,7 @@ import {
VideoSource, VideoSource,
observeParticipantEvents, observeParticipantEvents,
observeParticipantMedia, observeParticipantMedia,
roomEventSelector,
} from "@livekit/components-core"; } from "@livekit/components-core";
import { import {
LocalParticipant, LocalParticipant,
@@ -30,20 +31,26 @@ import {
Track, Track,
TrackEvent, TrackEvent,
facingModeFromLocalTrack, facingModeFromLocalTrack,
Room as LivekitRoom,
RoomEvent as LivekitRoomEvent,
} 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 {
BehaviorSubject, BehaviorSubject,
Observable, Observable,
combineLatest, combineLatest,
debounceTime,
distinctUntilChanged, distinctUntilChanged,
distinctUntilKeyChanged, distinctUntilKeyChanged,
filter,
fromEvent, fromEvent,
map, map,
merge,
of, of,
shareReplay, shareReplay,
startWith, startWith,
switchMap, switchMap,
throttleTime,
} from "rxjs"; } from "rxjs";
import { useEffect } from "react"; import { useEffect } from "react";
import { CallMembership } from "matrix-js-sdk/src/matrixrtc"; import { CallMembership } from "matrix-js-sdk/src/matrixrtc";
@@ -89,6 +96,29 @@ function observeTrackReference(
); );
} }
function encryptionErrorObservable(
room: LivekitRoom,
participant: Participant,
): Observable<[Error | undefined]> {
const roomEvents = roomEventSelector(
room,
LivekitRoomEvent.EncryptionError,
).pipe(
filter(([err, participantIdentity]) => {
return participantIdentity == participant.identity;
}),
throttleTime(1000), // Throttle to avoid spamming the UI
);
return merge(
roomEvents,
roomEvents.pipe(
debounceTime(3000), // Wait 3 seconds before clearing the error, toast style
map(() => [undefined]),
),
).pipe(map(([x]) => [x as Error]));
}
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.
@@ -106,6 +136,8 @@ abstract class BaseMediaViewModel extends ViewModel {
*/ */
public readonly unencryptedWarning: Observable<boolean>; public readonly unencryptedWarning: Observable<boolean>;
public readonly lastEncryptionError: Observable<Error | undefined>;
public constructor( public constructor(
/** /**
* An opaque identifier for this media. * An opaque identifier for this media.
@@ -124,6 +156,7 @@ abstract class BaseMediaViewModel extends ViewModel {
callEncrypted: boolean, callEncrypted: boolean,
audioSource: AudioSource, audioSource: AudioSource,
videoSource: VideoSource, videoSource: VideoSource,
livekitRoom: LivekitRoom,
) { ) {
super(); super();
if (participant) { if (participant) {
@@ -136,9 +169,14 @@ abstract class BaseMediaViewModel extends ViewModel {
(a.publication?.isEncrypted === false || (a.publication?.isEncrypted === false ||
v?.publication?.isEncrypted === false), v?.publication?.isEncrypted === false),
).pipe(distinctUntilChanged(), shareReplay(1)); ).pipe(distinctUntilChanged(), shareReplay(1));
this.lastEncryptionError = encryptionErrorObservable(livekitRoom, participant).pipe(
map(([error]) => error as Error),
startWith(undefined),
);
} else { } else {
this.video = of(undefined); this.video = of(undefined);
this.unencryptedWarning = of(false); this.unencryptedWarning = of(false);
this.lastEncryptionError = of(undefined);
} }
} }
} }
@@ -181,6 +219,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant | undefined, participant: LocalParticipant | RemoteParticipant | undefined,
callEncrypted: boolean, callEncrypted: boolean,
livekitRoom: LivekitRoom,
) { ) {
super( super(
id, id,
@@ -189,6 +228,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
callEncrypted, callEncrypted,
Track.Source.Microphone, Track.Source.Microphone,
Track.Source.Camera, Track.Source.Camera,
livekitRoom,
); );
if (participant) { if (participant) {
@@ -309,6 +349,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant, participant: LocalParticipant | RemoteParticipant,
callEncrypted: boolean, callEncrypted: boolean,
livekitRoom: LivekitRoom,
) { ) {
super( super(
id, id,
@@ -317,6 +358,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
callEncrypted, callEncrypted,
Track.Source.ScreenShareAudio, Track.Source.ScreenShareAudio,
Track.Source.ScreenShare, Track.Source.ScreenShare,
livekitRoom,
); );
} }
} }

View File

@@ -17,7 +17,7 @@ limitations under the License.
import { TrackReferenceOrPlaceholder } from "@livekit/components-core"; import { TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { animated } from "@react-spring/web"; import { animated } from "@react-spring/web";
import { RoomMember } from "matrix-js-sdk/src/matrix"; import { RoomMember } from "matrix-js-sdk/src/matrix";
import { ComponentProps, ReactNode, forwardRef } from "react"; import { ComponentProps, ReactNode, forwardRef, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classNames from "classnames"; import classNames from "classnames";
import { VideoTrack } from "@livekit/components-react"; import { VideoTrack } from "@livekit/components-react";
@@ -41,6 +41,7 @@ interface Props extends ComponentProps<typeof animated.div> {
nameTagLeadingIcon?: ReactNode; nameTagLeadingIcon?: ReactNode;
displayName: string; displayName: string;
primaryButton?: ReactNode; primaryButton?: ReactNode;
lastEncryptionError?: Error;
} }
export const MediaView = forwardRef<HTMLDivElement, Props>( export const MediaView = forwardRef<HTMLDivElement, Props>(
@@ -59,12 +60,18 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
nameTagLeadingIcon, nameTagLeadingIcon,
displayName, displayName,
primaryButton, primaryButton,
lastEncryptionError,
...props ...props
}, },
ref, ref,
) => { ) => {
const { t } = useTranslation(); const { t } = useTranslation();
const statusText = useMemo<string | undefined>(
() => lastEncryptionError?.message,
[lastEncryptionError],
);
return ( return (
<animated.div <animated.div
className={classNames(styles.media, className, { className={classNames(styles.media, className, {
@@ -95,6 +102,11 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
) : null} ) : null}
</div> </div>
<div className={styles.fg}> <div className={styles.fg}>
{statusText ? (
<div className={styles.status}>
<span>{statusText}</span>
</div>
) : null}
{!video ? ( {!video ? (
<div className={styles.status}> <div className={styles.status}>
<span>{t("no_media_available")}</span> <span>{t("no_media_available")}</span>