From d2aa6d561f540c0b8e3e1cc8545d2584110e5e8f Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 28 Oct 2024 15:15:02 -0400 Subject: [PATCH 1/8] Hide the 'profile' menu option in widget mode The profile settings tab is non-functional in widget mode so we need to hide its other points of entry. --- src/room/InCallView.tsx | 15 +++++++++++---- src/tile/GridTile.tsx | 16 +++++++++------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index d50be3c9..ec981c45 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -260,10 +260,17 @@ export const InCallView: FC = ({ [setSettingsModalOpen], ); - const openProfile = useCallback(() => { - setSettingsTab("profile"); - setSettingsModalOpen(true); - }, [setSettingsTab, setSettingsModalOpen]); + const openProfile = useMemo( + () => + // Profile settings are unavailable in widget mode + widget === null + ? (): void => { + setSettingsTab("profile"); + setSettingsModalOpen(true); + } + : null, + [setSettingsTab, setSettingsModalOpen], + ); const [headerRef, headerBounds] = useMeasure(); const [footerRef, footerBounds] = useMeasure(); diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index a85abe23..1eb0b933 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -160,7 +160,7 @@ UserMediaTile.displayName = "UserMediaTile"; interface LocalUserMediaTileProps extends TileProps { vm: LocalUserMediaViewModel; - onOpenProfile: () => void; + onOpenProfile: (() => void) | null; } const LocalUserMediaTile = forwardRef( @@ -191,11 +191,13 @@ const LocalUserMediaTile = forwardRef( /> } menuEnd={ - + onOpenProfile && ( + + ) } {...props} /> @@ -268,7 +270,7 @@ RemoteUserMediaTile.displayName = "RemoteUserMediaTile"; interface GridTileProps { vm: UserMediaViewModel; - onOpenProfile: () => void; + onOpenProfile: (() => void) | null; targetWidth: number; targetHeight: number; className?: string; From 592ed1f0cf42cabcc9e75854ad6529bc6246c4e4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 31 Oct 2024 22:19:15 +0000 Subject: [PATCH 2/8] Update dependency livekit-client to v2.6.0 --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 7be31f53..b91892ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5644,9 +5644,9 @@ lines-and-columns@^1.1.6: integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== livekit-client@^2.5.7: - version "2.5.10" - resolved "https://registry.yarnpkg.com/livekit-client/-/livekit-client-2.5.10.tgz#ce0b70fe9b199760a159ffb99536bc3b6492ab64" - integrity sha512-H7EeIb19LAH8ejlvhh0JWtWkvXDan6Yf3bpFGlDMb54uPmyRgBY+McfgQsFgJCB9WJL0X+GYUoV1Cmnn8iAoIQ== + version "2.6.0" + resolved "https://registry.yarnpkg.com/livekit-client/-/livekit-client-2.6.0.tgz#7506838fb59937150208b8e593701cde19f3fb75" + integrity sha512-hpxNBtyWIFCefoHjHoSjqPCw3m7AfSJVcVZw6rMsqds4u+dSpWLfYkglWP8JuPGUIssyOsZm/+bV3gBWfuOGGQ== dependencies: "@livekit/mutex" "1.0.0" "@livekit/protocol" "1.24.0" From 05b06ce3e25bf2709a642cd220a3cd84e718667b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 1 Nov 2024 01:16:40 +0000 Subject: [PATCH 3/8] Update GitHub Actions --- .github/workflows/docker.yaml | 2 +- .github/workflows/e2e.yml | 2 +- .github/workflows/element-call.yaml | 4 ++-- .github/workflows/lint.yaml | 4 ++-- .github/workflows/test.yaml | 4 ++-- .github/workflows/translations-download.yaml | 4 ++-- .github/workflows/translations-upload.yaml | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 3f707a36..c85cddf5 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -23,7 +23,7 @@ jobs: packages: write steps: - name: Check it out - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: ๐Ÿ“ฅ Download artifact uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c1fbca28..ff347636 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out test private repo - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: repository: element-hq/static-call-participant ref: refs/heads/main diff --git a/.github/workflows/element-call.yaml b/.github/workflows/element-call.yaml index 5a342cb8..eaa392be 100644 --- a/.github/workflows/element-call.yaml +++ b/.github/workflows/element-call.yaml @@ -21,9 +21,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Yarn cache - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4 + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4 with: cache: "yarn" node-version: "lts/*" diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 585f0f43..69493ff6 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -7,9 +7,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Yarn cache - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4 + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4 with: cache: "yarn" node-version: "lts/*" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index fde77b63..b63eb283 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,9 +9,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Yarn cache - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4 + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4 with: cache: "yarn" node-version: "lts/*" diff --git a/.github/workflows/translations-download.yaml b/.github/workflows/translations-download.yaml index 1caab0b0..d03040e4 100644 --- a/.github/workflows/translations-download.yaml +++ b/.github/workflows/translations-download.yaml @@ -13,9 +13,9 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4 + - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4 with: cache: "yarn" node-version: "lts/*" diff --git a/.github/workflows/translations-upload.yaml b/.github/workflows/translations-upload.yaml index f52421b8..bab7a0d2 100644 --- a/.github/workflows/translations-upload.yaml +++ b/.github/workflows/translations-upload.yaml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Upload uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1 From 8c0280954c420090dc236daea5c45ee8158bdaa2 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 28 Oct 2024 17:29:26 -0400 Subject: [PATCH 4/8] Add a button to switch the camera on mobile --- public/locales/en-GB/app.json | 1 + src/button/Button.tsx | 18 +++++++ src/room/InCallView.tsx | 17 ++++-- src/room/LobbyView.tsx | 73 +++++++++++++++++++++++++- src/room/VideoPreview.tsx | 59 ++------------------- src/room/useSwitchCamera.ts | 98 +++++++++++++++++++++++++++++++++++ src/state/CallViewModel.ts | 14 +++++ src/state/MediaViewModel.ts | 2 +- 8 files changed, 219 insertions(+), 63 deletions(-) create mode 100644 src/room/useSwitchCamera.ts diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index 0cfa3085..fa4066ad 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -154,6 +154,7 @@ "stop_screenshare_button_label": "Sharing screen", "stop_video_button_label": "Stop video", "submitting": "Submittingโ€ฆ", + "switch_camera": "Switch camera", "unauthenticated_view_body": "Not registered yet? <2>Create an account", "unauthenticated_view_eula_caption": "By clicking \"Go\", you agree to our <2>End User Licensing Agreement (EULA)", "unauthenticated_view_login_button": "Login to your account", diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 5d747a03..5c85ddbf 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -16,6 +16,7 @@ import { EndCallIcon, ShareScreenSolidIcon, SettingsSolidIcon, + SwitchCameraSolidIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import styles from "./Button.module.css"; @@ -66,6 +67,23 @@ export const VideoButton: FC = ({ muted, ...props }) => { ); }; +export const SwitchCameraButton: FC> = ( + props, +) => { + const { t } = useTranslation(); + + return ( + + + + ); +}; + interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> { enabled: boolean; } diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index d50be3c9..9ec2108a 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -40,6 +40,7 @@ import { VideoButton, ShareScreenButton, SettingsButton, + SwitchCameraButton, } from "../button"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { useUrlParams } from "../UrlParams"; @@ -78,6 +79,7 @@ import { makeOneOnOneLayout } from "../grid/OneOnOneLayout"; import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout"; import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout"; import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout"; +import { useSwitchCamera } from "./useSwitchCamera"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -217,6 +219,7 @@ export const InCallView: FC = ({ const gridMode = useObservableEagerState(vm.gridMode); const showHeader = useObservableEagerState(vm.showHeader); const showFooter = useObservableEagerState(vm.showFooter); + const switchCamera = useSwitchCamera(vm.localVideo); // Ideally we could detect taps by listening for click events and checking // that the pointerType of the event is "touch", but this isn't yet supported @@ -488,14 +491,14 @@ export const InCallView: FC = ({ buttons.push( , = ({ />, ); if (!reducedControls) { + if (switchCamera !== null) + buttons.push( + , + ); if (canScreenshare && !hideScreensharing) { buttons.push( , ); } - buttons.push(); + buttons.push(); } buttons.push( = ({ ); + const devices = useMediaDevices(); + + // Capture the audio options as they were when we first mounted, because + // we're not doing anything with the audio anyway so we don't need to + // re-open the devices when they change (see below). + const initialAudioOptions = useInitial( + () => + muteStates.audio.enabled && { deviceId: devices.audioInput.selectedId }, + ); + + const localTrackOptions = useMemo( + () => ({ + // The only reason we request audio here is to get the audio permission + // request over with at the same time. But changing the audio settings + // shouldn't cause this hook to recreate the track, which is why we + // reference the initial values here. + // We also pass in a clone because livekit mutates the object passed in, + // which would cause the devices to be re-opened on the next render. + audio: Object.assign({}, initialAudioOptions), + video: muteStates.video.enabled && { + deviceId: devices.videoInput.selectedId, + }, + }), + [ + initialAudioOptions, + devices.videoInput.selectedId, + muteStates.video.enabled, + ], + ); + + const onError = useCallback( + (error: Error) => { + logger.error("Error while creating preview Tracks:", error); + muteStates.audio.setEnabled?.(false); + muteStates.video.setEnabled?.(false); + }, + [muteStates.audio, muteStates.video], + ); + + const tracks = usePreviewTracks(localTrackOptions, onError); + + const videoTrack = useMemo( + () => + (tracks?.find((t) => t.kind === Track.Kind.Video) ?? + null) as LocalVideoTrack | null, + [tracks], + ); + + const switchCamera = useSwitchCamera( + useObservable( + (inputs) => inputs.pipe(map(([video]) => video)), + [videoTrack], + ), + ); + // TODO: Unify this component with InCallView, so we can get slick joining // animations and don't have to feel bad about reusing its CSS return ( @@ -111,7 +175,11 @@ export const LobbyView: FC = ({ )}
- +
diff --git a/src/room/VideoPreview.tsx b/src/room/VideoPreview.tsx index f8d45971..80aa1069 100644 --- a/src/room/VideoPreview.tsx +++ b/src/room/VideoPreview.tsx @@ -5,18 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import { useEffect, useMemo, useRef, FC, ReactNode, useCallback } from "react"; +import { useEffect, useRef, FC, ReactNode } from "react"; import useMeasure from "react-use-measure"; -import { usePreviewTracks } from "@livekit/components-react"; -import { LocalVideoTrack, Track } from "livekit-client"; +import { LocalVideoTrack } from "livekit-client"; import classNames from "classnames"; -import { logger } from "matrix-js-sdk/src/logger"; import { Avatar } from "../Avatar"; import styles from "./VideoPreview.module.css"; -import { useMediaDevices } from "../livekit/MediaDevicesContext"; import { MuteStates } from "./MuteStates"; -import { useInitial } from "../useInitial"; import { EncryptionSystem } from "../e2ee/sharedKeyManagement"; export type MatrixInfo = { @@ -33,65 +29,18 @@ export type MatrixInfo = { interface Props { matrixInfo: MatrixInfo; muteStates: MuteStates; + videoTrack: LocalVideoTrack | null; children: ReactNode; } export const VideoPreview: FC = ({ matrixInfo, muteStates, + videoTrack, children, }) => { const [previewRef, previewBounds] = useMeasure(); - const devices = useMediaDevices(); - - // Capture the audio options as they were when we first mounted, because - // we're not doing anything with the audio anyway so we don't need to - // re-open the devices when they change (see below). - const initialAudioOptions = useInitial( - () => - muteStates.audio.enabled && { deviceId: devices.audioInput.selectedId }, - ); - - const localTrackOptions = useMemo( - () => ({ - // The only reason we request audio here is to get the audio permission - // request over with at the same time. But changing the audio settings - // shouldn't cause this hook to recreate the track, which is why we - // reference the initial values here. - // We also pass in a clone because livekit mutates the object passed in, - // which would cause the devices to be re-opened on the next render. - audio: Object.assign({}, initialAudioOptions), - video: muteStates.video.enabled && { - deviceId: devices.videoInput.selectedId, - }, - }), - [ - initialAudioOptions, - devices.videoInput.selectedId, - muteStates.video.enabled, - ], - ); - - const onError = useCallback( - (error: Error) => { - logger.error("Error while creating preview Tracks:", error); - muteStates.audio.setEnabled?.(false); - muteStates.video.setEnabled?.(false); - }, - [muteStates.audio, muteStates.video], - ); - - const tracks = usePreviewTracks(localTrackOptions, onError); - - const videoTrack = useMemo( - () => - tracks?.find((t) => t.kind === Track.Kind.Video) as - | LocalVideoTrack - | undefined, - [tracks], - ); - const videoEl = useRef(null); useEffect(() => { diff --git a/src/room/useSwitchCamera.ts b/src/room/useSwitchCamera.ts new file mode 100644 index 00000000..e0434566 --- /dev/null +++ b/src/room/useSwitchCamera.ts @@ -0,0 +1,98 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { + fromEvent, + map, + merge, + Observable, + of, + startWith, + switchMap, +} from "rxjs"; +import { + facingModeFromLocalTrack, + LocalVideoTrack, + TrackEvent, +} from "livekit-client"; +import { useObservable, useObservableEagerState } from "observable-hooks"; +import { useEffect } from "react"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { useMediaDevices } from "../livekit/MediaDevicesContext"; +import { platform } from "../Platform"; +import { useLatest } from "../useLatest"; + +/** + * Determines whether the user should be shown a button to switch their camera, + * producing a callback if so. + */ +export function useSwitchCamera( + video: Observable, +): (() => void) | null { + const mediaDevices = useMediaDevices(); + + // Produce an observable like the input 'video' observable, except make it + // emit whenever the track is muted or the device changes + const videoTrack: Observable = useObservable( + (inputs) => + inputs.pipe( + switchMap(([video]) => video), + switchMap((video) => { + if (video === null) return of(null); + return merge( + fromEvent(video, TrackEvent.Restarted).pipe( + startWith(null), + map(() => video), + ), + fromEvent(video, TrackEvent.Muted).pipe(map(() => null)), + ); + }), + ), + [video], + ); + + const switchCamera: Observable<(() => void) | null> = useObservable( + (inputs) => + platform === "desktop" + ? of(null) + : inputs.pipe( + switchMap(([track]) => track), + map((track) => { + if (track === null) return null; + const facingMode = facingModeFromLocalTrack(track).facingMode; + // If the camera isn't front or back-facing, don't provide a switch + // camera shortcut at all + if (facingMode !== "user" && facingMode !== "environment") + return null; + // Restart the track with a camera facing the opposite direction + return (): void => + void track + .restartTrack({ + facingMode: facingMode === "user" ? "environment" : "user", + }) + .catch((e) => + logger.error("Failed to switch camera", facingMode, e), + ); + }), + ), + [videoTrack], + ); + + const setVideoInput = useLatest(mediaDevices.videoInput.select); + useEffect(() => { + // Watch for device changes due to switching the camera and feed them back + // into the MediaDeviceContext + const subscription = videoTrack.subscribe((track) => { + const deviceId = track?.mediaStreamTrack.getSettings().deviceId; + if (deviceId !== undefined) setVideoInput.current(deviceId); + }); + return (): void => subscription.unsubscribe(); + }, [videoTrack, setVideoInput]); + + return useObservableEagerState(switchCamera); +} diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 00e10dfe..6064e611 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -13,8 +13,10 @@ import { import { Room as LivekitRoom, LocalParticipant, + LocalVideoTrack, ParticipantEvent, RemoteParticipant, + Track, } from "livekit-client"; import { Room as MatrixRoom, @@ -58,6 +60,7 @@ import { import { LocalUserMediaViewModel, MediaViewModel, + observeTrackReference, RemoteUserMediaViewModel, ScreenShareViewModel, UserMediaViewModel, @@ -259,6 +262,17 @@ function findMatrixMember( // TODO: Move wayyyy more business logic from the call and lobby views into here export class CallViewModel extends ViewModel { + public readonly localVideo: Observable = + observeTrackReference( + this.livekitRoom.localParticipant, + Track.Source.Camera, + ).pipe( + map((trackRef) => { + const track = trackRef.publication?.track; + return track instanceof LocalVideoTrack ? track : null; + }), + ); + private readonly rawRemoteParticipants = connectedParticipantsObserver( this.livekitRoom, ).pipe(this.scope.state()); diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index 26894f9e..50d8613a 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -65,7 +65,7 @@ export function useDisplayName(vm: MediaViewModel): string { return displayName; } -function observeTrackReference( +export function observeTrackReference( participant: Participant, source: Track.Source, ): Observable { From 7ad70064a37acd1377bc270ff9af50b466b2dd95 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 1 Nov 2024 16:56:43 -0400 Subject: [PATCH 5/8] Add a config option to disable the app prompt --- docs/url-params.md | 2 ++ src/UrlParams.ts | 2 ++ src/config/ConfigOptions.ts | 10 ++++++++++ src/room/RoomPage.tsx | 2 ++ 4 files changed, 16 insertions(+) diff --git a/docs/url-params.md b/docs/url-params.md index 4a22359a..c45c2610 100644 --- a/docs/url-params.md +++ b/docs/url-params.md @@ -98,6 +98,8 @@ confineToRoom: boolean; (default: false) Whether upon entering a room, the user should be prompted to launch the native mobile app. (Affects only Android and iOS.) +The app prompt must also be enabled in the config for this to take effect. + ```ts appPrompt: boolean; (default: true) ``` diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 5db5f0d5..b4f6ca28 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -43,6 +43,8 @@ export interface UrlParams { /** * Whether upon entering a room, the user should be prompted to launch the * native mobile app. (Affects only Android and iOS.) + * + * The app prompt must also be enabled in the config for this to take effect. */ appPrompt: boolean; /** diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts index 4f1ed02a..65f04c95 100644 --- a/src/config/ConfigOptions.ts +++ b/src/config/ConfigOptions.ts @@ -88,6 +88,14 @@ export interface ConfigOptions { */ enable_video?: boolean; }; + + /** + * Whether upon entering a room, the user should be prompted to launch the + * native mobile app. (Affects only Android and iOS.) + * + * Note that this can additionally be disabled by the app's URL parameters. + */ + app_prompt?: boolean; } // Overrides members from ConfigOptions that are always provided by the @@ -103,6 +111,7 @@ export interface ResolvedConfigOptions extends ConfigOptions { enable_audio: boolean; enable_video: boolean; }; + app_prompt: boolean; } export const DEFAULT_CONFIG: ResolvedConfigOptions = { @@ -117,4 +126,5 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = { enable_audio: true, enable_video: true, }, + app_prompt: true, }; diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index bb32040d..ce6c9f70 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -27,6 +27,7 @@ import { E2eeType } from "../e2ee/e2eeType"; import { useProfile } from "../profile/useProfile"; import { useMuteStates } from "./MuteStates"; import { useOptInAnalytics } from "../settings/settings"; +import { Config } from "../config/Config"; export const RoomPage: FC = () => { const { @@ -185,6 +186,7 @@ export const RoomPage: FC = () => { {content} {/* On Android and iOS, show a prompt to launch the mobile app. */} {appPrompt && + Config.get().app_prompt && (platform === "android" || platform === "ios") && roomId && } From 7b0bdd320ab8323c85eccdfb528fb5638ce091a5 Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 1 Nov 2024 17:26:14 -0400 Subject: [PATCH 6/8] Don't mirror rear-facing cameras in the lobby --- src/room/VideoPreview.module.css | 5 ++++- src/room/VideoPreview.tsx | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/room/VideoPreview.module.css b/src/room/VideoPreview.module.css index e7aabf79..8b34b8a1 100644 --- a/src/room/VideoPreview.module.css +++ b/src/room/VideoPreview.module.css @@ -19,10 +19,13 @@ Please see LICENSE in the repository root for full details. height: 100%; object-fit: cover; background-color: black; - transform: scaleX(-1); background-color: var(--cpd-color-bg-subtle-primary); } +video.mirror { + transform: scaleX(-1); +} + .avatarContainer { position: absolute; top: 0; diff --git a/src/room/VideoPreview.tsx b/src/room/VideoPreview.tsx index 80aa1069..af05369e 100644 --- a/src/room/VideoPreview.tsx +++ b/src/room/VideoPreview.tsx @@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details. import { useEffect, useRef, FC, ReactNode } from "react"; import useMeasure from "react-use-measure"; -import { LocalVideoTrack } from "livekit-client"; +import { facingModeFromLocalTrack, LocalVideoTrack } from "livekit-client"; import classNames from "classnames"; import { Avatar } from "../Avatar"; @@ -56,6 +56,12 @@ export const VideoPreview: FC = ({ return (
+
{nameTagLeadingIcon} diff --git a/src/useReactions.test.tsx b/src/useReactions.test.tsx new file mode 100644 index 00000000..79caeb0a --- /dev/null +++ b/src/useReactions.test.tsx @@ -0,0 +1,268 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { act, render } from "@testing-library/react"; +import { FC, ReactNode } from "react"; +import { describe, expect, test } from "vitest"; +import { + MatrixRTCSession, + MatrixRTCSessionEvent, +} from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; +import { + EventTimeline, + EventTimelineSet, + EventType, + MatrixClient, + MatrixEvent, + Room, + RoomEvent, +} from "matrix-js-sdk/src/matrix"; +import EventEmitter from "events"; +import { randomUUID } from "crypto"; + +import { ReactionsProvider, useReactions } from "./useReactions"; + +/** + * Test explanation. + * This test suite checks that the useReactions hook appropriately reacts + * to new reactions, redactions and membership changesin the room. There is + * a large amount of test structure used to construct a mock environment. + */ + +const memberUserIdAlice = "@alice:example.org"; +const memberEventAlice = "$membership-alice:example.org"; +const memberUserIdBob = "@bob:example.org"; +const memberEventBob = "$membership-bob:example.org"; + +const membership: Record = { + [memberEventAlice]: memberUserIdAlice, + [memberEventBob]: memberUserIdBob, + "$membership-charlie:example.org": "@charlie:example.org", +}; + +const TestComponent: FC = () => { + const { raisedHands, myReactionId } = useReactions(); + return ( +
+
    + {Object.entries(raisedHands).map(([userId, date]) => ( +
  • + {userId} + +
  • + ))} +
+

{myReactionId ? "Local reaction" : "No local reaction"}

+
+ ); +}; + +const TestComponentWrapper = ({ + rtcSession, +}: { + rtcSession: MockRTCSession; +}): ReactNode => { + return ( + + + + ); +}; + +export class MockRTCSession extends EventEmitter { + public memberships = Object.entries(membership).map(([eventId, sender]) => ({ + sender, + eventId, + createdTs: (): Date => new Date(), + })); + + public constructor(public readonly room: MockRoom) { + super(); + } + + public testRemoveMember(userId: string): void { + this.memberships = this.memberships.filter((u) => u.sender !== userId); + this.emit(MatrixRTCSessionEvent.MembershipsChanged); + } + + public testAddMember(sender: string): void { + this.memberships.push({ + sender, + eventId: `!fake-${randomUUID()}:event`, + createdTs: (): Date => new Date(), + }); + this.emit(MatrixRTCSessionEvent.MembershipsChanged); + } +} + +function createReaction( + parentMemberEvent: string, + overridenSender?: string, +): MatrixEvent { + return new MatrixEvent({ + sender: overridenSender ?? membership[parentMemberEvent], + type: EventType.Reaction, + origin_server_ts: new Date().getTime(), + content: { + "m.relates_to": { + key: "๐Ÿ–๏ธ", + event_id: parentMemberEvent, + }, + }, + event_id: randomUUID(), + }); +} + +function createRedaction(sender: string, reactionEventId: string): MatrixEvent { + return new MatrixEvent({ + sender, + type: EventType.RoomRedaction, + origin_server_ts: new Date().getTime(), + redacts: reactionEventId, + content: {}, + event_id: randomUUID(), + }); +} + +export class MockRoom extends EventEmitter { + public constructor(private readonly existingRelations: MatrixEvent[] = []) { + super(); + } + + public get client(): MatrixClient { + return { + getUserId: (): string => memberUserIdAlice, + } as unknown as MatrixClient; + } + + public get relations(): Room["relations"] { + return { + getChildEventsForEvent: (membershipEventId: string) => ({ + getRelations: (): MatrixEvent[] => { + return this.existingRelations.filter( + (r) => + r.getContent()["m.relates_to"]?.event_id === membershipEventId, + ); + }, + }), + } as unknown as Room["relations"]; + } + + public testSendReaction( + parentMemberEvent: string, + overridenSender?: string, + ): string { + const evt = createReaction(parentMemberEvent, overridenSender); + this.emit(RoomEvent.Timeline, evt, this, undefined, false, { + timeline: new EventTimeline(new EventTimelineSet(undefined)), + }); + return evt.getId()!; + } +} + +describe("useReactions", () => { + test("starts with an empty list", () => { + const rtcSession = new MockRTCSession(new MockRoom()); + const { queryByRole } = render( + , + ); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); + test("handles own raised hand", async () => { + const room = new MockRoom(); + const rtcSession = new MockRTCSession(room); + const { queryByText } = render( + , + ); + await act(() => room.testSendReaction(memberEventAlice)); + expect(queryByText("Local reaction")).toBeTruthy(); + }); + test("handles incoming raised hand", async () => { + const room = new MockRoom(); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + await act(() => room.testSendReaction(memberEventAlice)); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + await act(() => room.testSendReaction(memberEventBob)); + expect(queryByRole("list")?.children).to.have.lengthOf(2); + }); + test("handles incoming unraised hand", async () => { + const room = new MockRoom(); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + const reactionEventId = await act(() => + room.testSendReaction(memberEventAlice), + ); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + await act(() => + room.emit( + RoomEvent.Redaction, + createRedaction(memberUserIdAlice, reactionEventId), + room, + undefined, + ), + ); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); + test("handles loading prior raised hand events", () => { + const room = new MockRoom([createReaction(memberEventAlice)]); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + }); + // If the membership event changes for a user, we want to remove + // the raised hand event. + test("will remove reaction when a member leaves the call", () => { + const room = new MockRoom([createReaction(memberEventAlice)]); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + act(() => rtcSession.testRemoveMember(memberUserIdAlice)); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); + test("will remove reaction when a member joins via a new event", () => { + const room = new MockRoom([createReaction(memberEventAlice)]); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + // Simulate leaving and rejoining + act(() => { + rtcSession.testRemoveMember(memberUserIdAlice); + rtcSession.testAddMember(memberUserIdAlice); + }); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); + test("ignores invalid sender for historic event", () => { + const room = new MockRoom([ + createReaction(memberEventAlice, memberUserIdBob), + ]); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); + test("ignores invalid sender for new event", async () => { + const room = new MockRoom([]); + const rtcSession = new MockRTCSession(room); + const { queryByRole } = render( + , + ); + await act(() => room.testSendReaction(memberEventAlice, memberUserIdBob)); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); +}); diff --git a/src/useReactions.tsx b/src/useReactions.tsx new file mode 100644 index 00000000..33031847 --- /dev/null +++ b/src/useReactions.tsx @@ -0,0 +1,249 @@ +/* +Copyright 2024 Milton Moura + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { + EventType, + MatrixEvent, + RelationType, + RoomEvent as MatrixRoomEvent, +} from "matrix-js-sdk/src/matrix"; +import { ReactionEventContent } from "matrix-js-sdk/src/types"; +import { + createContext, + useContext, + useState, + ReactNode, + useCallback, + useEffect, + useMemo, +} from "react"; +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships"; +import { useClientState } from "./ClientContext"; + +interface ReactionsContextType { + raisedHands: Record; + supportsReactions: boolean; + myReactionId: string | null; +} + +const ReactionsContext = createContext( + undefined, +); + +interface RaisedHandInfo { + /** + * Call membership event that was reacted to. + */ + membershipEventId: string; + /** + * Event ID of the reaction itself. + */ + reactionEventId: string; + /** + * The time when the reaction was raised. + */ + time: Date; +} + +export const useReactions = (): ReactionsContextType => { + const context = useContext(ReactionsContext); + if (!context) { + throw new Error("useReactions must be used within a ReactionsProvider"); + } + return context; +}; + +/** + * Provider that handles raised hand reactions for a given `rtcSession`. + */ +export const ReactionsProvider = ({ + children, + rtcSession, +}: { + children: ReactNode; + rtcSession: MatrixRTCSession; +}): JSX.Element => { + const [raisedHands, setRaisedHands] = useState< + Record + >({}); + const memberships = useMatrixRTCSessionMemberships(rtcSession); + const clientState = useClientState(); + const supportsReactions = + clientState?.state === "valid" && clientState.supportedFeatures.reactions; + const room = rtcSession.room; + const myUserId = room.client.getUserId(); + + // Calculate our own reaction event. + const myReactionId = useMemo( + (): string | null => + (myUserId && raisedHands[myUserId]?.reactionEventId) ?? null, + [raisedHands, myUserId], + ); + + // Reduce the data down for the consumers. + const resultRaisedHands = useMemo( + () => + Object.fromEntries( + Object.entries(raisedHands).map(([uid, data]) => [uid, data.time]), + ), + [raisedHands], + ); + + const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => { + setRaisedHands((prevRaisedHands) => ({ + ...prevRaisedHands, + [userId]: info, + })); + }, []); + + const removeRaisedHand = useCallback((userId: string) => { + setRaisedHands( + ({ [userId]: _removed, ...remainingRaisedHands }) => remainingRaisedHands, + ); + }, []); + + // This effect will check the state whenever the membership of the session changes. + useEffect(() => { + // Fetches the first reaction for a given event. + const getLastReactionEvent = ( + eventId: string, + expectedSender: string, + ): MatrixEvent | undefined => { + const relations = room.relations.getChildEventsForEvent( + eventId, + RelationType.Annotation, + EventType.Reaction, + ); + const allEvents = relations?.getRelations() ?? []; + return allEvents.find( + (reaction) => + reaction.event.sender === expectedSender && + reaction.getType() === EventType.Reaction && + reaction.getContent()?.["m.relates_to"]?.key === "๐Ÿ–๏ธ", + ); + }; + + // Remove any raised hands for users no longer joined to the call. + for (const userId of Object.keys(raisedHands).filter( + (rhId) => !memberships.find((u) => u.sender == rhId), + )) { + removeRaisedHand(userId); + } + + // For each member in the call, check to see if a reaction has + // been raised and adjust. + for (const m of memberships) { + if (!m.sender || !m.eventId) { + continue; + } + if ( + raisedHands[m.sender] && + raisedHands[m.sender].membershipEventId !== m.eventId + ) { + // Membership event for sender has changed since the hand + // was raised, reset. + removeRaisedHand(m.sender); + } + const reaction = getLastReactionEvent(m.eventId, m.sender); + if (reaction) { + const eventId = reaction?.getId(); + if (!eventId) { + continue; + } + addRaisedHand(m.sender, { + membershipEventId: m.eventId, + reactionEventId: eventId, + time: new Date(reaction.localTimestamp), + }); + } + } + // Ignoring raisedHands here because we don't want to trigger each time the raised + // hands set is updated. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [room, memberships, myUserId, addRaisedHand, removeRaisedHand]); + + // This effect handles any *live* reaction/redactions in the room. + useEffect(() => { + const handleReactionEvent = (event: MatrixEvent): void => { + if (event.isSending()) { + // Skip any events that are still sending. + return; + } + + const sender = event.getSender(); + const reactionEventId = event.getId(); + if (!sender || !reactionEventId) { + // Skip any event without a sender or event ID. + return; + } + + if (event.getType() === EventType.Reaction) { + const content = event.getContent() as ReactionEventContent; + const membershipEventId = content["m.relates_to"].event_id; + + // Check to see if this reaction was made to a membership event (and the + // sender of the reaction matches the membership) + if ( + !memberships.some( + (e) => e.eventId === membershipEventId && e.sender === sender, + ) + ) { + logger.warn( + `Reaction target was not a membership event for ${sender}, ignoring`, + ); + return; + } + + if (content?.["m.relates_to"].key === "๐Ÿ–๏ธ") { + addRaisedHand(sender, { + reactionEventId, + membershipEventId, + time: new Date(event.localTimestamp), + }); + } + } else if (event.getType() === EventType.RoomRedaction) { + const targetEvent = event.event.redacts; + const targetUser = Object.entries(raisedHands).find( + ([_u, r]) => r.reactionEventId === targetEvent, + )?.[0]; + if (!targetUser) { + // Reaction target was not for us, ignoring + return; + } + removeRaisedHand(targetUser); + } + }; + + room.on(MatrixRoomEvent.Timeline, handleReactionEvent); + room.on(MatrixRoomEvent.Redaction, handleReactionEvent); + + // We listen for a local echo to get the real event ID, as timeline events + // may still be sending. + room.on(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent); + + return (): void => { + room.off(MatrixRoomEvent.Timeline, handleReactionEvent); + room.off(MatrixRoomEvent.Redaction, handleReactionEvent); + room.off(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent); + }; + }, [room, addRaisedHand, removeRaisedHand, memberships, raisedHands]); + + return ( + + {children} + + ); +}; diff --git a/src/widget.ts b/src/widget.ts index f08968b6..9d3da479 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -103,6 +103,8 @@ export const widget = ((): WidgetHelpers | null => { const sendRecvEvent = [ "org.matrix.rageshake_request", EventType.CallEncryptionKeysPrefix, + EventType.Reaction, + EventType.RoomRedaction, ]; const sendState = [ diff --git a/yarn.lock b/yarn.lock index b91892ce..bf4b7536 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1601,6 +1601,38 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62" integrity sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig== +"@formatjs/ecma402-abstract@2.2.1": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-2.2.1.tgz#2e62bc5c22b0e6a5e13bfec6aac15d3d403e1065" + integrity sha512-O4ywpkdJybrjFc9zyL8qK5aklleIAi5O4nYhBVJaOFtCkNrnU+lKFeJOFC48zpsZQmR8Aok2V79hGpHnzbmFpg== + dependencies: + "@formatjs/fast-memoize" "2.2.2" + "@formatjs/intl-localematcher" "0.5.6" + tslib "2" + +"@formatjs/fast-memoize@2.2.2": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-2.2.2.tgz#2409ec10f5f7d6c65f4c04e6c2d6cc56fa1e4cef" + integrity sha512-mzxZcS0g1pOzwZTslJOBTmLzDXseMLLvnh25ymRilCm8QLMObsQ7x/rj9GNrH0iUhZMlFisVOD6J1n6WQqpKPQ== + dependencies: + tslib "2" + +"@formatjs/intl-durationformat@^0.6.1": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@formatjs/intl-durationformat/-/intl-durationformat-0.6.1.tgz#ea376202b1dc70683a3f3e125bb07f4fab1135a5" + integrity sha512-tPSX/D/wjO5ZKnRtwLlUYtjLUBILLX1w6+arU97NpPCpZ8SRWQePu+kDAxDwFKJ/w09idqvSFkJjYGTs6hMd1A== + dependencies: + "@formatjs/ecma402-abstract" "2.2.1" + "@formatjs/intl-localematcher" "0.5.6" + tslib "2" + +"@formatjs/intl-localematcher@0.5.6": + version "0.5.6" + resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.5.6.tgz#cd0cd99483673d3196a15b4e2c924cfda7f002f8" + integrity sha512-roz1+Ba5e23AHX6KUAWmLEyTRZegM5YDuxuvkHCyK3RJddf/UXB2f+s7pOMm9ktfPGla0g+mQXOn5vsuYirnaA== + dependencies: + tslib "2" + "@gulpjs/to-absolute-glob@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@gulpjs/to-absolute-glob/-/to-absolute-glob-4.0.0.tgz#1fc2460d3953e1d9b9f2dfdb4bcc99da4710c021" @@ -7631,16 +7663,16 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" +tslib@2, tslib@^2.0.0, tslib@^2.1.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.0.tgz#d124c86c3c05a40a91e6fdea4021bd31d377971b" + integrity sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA== + tslib@2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== -tslib@^2.0.0, tslib@^2.1.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.0.tgz#d124c86c3c05a40a91e6fdea4021bd31d377971b" - integrity sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA== - tslib@^2.0.3: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"