diff --git a/.vscode/settings.json b/.vscode/settings.json index 7fa064e3..4b7e7240 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,5 +18,8 @@ "[javascript]": { "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "i18n-ally.localesPaths": [ + "public/locales" + ] } diff --git a/package.json b/package.json index 8f1bab64..6f007123 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "i18next": "^23.0.0", "i18next-browser-languagedetector": "^8.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", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#0fe53876ec555482bbeb086fd4dec8de7b2e21eb", "matrix-widget-api": "^1.8.2", @@ -128,4 +128,4 @@ "vite-plugin-svgr": "^4.0.0", "vitest": "^2.0.0" } -} +} \ No newline at end of file diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 284b0fe3..d9b668a5 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -241,8 +241,15 @@ class ScreenShare { member: RoomMember | undefined, participant: LocalParticipant | RemoteParticipant, callEncrypted: boolean, + livekitRoom: LivekitRoom, ) { - this.vm = new ScreenShareViewModel(id, member, participant, callEncrypted); + this.vm = new ScreenShareViewModel( + id, + member, + participant, + callEncrypted, + livekitRoom, + ); } public destroy(): void { @@ -437,6 +444,7 @@ export class CallViewModel extends ViewModel { member, p, this.encrypted, + this.livekitRoom, ), ]; } diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index 4b1b86b5..898c0320 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -20,6 +20,7 @@ import { VideoSource, observeParticipantEvents, observeParticipantMedia, + roomEventSelector, } from "@livekit/components-core"; import { LocalParticipant, @@ -30,20 +31,26 @@ import { Track, TrackEvent, facingModeFromLocalTrack, + Room as LivekitRoom, + RoomEvent as LivekitRoomEvent, } from "livekit-client"; import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix"; import { BehaviorSubject, Observable, combineLatest, + debounceTime, distinctUntilChanged, distinctUntilKeyChanged, + filter, fromEvent, map, + merge, of, shareReplay, startWith, switchMap, + throttleTime, } from "rxjs"; import { useEffect } from "react"; 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 { /** * Whether the media belongs to the local user. @@ -106,6 +136,8 @@ abstract class BaseMediaViewModel extends ViewModel { */ public readonly unencryptedWarning: Observable; + public readonly lastEncryptionError: Observable; + public constructor( /** * An opaque identifier for this media. @@ -124,6 +156,7 @@ abstract class BaseMediaViewModel extends ViewModel { callEncrypted: boolean, audioSource: AudioSource, videoSource: VideoSource, + livekitRoom: LivekitRoom, ) { super(); if (participant) { @@ -136,9 +169,14 @@ abstract class BaseMediaViewModel extends ViewModel { (a.publication?.isEncrypted === false || v?.publication?.isEncrypted === false), ).pipe(distinctUntilChanged(), shareReplay(1)); + this.lastEncryptionError = encryptionErrorObservable(livekitRoom, participant).pipe( + map(([error]) => error as Error), + startWith(undefined), + ); } else { this.video = of(undefined); this.unencryptedWarning = of(false); + this.lastEncryptionError = of(undefined); } } } @@ -181,6 +219,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { member: RoomMember | undefined, participant: LocalParticipant | RemoteParticipant | undefined, callEncrypted: boolean, + livekitRoom: LivekitRoom, ) { super( id, @@ -189,6 +228,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel { callEncrypted, Track.Source.Microphone, Track.Source.Camera, + livekitRoom, ); if (participant) { @@ -309,6 +349,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel { member: RoomMember | undefined, participant: LocalParticipant | RemoteParticipant, callEncrypted: boolean, + livekitRoom: LivekitRoom, ) { super( id, @@ -317,6 +358,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel { callEncrypted, Track.Source.ScreenShareAudio, Track.Source.ScreenShare, + livekitRoom, ); } } diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index a256aff9..c984fabc 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -17,7 +17,7 @@ limitations under the License. import { TrackReferenceOrPlaceholder } from "@livekit/components-core"; import { animated } from "@react-spring/web"; 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 classNames from "classnames"; import { VideoTrack } from "@livekit/components-react"; @@ -41,6 +41,7 @@ interface Props extends ComponentProps { nameTagLeadingIcon?: ReactNode; displayName: string; primaryButton?: ReactNode; + lastEncryptionError?: Error; } export const MediaView = forwardRef( @@ -59,12 +60,18 @@ export const MediaView = forwardRef( nameTagLeadingIcon, displayName, primaryButton, + lastEncryptionError, ...props }, ref, ) => { const { t } = useTranslation(); + const statusText = useMemo( + () => lastEncryptionError?.message, + [lastEncryptionError], + ); + return ( ( ) : null}
+ {statusText ? ( +
+ {statusText} +
+ ) : null} {!video ? (
{t("no_media_available")}