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 54525f1a..6045ac34 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -161,6 +161,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/button/RaisedHandToggleButton.tsx b/src/button/RaisedHandToggleButton.tsx new file mode 100644 index 00000000..277817de --- /dev/null +++ b/src/button/RaisedHandToggleButton.tsx @@ -0,0 +1,133 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { Button as CpdButton, Tooltip } from "@vector-im/compound-web"; +import { + ComponentPropsWithoutRef, + FC, + ReactNode, + useCallback, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { logger } from "matrix-js-sdk/src/logger"; +import { EventType, RelationType } from "matrix-js-sdk/src/matrix"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; + +import { useReactions } from "../useReactions"; +import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; + +interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> { + raised: boolean; +} + +const InnerButton: FC = ({ raised, ...props }) => { + const { t } = useTranslation(); + + return ( + + +

+ ✋ +

+
+
+ ); +}; + +interface RaisedHandToggleButtonProps { + rtcSession: MatrixRTCSession; + client: MatrixClient; +} + +export function RaiseHandToggleButton({ + client, + rtcSession, +}: RaisedHandToggleButtonProps): ReactNode { + const { raisedHands, myReactionId } = useReactions(); + const [busy, setBusy] = useState(false); + const userId = client.getUserId()!; + const isHandRaised = !!raisedHands[userId]; + const memberships = useMatrixRTCSessionMemberships(rtcSession); + + const toggleRaisedHand = useCallback(() => { + const raiseHand = async (): Promise => { + if (isHandRaised) { + if (!myReactionId) { + logger.warn(`Hand raised but no reaction event to redact!`); + return; + } + try { + setBusy(true); + await client.redactEvent(rtcSession.room.roomId, myReactionId); + logger.debug("Redacted raise hand event"); + } catch (ex) { + logger.error("Failed to redact reaction event", myReactionId, ex); + } finally { + setBusy(false); + } + } else { + const myMembership = memberships.find((m) => m.sender === userId); + if (!myMembership?.eventId) { + logger.error("Cannot find own membership event"); + return; + } + const parentEventId = myMembership.eventId; + try { + setBusy(true); + const reaction = await client.sendEvent( + rtcSession.room.roomId, + EventType.Reaction, + { + "m.relates_to": { + rel_type: RelationType.Annotation, + event_id: parentEventId, + key: "🖐️", + }, + }, + ); + logger.debug("Sent raise hand event", reaction.event_id); + } catch (ex) { + logger.error("Failed to send reaction event", ex); + } finally { + setBusy(false); + } + } + }; + + void raiseHand(); + }, [ + client, + isHandRaised, + memberships, + myReactionId, + rtcSession.room.roomId, + userId, + ]); + + return ( + + ); +} 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/reactions/RaisedHandIndicator.module.css b/src/reactions/RaisedHandIndicator.module.css new file mode 100644 index 00000000..4c274374 --- /dev/null +++ b/src/reactions/RaisedHandIndicator.module.css @@ -0,0 +1,52 @@ +.raisedHandWidget { + display: flex; + background-color: var(--cpd-color-bg-subtle-primary); + border-radius: var(--cpd-radius-pill-effect); + color: var(--cpd-color-icon-secondary); +} + +.raisedHandWidget > p { + padding: none; + margin-top: auto; + margin-bottom: auto; + width: 4em; +} + +.raisedHandWidgetLarge > p { + padding: var(--cpd-space-2x); +} + +.raisedHandLarge { + margin: var(--cpd-space-2x); + padding: var(--cpd-space-2x); + padding-block: var(--cpd-space-2x); +} + +.raisedHand { + margin: var(--cpd-space-1x); + color: var(--cpd-color-icon-secondary); + background-color: var(--cpd-color-icon-secondary); + 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%; + max-width: fit-content; +} + +.raisedHand > span { + width: var(--cpd-space-6x); + height: var(--cpd-space-6x); + display: inline-block; + text-align: center; + font-size: 16px; +} + +.raisedHandLarge > span { + width: var(--cpd-space-8x); + height: var(--cpd-space-8x); + font-size: 22px; +} diff --git a/src/reactions/RaisedHandIndicator.tsx b/src/reactions/RaisedHandIndicator.tsx index 0cc1e2c7..3934ac81 100644 --- a/src/reactions/RaisedHandIndicator.tsx +++ b/src/reactions/RaisedHandIndicator.tsx @@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details. import { ReactNode, useEffect, useState } from "react"; import "@formatjs/intl-durationformat/polyfill"; import { DurationFormat } from "@formatjs/intl-durationformat"; - import { ReactionIndicator } from "./ReactionIndicator"; const durationFormatter = new DurationFormat(undefined, { diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index e486e721..7a113d3d 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -42,6 +42,7 @@ import { ShareScreenButton, SettingsButton, ReactionToggleButton, + SwitchCameraButton, } from "../button"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { useUrlParams } from "../UrlParams"; @@ -84,6 +85,7 @@ import { ReactionsProvider, useReactions } from "../useReactions"; import handSoundOgg from "../sound/raise_hand.ogg?url"; import handSoundMp3 from "../sound/raise_hand.mp3?url"; import { ReactionsAudioRenderer } from "./ReactionAudioRenderer"; +import { useSwitchCamera } from "./useSwitchCamera"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -119,7 +121,7 @@ export const ActiveCall: FC = (props) => { const vm = new CallViewModel( props.rtcSession.room, livekitRoom, - props.e2eeSystem.kind !== E2eeType.NONE, + props.e2eeSystem, connStateObservable, ); setVm(vm); @@ -128,7 +130,7 @@ export const ActiveCall: FC = (props) => { }, [ props.rtcSession.room, livekitRoom, - props.e2eeSystem.kind, + props.e2eeSystem, connStateObservable, ]); @@ -242,6 +244,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 @@ -285,10 +288,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(); @@ -526,14 +536,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 (