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 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/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index ea849bf0..02dd7740 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -160,6 +160,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/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/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/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/InCallView.tsx b/src/room/InCallView.tsx index 63e16d12..06feca00 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -42,6 +42,7 @@ import { ShareScreenButton, SettingsButton, RaiseHandToggleButton, + SwitchCameraButton, } from "../button"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { useUrlParams } from "../UrlParams"; @@ -82,7 +83,8 @@ import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout"; import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout"; import { ReactionsProvider, useReactions } from "../useReactions"; import handSoundOgg from "../sound/raise_hand.ogg?url"; -import handSoundMp3 from "../sound/raise_hand.mp3?url"; +import handSoundMp3 from "../sound/raise_hand.mp3?url" +import { useSwitchCamera } from "./useSwitchCamera"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -231,6 +233,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 @@ -274,10 +277,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(); @@ -515,14 +525,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/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 && } 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 f8d45971..af05369e 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 { facingModeFromLocalTrack, 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(() => { @@ -107,6 +56,12 @@ export const VideoPreview: FC = ({ return (