From 72197c1a0aa5a48db00d82575dfb7dff3e0ee584 Mon Sep 17 00:00:00 2001 From: Robert Long Date: Thu, 7 Apr 2022 14:22:36 -0700 Subject: [PATCH] Remove dependency on matrix-react-sdk --- .storybook/main.js | 5 - Dockerfile | 6 +- README.md | 14 +- package.json | 4 +- scripts/dockerbuild.sh | 13 +- src/icons/MicMuted.svg | 5 + src/icons/VideoMuted.svg | 6 + src/main.jsx | 4 +- src/room/FeedbackModal.jsx | 5 +- src/room/GroupCallView.jsx | 4 +- src/room/InCallView.jsx | 13 +- src/room/LobbyView.jsx | 4 +- src/room/RageshakeRequestModal.jsx | 2 +- src/room/RoomPage.jsx | 5 +- src/room/useGroupCall.js | 252 ++++++ src/room/usePageUnload.js | 54 ++ src/settings/SettingsModal.jsx | 2 +- src/settings/rageshake.js | 793 ++++++++++++------- src/settings/submit-rageshake.js | 300 ++++++++ src/video-grid/VideoGrid.jsx | 1017 +++++++++++++++++++++++++ src/video-grid/VideoGrid.module.css | 7 + src/video-grid/VideoGrid.stories.jsx | 7 +- src/video-grid/VideoTile.jsx | 54 ++ src/video-grid/VideoTile.module.css | 113 +++ src/video-grid/VideoTileContainer.jsx | 49 ++ src/video-grid/useCallFeed.js | 56 ++ src/video-grid/useMediaStream.js | 48 ++ src/video-grid/useRoomMemberName.js | 24 + vite.config.js | 8 - yarn.lock | 943 ++--------------------- 30 files changed, 2608 insertions(+), 1209 deletions(-) create mode 100644 src/icons/MicMuted.svg create mode 100644 src/icons/VideoMuted.svg create mode 100644 src/room/useGroupCall.js create mode 100644 src/room/usePageUnload.js create mode 100644 src/settings/submit-rageshake.js create mode 100644 src/video-grid/VideoGrid.jsx create mode 100644 src/video-grid/VideoGrid.module.css create mode 100644 src/video-grid/VideoTile.jsx create mode 100644 src/video-grid/VideoTile.module.css create mode 100644 src/video-grid/VideoTileContainer.jsx create mode 100644 src/video-grid/useCallFeed.js create mode 100644 src/video-grid/useMediaStream.js create mode 100644 src/video-grid/useRoomMemberName.js diff --git a/.storybook/main.js b/.storybook/main.js index af43aa682..682489d9e 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -18,11 +18,6 @@ module.exports = { ); config.plugins.push(svgrPlugin()); config.resolve = config.resolve || {}; - config.resolve.alias = config.resolve.alias || {}; - config.resolve.alias["$(res)"] = path.resolve( - __dirname, - "../node_modules/matrix-react-sdk/res" - ); config.resolve.dedupe = config.resolve.dedupe || []; config.resolve.dedupe.push("react", "react-dom", "matrix-js-sdk"); return config; diff --git a/Dockerfile b/Dockerfile index 88551511e..6125b9704 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,13 +2,13 @@ FROM node:16-buster as builder WORKDIR /src -COPY . /src/matrix-video-chat -RUN matrix-video-chat/scripts/dockerbuild.sh +COPY . /src/element-call +RUN element-call/scripts/dockerbuild.sh # App FROM nginxinc/nginx-unprivileged:alpine -COPY --from=builder /src/matrix-video-chat/dist /app +COPY --from=builder /src/element-call/dist /app COPY scripts/default.conf /etc/nginx/conf.d/ USER root diff --git a/README.md b/README.md index d522d0f3f..75c9a5e65 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Discussion in [#webrtc:matrix.org: ![#webrtc:matrix.org](https://img.shields.io/ ## Getting Started -`element-call` is built against the `robertlong/group-call` branch of both [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk/pull/1902) and [matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk/pull/6848). Because of how these packages are configured and Vite's requirements, you will need to clone them locally and use `yarn link` to stich things together. +`element-call` is built against the `robertlong/group-call` branch of [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk/pull/1902). Because of how this package is configured and Vite's requirements, you will need to clone it locally and use `yarn link` to stich things together. First clone, install, and link `matrix-js-sdk` @@ -18,17 +18,6 @@ yarn yarn link ``` -Then clone, install, link `matrix-js-sdk` into `matrix-react-sdk`, and link `matrix-react-sdk` - -``` -git clone https://github.com/matrix-org/matrix-react-sdk.git -cd matrix-react-sdk -git checkout robertlong/group-call -yarn -yarn link matrix-js-sdk -yarn link -``` - Next you'll also need [Synapse](https://matrix-org.github.io/synapse/latest/setup/installation.html) installed locally and running on port 8008. Finally we can set up this project. @@ -38,7 +27,6 @@ git clone https://github.com/vector-im/element-call.git cd element-call yarn yarn link matrix-js-sdk -yarn link matrix-react-sdk yarn dev ``` diff --git a/package.json b/package.json index b10127574..9471442e8 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@react-aria/tabs": "^3.1.0", "@react-aria/tooltip": "^3.1.3", "@react-aria/utils": "^3.10.0", + "@react-spring/web": "^9.4.4", "@react-stately/collections": "^3.3.4", "@react-stately/overlays": "^3.1.3", "@react-stately/select": "^3.1.3", @@ -25,11 +26,12 @@ "@react-stately/tree": "^3.2.0", "@sentry/react": "^6.13.3", "@sentry/tracing": "^6.13.3", + "@use-gesture/react": "^10.2.11", "classnames": "^2.3.1", "color-hash": "^2.0.1", "events": "^3.3.0", + "lodash-move": "^1.1.1", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#robertlong/group-call", - "matrix-react-sdk": "github:matrix-org/matrix-react-sdk#robertlong/group-call", "mermaid": "^8.13.8", "normalize.css": "^8.0.1", "pako": "^2.0.4", diff --git a/scripts/dockerbuild.sh b/scripts/dockerbuild.sh index 2a10d4ebb..491fc02a2 100755 --- a/scripts/dockerbuild.sh +++ b/scripts/dockerbuild.sh @@ -13,22 +13,11 @@ git checkout robertlong/group-call yarn install yarn run build yarn link -cd .. -git clone https://github.com/matrix-org/matrix-react-sdk.git -cd matrix-react-sdk -git checkout robertlong/group-call -yarn link matrix-js-sdk -yarn install -yarn run build -yarn link -cd .. - -cd matrix-video-chat +cd ../element-call export VITE_APP_VERSION=$(git describe --tags --abbrev=0) yarn link matrix-js-sdk -yarn link matrix-react-sdk yarn install yarn run build diff --git a/src/icons/MicMuted.svg b/src/icons/MicMuted.svg new file mode 100644 index 000000000..0cb7ad1c9 --- /dev/null +++ b/src/icons/MicMuted.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/icons/VideoMuted.svg b/src/icons/VideoMuted.svg new file mode 100644 index 000000000..188ed08bd --- /dev/null +++ b/src/icons/VideoMuted.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main.jsx b/src/main.jsx index 21138b34b..d620ff91d 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -22,10 +22,10 @@ import App from "./App"; import * as Sentry from "@sentry/react"; import { Integrations } from "@sentry/tracing"; import { ErrorView } from "./FullScreenView"; -import * as rageshake from "matrix-react-sdk/src/rageshake/rageshake"; +import { init as initRageshake } from "./settings/rageshake"; import { InspectorContextProvider } from "./room/GroupCallInspector"; -rageshake.init(); +initRageshake(); console.info(`matrix-video-chat ${import.meta.env.VITE_APP_VERSION || "dev"}`); diff --git a/src/room/FeedbackModal.jsx b/src/room/FeedbackModal.jsx index 34b524eef..84338d64e 100644 --- a/src/room/FeedbackModal.jsx +++ b/src/room/FeedbackModal.jsx @@ -2,7 +2,10 @@ import React, { useCallback, useEffect } from "react"; import { Modal, ModalContent } from "../Modal"; import { Button } from "../button"; import { FieldRow, InputField, ErrorMessage } from "../input/Input"; -import { useSubmitRageshake, useRageshakeRequest } from "../settings/rageshake"; +import { + useSubmitRageshake, + useRageshakeRequest, +} from "../settings/submit-rageshake"; import { Body } from "../typography/Typography"; import { randomString } from "matrix-js-sdk/src/randomstring"; diff --git a/src/room/GroupCallView.jsx b/src/room/GroupCallView.jsx index a7c5f5075..908613fb7 100644 --- a/src/room/GroupCallView.jsx +++ b/src/room/GroupCallView.jsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useState } from "react"; import { useHistory } from "react-router-dom"; import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall"; -import { useGroupCall } from "matrix-react-sdk/src/hooks/useGroupCall"; +import { useGroupCall } from "./useGroupCall"; import { ErrorView, FullScreenView } from "../FullScreenView"; import { LobbyView } from "./LobbyView"; import { InCallView } from "./InCallView"; @@ -14,7 +14,6 @@ export function GroupCallView({ isPasswordlessUser, roomId, groupCall, - simpleGrid, }) { const [showInspector, setShowInspector] = useState( () => !!localStorage.getItem("matrix-group-call-inspector") @@ -89,7 +88,6 @@ export function GroupCallView({ isScreensharing={isScreensharing} localScreenshareFeed={localScreenshareFeed} screenshareFeeds={screenshareFeeds} - simpleGrid={simpleGrid} setShowInspector={onChangeShowInspector} showInspector={showInspector} roomId={roomId} diff --git a/src/room/InCallView.jsx b/src/room/InCallView.jsx index 563c11f80..ea194f069 100644 --- a/src/room/InCallView.jsx +++ b/src/room/InCallView.jsx @@ -7,19 +7,15 @@ import { ScreenshareButton, } from "../button"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; -import VideoGrid, { - useVideoGridLayout, -} from "matrix-react-sdk/src/components/views/voip/GroupCallView/VideoGrid"; -import { VideoTileContainer } from "matrix-react-sdk/src/components/views/voip/GroupCallView/VideoTileContainer"; -import SimpleVideoGrid from "matrix-react-sdk/src/components/views/voip/GroupCallView/SimpleVideoGrid"; -import "matrix-react-sdk/res/css/views/voip/GroupCallView/_VideoGrid.scss"; +import { VideoGrid, useVideoGridLayout } from "../video-grid/VideoGrid"; +import { VideoTileContainer } from "../video-grid/VideoTileContainer"; import { getAvatarUrl } from "../matrix-utils"; import { GroupCallInspector } from "./GroupCallInspector"; import { OverflowMenu } from "./OverflowMenu"; import { GridLayoutMenu } from "./GridLayoutMenu"; import { Avatar } from "../Avatar"; import { UserMenuContainer } from "../UserMenuContainer"; -import { useRageshakeRequestModal } from "../settings/rageshake"; +import { useRageshakeRequestModal } from "../settings/submit-rageshake"; import { RageshakeRequestModal } from "./RageshakeRequestModal"; import { usePreventScroll } from "@react-aria/overlays"; import { useMediaHandler } from "../settings/useMediaHandler"; @@ -44,7 +40,6 @@ export function InCallView({ toggleScreensharing, isScreensharing, screenshareFeeds, - simpleGrid, setShowInspector, showInspector, roomId, @@ -149,8 +144,6 @@ export function InCallView({

Waiting for other participants...

- ) : simpleGrid ? ( - ) : ( { + const [viaServers] = useMemo(() => { const params = new URLSearchParams(search); - return [params.has("simple"), params.getAll("via")]; + return [params.getAll("via")]; }, [search]); const roomId = (maybeRoomId || hash || "").toLowerCase(); @@ -56,7 +56,6 @@ export function RoomPage() { roomId={roomId} groupCall={groupCall} isPasswordlessUser={isPasswordlessUser} - simpleGrid={simpleGrid} /> )} diff --git a/src/room/useGroupCall.js b/src/room/useGroupCall.js new file mode 100644 index 000000000..d5c535c20 --- /dev/null +++ b/src/room/useGroupCall.js @@ -0,0 +1,252 @@ +import { useCallback, useEffect, useState } from "react"; +import { + GroupCallEvent, + GroupCallState, +} from "matrix-js-sdk/src/webrtc/groupCall"; +import { usePageUnload } from "./usePageUnload"; + +export function useGroupCall(groupCall) { + const [ + { + state, + calls, + localCallFeed, + activeSpeaker, + userMediaFeeds, + error, + microphoneMuted, + localVideoMuted, + isScreensharing, + screenshareFeeds, + localScreenshareFeed, + localDesktopCapturerSourceId, + participants, + hasLocalParticipant, + requestingScreenshare, + }, + setState, + ] = useState({ + state: GroupCallState.LocalCallFeedUninitialized, + calls: [], + userMediaFeeds: [], + microphoneMuted: false, + localVideoMuted: false, + screenshareFeeds: [], + isScreensharing: false, + requestingScreenshare: false, + participants: [], + hasLocalParticipant: false, + }); + + const updateState = (state) => + setState((prevState) => ({ ...prevState, ...state })); + + useEffect(() => { + function onGroupCallStateChanged() { + updateState({ + state: groupCall.state, + calls: [...groupCall.calls], + localCallFeed: groupCall.localCallFeed, + activeSpeaker: groupCall.activeSpeaker, + userMediaFeeds: [...groupCall.userMediaFeeds], + microphoneMuted: groupCall.isMicrophoneMuted(), + localVideoMuted: groupCall.isLocalVideoMuted(), + isScreensharing: groupCall.isScreensharing(), + localScreenshareFeed: groupCall.localScreenshareFeed, + localDesktopCapturerSourceId: groupCall.localDesktopCapturerSourceId, + screenshareFeeds: [...groupCall.screenshareFeeds], + participants: [...groupCall.participants], + }); + } + + function onUserMediaFeedsChanged(userMediaFeeds) { + updateState({ + userMediaFeeds: [...userMediaFeeds], + }); + } + + function onScreenshareFeedsChanged(screenshareFeeds) { + updateState({ + screenshareFeeds: [...screenshareFeeds], + }); + } + + function onActiveSpeakerChanged(activeSpeaker) { + updateState({ + activeSpeaker: activeSpeaker, + }); + } + + function onLocalMuteStateChanged(microphoneMuted, localVideoMuted) { + updateState({ + microphoneMuted, + localVideoMuted, + }); + } + + function onLocalScreenshareStateChanged( + isScreensharing, + localScreenshareFeed, + localDesktopCapturerSourceId + ) { + updateState({ + isScreensharing, + localScreenshareFeed, + localDesktopCapturerSourceId, + }); + } + + function onCallsChanged(calls) { + updateState({ + calls: [...calls], + }); + } + + function onParticipantsChanged(participants) { + updateState({ + participants: [...participants], + hasLocalParticipant: groupCall.hasLocalParticipant(), + }); + } + + groupCall.on(GroupCallEvent.GroupCallStateChanged, onGroupCallStateChanged); + groupCall.on(GroupCallEvent.UserMediaFeedsChanged, onUserMediaFeedsChanged); + groupCall.on( + GroupCallEvent.ScreenshareFeedsChanged, + onScreenshareFeedsChanged + ); + groupCall.on(GroupCallEvent.ActiveSpeakerChanged, onActiveSpeakerChanged); + groupCall.on(GroupCallEvent.LocalMuteStateChanged, onLocalMuteStateChanged); + groupCall.on( + GroupCallEvent.LocalScreenshareStateChanged, + onLocalScreenshareStateChanged + ); + groupCall.on(GroupCallEvent.CallsChanged, onCallsChanged); + groupCall.on(GroupCallEvent.ParticipantsChanged, onParticipantsChanged); + + updateState({ + error: null, + state: groupCall.state, + calls: [...groupCall.calls], + localCallFeed: groupCall.localCallFeed, + activeSpeaker: groupCall.activeSpeaker, + userMediaFeeds: [...groupCall.userMediaFeeds], + microphoneMuted: groupCall.isMicrophoneMuted(), + localVideoMuted: groupCall.isLocalVideoMuted(), + isScreensharing: groupCall.isScreensharing(), + localScreenshareFeed: groupCall.localScreenshareFeed, + localDesktopCapturerSourceId: groupCall.localDesktopCapturerSourceId, + screenshareFeeds: [...groupCall.screenshareFeeds], + participants: [...groupCall.participants], + hasLocalParticipant: groupCall.hasLocalParticipant(), + }); + + return () => { + groupCall.removeListener( + GroupCallEvent.GroupCallStateChanged, + onGroupCallStateChanged + ); + groupCall.removeListener( + GroupCallEvent.UserMediaFeedsChanged, + onUserMediaFeedsChanged + ); + groupCall.removeListener( + GroupCallEvent.ScreenshareFeedsChanged, + onScreenshareFeedsChanged + ); + groupCall.removeListener( + GroupCallEvent.ActiveSpeakerChanged, + onActiveSpeakerChanged + ); + groupCall.removeListener( + GroupCallEvent.LocalMuteStateChanged, + onLocalMuteStateChanged + ); + groupCall.removeListener( + GroupCallEvent.LocalScreenshareStateChanged, + onLocalScreenshareStateChanged + ); + groupCall.removeListener(GroupCallEvent.CallsChanged, onCallsChanged); + groupCall.removeListener( + GroupCallEvent.ParticipantsChanged, + onParticipantsChanged + ); + groupCall.leave(); + }; + }, [groupCall]); + + usePageUnload(() => { + groupCall.leave(); + }); + + const initLocalCallFeed = useCallback( + () => groupCall.initLocalCallFeed(), + [groupCall] + ); + + const enter = useCallback(() => { + if ( + groupCall.state !== GroupCallState.LocalCallFeedUninitialized && + groupCall.state !== GroupCallState.LocalCallFeedInitialized + ) { + return; + } + + groupCall.enter().catch((error) => { + console.error(error); + updateState({ error }); + }); + }, [groupCall]); + + const leave = useCallback(() => groupCall.leave(), [groupCall]); + + const toggleLocalVideoMuted = useCallback(() => { + groupCall.setLocalVideoMuted(!groupCall.isLocalVideoMuted()); + }, [groupCall]); + + const toggleMicrophoneMuted = useCallback(() => { + groupCall.setMicrophoneMuted(!groupCall.isMicrophoneMuted()); + }, [groupCall]); + + const toggleScreensharing = useCallback(() => { + updateState({ requestingScreenshare: true }); + + groupCall.setScreensharingEnabled(!groupCall.isScreensharing()).then(() => { + updateState({ requestingScreenshare: false }); + }); + }, [groupCall]); + + useEffect(() => { + if (window.RTCPeerConnection === undefined) { + const error = new Error( + "WebRTC is not supported or is being blocked in this browser." + ); + console.error(error); + updateState({ error }); + } + }, []); + + return { + state, + calls, + localCallFeed, + activeSpeaker, + userMediaFeeds, + microphoneMuted, + localVideoMuted, + error, + initLocalCallFeed, + enter, + leave, + toggleLocalVideoMuted, + toggleMicrophoneMuted, + toggleScreensharing, + requestingScreenshare, + isScreensharing, + screenshareFeeds, + localScreenshareFeed, + localDesktopCapturerSourceId, + participants, + hasLocalParticipant, + }; +} diff --git a/src/room/usePageUnload.js b/src/room/usePageUnload.js new file mode 100644 index 000000000..2d6852015 --- /dev/null +++ b/src/room/usePageUnload.js @@ -0,0 +1,54 @@ +import { useEffect } from "react"; + +// https://stackoverflow.com/a/9039885 +function isIOS() { + return ( + [ + "iPad Simulator", + "iPhone Simulator", + "iPod Simulator", + "iPad", + "iPhone", + "iPod", + ].includes(navigator.platform) || + // iPad on iOS 13 detection + (navigator.userAgent.includes("Mac") && "ontouchend" in document) + ); +} + +export function usePageUnload(callback) { + useEffect(() => { + let pageVisibilityTimeout; + + function onBeforeUnload(event) { + if (event.type === "visibilitychange") { + if (document.visibilityState === "visible") { + clearTimeout(pageVisibilityTimeout); + } else { + // Wait 5 seconds before closing the page to avoid accidentally leaving + // TODO: Make this configurable? + pageVisibilityTimeout = setTimeout(() => { + callback(); + }, 5000); + } + } else { + callback(); + } + } + + // iOS doesn't fire beforeunload event, so leave the call when you hide the page. + if (isIOS()) { + window.addEventListener("pagehide", onBeforeUnload); + document.addEventListener("visibilitychange", onBeforeUnload); + } + + window.addEventListener("beforeunload", onBeforeUnload); + + return () => { + window.removeEventListener("pagehide", onBeforeUnload); + document.removeEventListener("visibilitychange", onBeforeUnload); + window.removeEventListener("beforeunload", onBeforeUnload); + clearTimeout(pageVisibilityTimeout); + }; + }, [callback]); +} diff --git a/src/settings/SettingsModal.jsx b/src/settings/SettingsModal.jsx index 2945daca4..fc57c624d 100644 --- a/src/settings/SettingsModal.jsx +++ b/src/settings/SettingsModal.jsx @@ -10,7 +10,7 @@ import { Item } from "@react-stately/collections"; import { useMediaHandler } from "./useMediaHandler"; import { FieldRow, InputField } from "../input/Input"; import { Button } from "../button"; -import { useDownloadDebugLog } from "./rageshake"; +import { useDownloadDebugLog } from "./submit-rageshake"; import { Body } from "../typography/Typography"; export function SettingsModal({ setShowInspector, showInspector, ...rest }) { diff --git a/src/settings/rageshake.js b/src/settings/rageshake.js index 80ee53996..c9e855eeb 100644 --- a/src/settings/rageshake.js +++ b/src/settings/rageshake.js @@ -1,300 +1,535 @@ -import { useCallback, useContext, useEffect, useState } from "react"; -import * as rageshake from "matrix-react-sdk/src/rageshake/rageshake"; -import pako from "pako"; -import { useClient } from "../ClientContext"; -import { InspectorContext } from "../room/GroupCallInspector"; -import { useModalTriggerState } from "../Modal"; - -export function useSubmitRageshake() { - const { client } = useClient(); - const [{ json }] = useContext(InspectorContext); - - const [{ sending, sent, error }, setState] = useState({ - sending: false, - sent: false, - error: null, - }); - - const submitRageshake = useCallback( - async (opts) => { - if (sending) { - return; - } - - try { - setState({ sending: true, sent: false, error: null }); - - let userAgent = "UNKNOWN"; - if (window.navigator && window.navigator.userAgent) { - userAgent = window.navigator.userAgent; - } - - let touchInput = "UNKNOWN"; +/* +Copyright 2017 OpenMarket Ltd +Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// This module contains all the code needed to log the console, persist it to +// disk and submit bug reports. Rationale is as follows: +// - Monkey-patching the console is preferable to having a log library because +// we can catch logs by other libraries more easily, without having to all +// depend on the same log framework / pass the logger around. +// - We use IndexedDB to persists logs because it has generous disk space +// limits compared to local storage. IndexedDB does not work in incognito +// mode, in which case this module will not be able to write logs to disk. +// However, the logs will still be stored in-memory, so can still be +// submitted in a bug report should the user wish to: we can also store more +// logs in-memory than in local storage, which does work in incognito mode. +// We also need to handle the case where there are 2+ tabs. Each JS runtime +// generates a random string which serves as the "ID" for that tab/session. +// These IDs are stored along with the log lines. +// - Bug reports are sent as a POST over HTTPS: it purposefully does not use +// Matrix as bug reports may be made when Matrix is not responsive (which may +// be the cause of the bug). We send the most recent N MB of UTF-8 log data, +// starting with the most recent, which we know because the "ID"s are +// actually timestamps. We then purge the remaining logs. We also do this +// purge on startup to prevent logs from accumulating. + +// the frequency with which we flush to indexeddb +import { logger } from "matrix-js-sdk/src/logger"; + +const FLUSH_RATE_MS = 30 * 1000; + +// the length of log data we keep in indexeddb (and include in the reports) +const MAX_LOG_SIZE = 1024 * 1024 * 5; // 5 MB + +// A class which monkey-patches the global console and stores log lines. +export class ConsoleLogger { + logs = ""; + + monkeyPatch(consoleObj) { + // Monkey-patch console logging + const consoleFunctionsToLevels = { + log: "I", + info: "I", + warn: "W", + error: "E", + }; + Object.keys(consoleFunctionsToLevels).forEach((fnName) => { + const level = consoleFunctionsToLevels[fnName]; + const originalFn = consoleObj[fnName].bind(consoleObj); + consoleObj[fnName] = (...args) => { + this.log(level, ...args); + originalFn(...args); + }; + }); + } + + log(level, ...args) { + // We don't know what locale the user may be running so use ISO strings + const ts = new Date().toISOString(); + + // Convert objects and errors to helpful things + args = args.map((arg) => { + if (arg instanceof DOMException) { + return arg.message + ` (${arg.name} | ${arg.code})`; + } else if (arg instanceof Error) { + return arg.message + (arg.stack ? `\n${arg.stack}` : ""); + } else if (typeof arg === "object") { try { - // MDN claims broad support across browsers - touchInput = String(window.matchMedia("(pointer: coarse)").matches); - } catch (e) {} - - const body = new FormData(); - body.append( - "text", - opts.description || "User did not supply any additional text." - ); - body.append("app", "matrix-video-chat"); - body.append("version", import.meta.env.VITE_APP_VERSION || "dev"); - body.append("user_agent", userAgent); - body.append("installed_pwa", false); - body.append("touch_input", touchInput); - - if (client) { - const userId = client.getUserId(); - const user = client.getUser(userId); - body.append("display_name", user?.displayName); - body.append("user_id", client.credentials.userId); - body.append("device_id", client.deviceId); - - if (opts.roomId) { - body.append("room_id", opts.roomId); - } - - if (client.isCryptoEnabled()) { - const keys = [`ed25519:${client.getDeviceEd25519Key()}`]; - if (client.getDeviceCurve25519Key) { - keys.push(`curve25519:${client.getDeviceCurve25519Key()}`); - } - body.append("device_keys", keys.join(", ")); - body.append("cross_signing_key", client.getCrossSigningId()); - - // add cross-signing status information - const crossSigning = client.crypto.crossSigningInfo; - const secretStorage = client.crypto.secretStorage; - - body.append( - "cross_signing_ready", - String(await client.isCrossSigningReady()) - ); - body.append( - "cross_signing_supported_by_hs", - String( - await client.doesServerSupportUnstableFeature( - "org.matrix.e2e_cross_signing" - ) - ) - ); - body.append("cross_signing_key", crossSigning.getId()); - body.append( - "cross_signing_privkey_in_secret_storage", - String( - !!(await crossSigning.isStoredInSecretStorage(secretStorage)) - ) - ); - - const pkCache = client.getCrossSigningCacheCallbacks(); - body.append( - "cross_signing_master_privkey_cached", - String( - !!(pkCache && (await pkCache.getCrossSigningKeyCache("master"))) - ) - ); - body.append( - "cross_signing_self_signing_privkey_cached", - String( - !!( - pkCache && - (await pkCache.getCrossSigningKeyCache("self_signing")) - ) - ) - ); - body.append( - "cross_signing_user_signing_privkey_cached", - String( - !!( - pkCache && - (await pkCache.getCrossSigningKeyCache("user_signing")) - ) - ) - ); - - body.append( - "secret_storage_ready", - String(await client.isSecretStorageReady()) - ); - body.append( - "secret_storage_key_in_account", - String(!!(await secretStorage.hasKey())) - ); - - body.append( - "session_backup_key_in_secret_storage", - String(!!(await client.isKeyBackupKeyStored())) - ); - const sessionBackupKeyFromCache = - await client.crypto.getSessionBackupPrivateKey(); - body.append( - "session_backup_key_cached", - String(!!sessionBackupKeyFromCache) - ); - body.append( - "session_backup_key_well_formed", - String(sessionBackupKeyFromCache instanceof Uint8Array) - ); - } - } - - if (opts.label) { - body.append("label", opts.label); - } - - // add storage persistence/quota information - if (navigator.storage && navigator.storage.persisted) { - try { - body.append( - "storageManager_persisted", - String(await navigator.storage.persisted()) - ); - } catch (e) {} - } else if (document.hasStorageAccess) { - // Safari - try { - body.append( - "storageManager_persisted", - String(await document.hasStorageAccess()) - ); - } catch (e) {} - } - - if (navigator.storage && navigator.storage.estimate) { - try { - const estimate = await navigator.storage.estimate(); - body.append("storageManager_quota", String(estimate.quota)); - body.append("storageManager_usage", String(estimate.usage)); - if (estimate.usageDetails) { - Object.keys(estimate.usageDetails).forEach((k) => { - body.append( - `storageManager_usage_${k}`, - String(estimate.usageDetails[k]) - ); - }); + return JSON.stringify(arg); + } catch (e) { + // In development, it can be useful to log complex cyclic + // objects to the console for inspection. This is fine for + // the console, but default `stringify` can't handle that. + // We workaround this by using a special replacer function + // to only log values of the root object and avoid cycles. + return JSON.stringify(arg, (key, value) => { + if (key && typeof value === "object") { + return ""; } - } catch (e) {} + return value; + }); } + } else { + return arg; + } + }); + + // Some browsers support string formatting which we're not doing here + // so the lines are a little more ugly but easy to implement / quick to + // run. + // Example line: + // 2017-01-18T11:23:53.214Z W Failed to set badge count + let line = `${ts} ${level} ${args.join(" ")}\n`; + // Do some cleanup + line = line.replace(/token=[a-zA-Z0-9-]+/gm, "token=xxxxx"); + // Using + really is the quickest way in JS + // http://jsperf.com/concat-vs-plus-vs-join + this.logs += line; + } + + /** + * Retrieve log lines to flush to disk. + * @param {boolean} keepLogs True to not delete logs after flushing. + * @return {string} \n delimited log lines to flush. + */ + flush(keepLogs) { + // The ConsoleLogger doesn't care how these end up on disk, it just + // flushes them to the caller. + if (keepLogs) { + return this.logs; + } + const logsToFlush = this.logs; + this.logs = ""; + return logsToFlush; + } +} - if (opts.sendLogs) { - const logs = await rageshake.getLogsForReport(); - - for (const entry of logs) { - // encode as UTF-8 - let buf = new TextEncoder().encode(entry.lines); - - // compress - buf = pako.gzip(buf); +// A class which stores log lines in an IndexedDB instance. +export class IndexedDBLogStore { + index = 0; + db = null; + flushPromise = null; + flushAgainPromise = null; + + constructor(indexedDB, logger) { + this.indexedDB = indexedDB; + this.logger = logger; + this.id = "instance-" + Math.random() + Date.now(); + } + + /** + * @return {Promise} Resolves when the store is ready. + */ + connect() { + const req = this.indexedDB.open("logs"); + return new Promise((resolve, reject) => { + req.onsuccess = (event) => { + // @ts-ignore + this.db = event.target.result; + // Periodically flush logs to local storage / indexeddb + setInterval(this.flush.bind(this), FLUSH_RATE_MS); + resolve(); + }; + + req.onerror = (event) => { + const err = + // @ts-ignore + "Failed to open log database: " + event.target.error.name; + logger.error(err); + reject(new Error(err)); + }; + + // First time: Setup the object store + req.onupgradeneeded = (event) => { + // @ts-ignore + const db = event.target.result; + const logObjStore = db.createObjectStore("logs", { + keyPath: ["id", "index"], + }); + // Keys in the database look like: [ "instance-148938490", 0 ] + // Later on we need to query everything based on an instance id. + // In order to do this, we need to set up indexes "id". + logObjStore.createIndex("id", "id", { unique: false }); + + logObjStore.add( + this.generateLogEntry(new Date() + " ::: Log database was created.") + ); - body.append("compressed-log", new Blob([buf]), entry.id); + const lastModifiedStore = db.createObjectStore("logslastmod", { + keyPath: "id", + }); + lastModifiedStore.add(this.generateLastModifiedTime()); + }; + }); + } + + /** + * Flush logs to disk. + * + * There are guards to protect against race conditions in order to ensure + * that all previous flushes have completed before the most recent flush. + * Consider without guards: + * - A calls flush() periodically. + * - B calls flush() and wants to send logs immediately afterwards. + * - If B doesn't wait for A's flush to complete, B will be missing the + * contents of A's flush. + * To protect against this, we set 'flushPromise' when a flush is ongoing. + * Subsequent calls to flush() during this period will chain another flush, + * then keep returning that same chained flush. + * + * This guarantees that we will always eventually do a flush when flush() is + * called. + * + * @return {Promise} Resolved when the logs have been flushed. + */ + flush() { + // check if a flush() operation is ongoing + if (this.flushPromise) { + if (this.flushAgainPromise) { + // this is the 3rd+ time we've called flush() : return the same promise. + return this.flushAgainPromise; + } + // queue up a flush to occur immediately after the pending one completes. + this.flushAgainPromise = this.flushPromise + .then(() => { + return this.flush(); + }) + .then(() => { + this.flushAgainPromise = null; + }); + return this.flushAgainPromise; + } + // there is no flush promise or there was but it has finished, so do + // a brand new one, destroying the chain which may have been built up. + this.flushPromise = new Promise((resolve, reject) => { + if (!this.db) { + // not connected yet or user rejected access for us to r/w to the db. + reject(new Error("No connected database")); + return; + } + const lines = this.logger.flush(); + if (lines.length === 0) { + resolve(); + return; + } + const txn = this.db.transaction(["logs", "logslastmod"], "readwrite"); + const objStore = txn.objectStore("logs"); + txn.oncomplete = (event) => { + resolve(); + }; + txn.onerror = (event) => { + logger.error("Failed to flush logs : ", event); + reject(new Error("Failed to write logs: " + event.target.errorCode)); + }; + objStore.add(this.generateLogEntry(lines)); + const lastModStore = txn.objectStore("logslastmod"); + lastModStore.put(this.generateLastModifiedTime()); + }).then(() => { + this.flushPromise = null; + }); + return this.flushPromise; + } + + /** + * Consume the most recent logs and return them. Older logs which are not + * returned are deleted at the same time, so this can be called at startup + * to do house-keeping to keep the logs from growing too large. + * + * @return {Promise} Resolves to an array of objects. The array is + * sorted in time (oldest first) based on when the log file was created (the + * log ID). The objects have said log ID in an "id" field and "lines" which + * is a big string with all the new-line delimited logs. + */ + async consume() { + const db = this.db; + + // Returns: a string representing the concatenated logs for this ID. + // Stops adding log fragments when the size exceeds maxSize + function fetchLogs(id, maxSize) { + const objectStore = db + .transaction("logs", "readonly") + .objectStore("logs"); + + return new Promise((resolve, reject) => { + const query = objectStore + .index("id") + .openCursor(IDBKeyRange.only(id), "prev"); + let lines = ""; + query.onerror = (event) => { + reject(new Error("Query failed: " + event.target.errorCode)); + }; + query.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) { + resolve(lines); + return; // end of results } - - if (json) { - body.append( - "file", - new Blob([JSON.stringify(json)], { type: "text/plain" }), - "groupcall.txt" - ); + lines = cursor.value.lines + lines; + if (lines.length >= maxSize) { + resolve(lines); + } else { + cursor.continue(); } - } - - if (opts.rageshakeRequestId) { - body.append( - "group_call_rageshake_request_id", - opts.rageshakeRequestId + }; + }); + } + + // Returns: A sorted array of log IDs. (newest first) + function fetchLogIds() { + // To gather all the log IDs, query for all records in logslastmod. + const o = db + .transaction("logslastmod", "readonly") + .objectStore("logslastmod"); + return selectQuery(o, undefined, (cursor) => { + return { + id: cursor.value.id, + ts: cursor.value.ts, + }; + }).then((res) => { + // Sort IDs by timestamp (newest first) + return res + .sort((a, b) => { + return b.ts - a.ts; + }) + .map((a) => a.id); + }); + } + + function deleteLogs(id) { + return new Promise((resolve, reject) => { + const txn = db.transaction(["logs", "logslastmod"], "readwrite"); + const o = txn.objectStore("logs"); + // only load the key path, not the data which may be huge + const query = o.index("id").openKeyCursor(IDBKeyRange.only(id)); + query.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) { + return; + } + o.delete(cursor.primaryKey); + cursor.continue(); + }; + txn.oncomplete = () => { + resolve(); + }; + txn.onerror = (event) => { + reject( + new Error( + "Failed to delete logs for " + + `'${id}' : ${event.target.errorCode}` + ) ); + }; + // delete last modified entries + const lastModStore = txn.objectStore("logslastmod"); + lastModStore.delete(id); + }); + } + + const allLogIds = await fetchLogIds(); + let removeLogIds = []; + const logs = []; + let size = 0; + for (let i = 0; i < allLogIds.length; i++) { + const lines = await fetchLogs(allLogIds[i], MAX_LOG_SIZE - size); + + // always add the log file: fetchLogs will truncate once the maxSize we give it is + // exceeded, so we'll go over the max but only by one fragment's worth. + logs.push({ + lines: lines, + id: allLogIds[i], + }); + size += lines.length; + + // If fetchLogs truncated we'll now be at or over the size limit, + // in which case we should stop and remove the rest of the log files. + if (size >= MAX_LOG_SIZE) { + // the remaining log IDs should be removed. If we go out of + // bounds this is just [] + removeLogIds = allLogIds.slice(i + 1); + break; + } + } + if (removeLogIds.length > 0) { + logger.log("Removing logs: ", removeLogIds); + // Don't await this because it's non-fatal if we can't clean up + // logs. + Promise.all(removeLogIds.map((id) => deleteLogs(id))).then( + () => { + logger.log(`Removed ${removeLogIds.length} old logs.`); + }, + (err) => { + logger.error(err); } + ); + } + return logs; + } + + generateLogEntry(lines) { + return { + id: this.id, + lines: lines, + index: this.index++, + }; + } - await fetch( - import.meta.env.VITE_RAGESHAKE_SUBMIT_URL || - "https://element.io/bugreports/submit", - { - method: "POST", - body, - } - ); + generateLastModifiedTime() { + return { + id: this.id, + ts: Date.now(), + }; + } +} - setState({ sending: false, sent: true, error: null }); - } catch (error) { - setState({ sending: false, sent: false, error }); - console.error(error); +/** + * Helper method to collect results from a Cursor and promiseify it. + * @param {ObjectStore|Index} store The store to perform openCursor on. + * @param {IDBKeyRange=} keyRange Optional key range to apply on the cursor. + * @param {Function} resultMapper A function which is repeatedly called with a + * Cursor. + * Return the data you want to keep. + * @return {Promise} Resolves to an array of whatever you returned from + * resultMapper. + */ +function selectQuery(store, keyRange, resultMapper) { + const query = store.openCursor(keyRange); + return new Promise((resolve, reject) => { + const results = []; + query.onerror = (event) => { + // @ts-ignore + reject(new Error("Query failed: " + event.target.errorCode)); + }; + // collect results + query.onsuccess = (event) => { + // @ts-ignore + const cursor = event.target.result; + if (!cursor) { + resolve(results); + return; // end of results } - }, - [client] - ); - - return { - submitRageshake, - sending, - sent, - error, - }; + results.push(resultMapper(cursor)); + cursor.continue(); + }; + }); } -export function useDownloadDebugLog() { - const [{ json }] = useContext(InspectorContext); - - const downloadDebugLog = useCallback(() => { - const blob = new Blob([JSON.stringify(json)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const el = document.createElement("a"); - el.href = url; - el.download = "groupcall.json"; - el.style.display = "none"; - document.body.appendChild(el); - el.click(); - setTimeout(() => { - URL.revokeObjectURL(url); - el.parentNode.removeChild(el); - }, 0); - }, [json]); - - return downloadDebugLog; +/** + * Configure rage shaking support for sending bug reports. + * Modifies globals. + * @param {boolean} setUpPersistence When true (default), the persistence will + * be set up immediately for the logs. + * @return {Promise} Resolves when set up. + */ +export function init(setUpPersistence = true) { + if (global.mx_rage_initPromise) { + return global.mx_rage_initPromise; + } + global.mx_rage_logger = new ConsoleLogger(); + global.mx_rage_logger.monkeyPatch(window.console); + + if (setUpPersistence) { + return tryInitStorage(); + } + + global.mx_rage_initPromise = Promise.resolve(); + return global.mx_rage_initPromise; } -export function useRageshakeRequest() { - const { client } = useClient(); - - const sendRageshakeRequest = useCallback( - (roomId, rageshakeRequestId) => { - client.sendEvent(roomId, "org.matrix.rageshake_request", { - request_id: rageshakeRequestId, - }); - }, - [client] - ); - - return sendRageshakeRequest; +/** + * Try to start up the rageshake storage for logs. If not possible (client unsupported) + * then this no-ops. + * @return {Promise} Resolves when complete. + */ +export function tryInitStorage() { + if (global.mx_rage_initStoragePromise) { + return global.mx_rage_initStoragePromise; + } + + logger.log("Configuring rageshake persistence..."); + + // just *accessing* indexedDB throws an exception in firefox with + // indexeddb disabled. + let indexedDB; + try { + indexedDB = window.indexedDB; + } catch (e) {} + + if (indexedDB) { + global.mx_rage_store = new IndexedDBLogStore( + indexedDB, + global.mx_rage_logger + ); + global.mx_rage_initStoragePromise = global.mx_rage_store.connect(); + return global.mx_rage_initStoragePromise; + } + global.mx_rage_initStoragePromise = Promise.resolve(); + return global.mx_rage_initStoragePromise; } -export function useRageshakeRequestModal(roomId) { - const { modalState, modalProps } = useModalTriggerState(); - const { client } = useClient(); - const [rageshakeRequestId, setRageshakeRequestId] = useState(); - - useEffect(() => { - const onEvent = (event) => { - const type = event.getType(); - - if ( - type === "org.matrix.rageshake_request" && - roomId === event.getRoomId() && - client.getUserId() !== event.getSender() - ) { - setRageshakeRequestId(event.getContent().request_id); - modalState.open(); - } - }; - - client.on("event", onEvent); +export function flush() { + if (!global.mx_rage_store) { + return; + } + global.mx_rage_store.flush(); +} - return () => { - client.removeListener("event", onEvent); - }; - }, [modalState.open, roomId]); +/** + * Clean up old logs. + * @return {Promise} Resolves if cleaned logs. + */ +export async function cleanup() { + if (!global.mx_rage_store) { + return; + } + await global.mx_rage_store.consume(); +} - return { modalState, modalProps: { ...modalProps, rageshakeRequestId } }; +/** + * Get a recent snapshot of the logs, ready for attaching to a bug report + * + * @return {Array<{lines: string, id, string}>} list of log data + */ +export async function getLogsForReport() { + if (!global.mx_rage_logger) { + throw new Error("No console logger, did you forget to call init()?"); + } + // If in incognito mode, store is null, but we still want bug report + // sending to work going off the in-memory console logs. + if (global.mx_rage_store) { + // flush most recent logs + await global.mx_rage_store.flush(); + return await global.mx_rage_store.consume(); + } else { + return [ + { + lines: global.mx_rage_logger.flush(true), + id: "-", + }, + ]; + } } diff --git a/src/settings/submit-rageshake.js b/src/settings/submit-rageshake.js new file mode 100644 index 000000000..ed1a22ee2 --- /dev/null +++ b/src/settings/submit-rageshake.js @@ -0,0 +1,300 @@ +import { useCallback, useContext, useEffect, useState } from "react"; +import { getLogsForReport } from "./rageshake"; +import pako from "pako"; +import { useClient } from "../ClientContext"; +import { InspectorContext } from "../room/GroupCallInspector"; +import { useModalTriggerState } from "../Modal"; + +export function useSubmitRageshake() { + const { client } = useClient(); + const [{ json }] = useContext(InspectorContext); + + const [{ sending, sent, error }, setState] = useState({ + sending: false, + sent: false, + error: null, + }); + + const submitRageshake = useCallback( + async (opts) => { + if (sending) { + return; + } + + try { + setState({ sending: true, sent: false, error: null }); + + let userAgent = "UNKNOWN"; + if (window.navigator && window.navigator.userAgent) { + userAgent = window.navigator.userAgent; + } + + let touchInput = "UNKNOWN"; + try { + // MDN claims broad support across browsers + touchInput = String(window.matchMedia("(pointer: coarse)").matches); + } catch (e) {} + + const body = new FormData(); + body.append( + "text", + opts.description || "User did not supply any additional text." + ); + body.append("app", "matrix-video-chat"); + body.append("version", import.meta.env.VITE_APP_VERSION || "dev"); + body.append("user_agent", userAgent); + body.append("installed_pwa", false); + body.append("touch_input", touchInput); + + if (client) { + const userId = client.getUserId(); + const user = client.getUser(userId); + body.append("display_name", user?.displayName); + body.append("user_id", client.credentials.userId); + body.append("device_id", client.deviceId); + + if (opts.roomId) { + body.append("room_id", opts.roomId); + } + + if (client.isCryptoEnabled()) { + const keys = [`ed25519:${client.getDeviceEd25519Key()}`]; + if (client.getDeviceCurve25519Key) { + keys.push(`curve25519:${client.getDeviceCurve25519Key()}`); + } + body.append("device_keys", keys.join(", ")); + body.append("cross_signing_key", client.getCrossSigningId()); + + // add cross-signing status information + const crossSigning = client.crypto.crossSigningInfo; + const secretStorage = client.crypto.secretStorage; + + body.append( + "cross_signing_ready", + String(await client.isCrossSigningReady()) + ); + body.append( + "cross_signing_supported_by_hs", + String( + await client.doesServerSupportUnstableFeature( + "org.matrix.e2e_cross_signing" + ) + ) + ); + body.append("cross_signing_key", crossSigning.getId()); + body.append( + "cross_signing_privkey_in_secret_storage", + String( + !!(await crossSigning.isStoredInSecretStorage(secretStorage)) + ) + ); + + const pkCache = client.getCrossSigningCacheCallbacks(); + body.append( + "cross_signing_master_privkey_cached", + String( + !!(pkCache && (await pkCache.getCrossSigningKeyCache("master"))) + ) + ); + body.append( + "cross_signing_self_signing_privkey_cached", + String( + !!( + pkCache && + (await pkCache.getCrossSigningKeyCache("self_signing")) + ) + ) + ); + body.append( + "cross_signing_user_signing_privkey_cached", + String( + !!( + pkCache && + (await pkCache.getCrossSigningKeyCache("user_signing")) + ) + ) + ); + + body.append( + "secret_storage_ready", + String(await client.isSecretStorageReady()) + ); + body.append( + "secret_storage_key_in_account", + String(!!(await secretStorage.hasKey())) + ); + + body.append( + "session_backup_key_in_secret_storage", + String(!!(await client.isKeyBackupKeyStored())) + ); + const sessionBackupKeyFromCache = + await client.crypto.getSessionBackupPrivateKey(); + body.append( + "session_backup_key_cached", + String(!!sessionBackupKeyFromCache) + ); + body.append( + "session_backup_key_well_formed", + String(sessionBackupKeyFromCache instanceof Uint8Array) + ); + } + } + + if (opts.label) { + body.append("label", opts.label); + } + + // add storage persistence/quota information + if (navigator.storage && navigator.storage.persisted) { + try { + body.append( + "storageManager_persisted", + String(await navigator.storage.persisted()) + ); + } catch (e) {} + } else if (document.hasStorageAccess) { + // Safari + try { + body.append( + "storageManager_persisted", + String(await document.hasStorageAccess()) + ); + } catch (e) {} + } + + if (navigator.storage && navigator.storage.estimate) { + try { + const estimate = await navigator.storage.estimate(); + body.append("storageManager_quota", String(estimate.quota)); + body.append("storageManager_usage", String(estimate.usage)); + if (estimate.usageDetails) { + Object.keys(estimate.usageDetails).forEach((k) => { + body.append( + `storageManager_usage_${k}`, + String(estimate.usageDetails[k]) + ); + }); + } + } catch (e) {} + } + + if (opts.sendLogs) { + const logs = await getLogsForReport(); + + for (const entry of logs) { + // encode as UTF-8 + let buf = new TextEncoder().encode(entry.lines); + + // compress + buf = pako.gzip(buf); + + body.append("compressed-log", new Blob([buf]), entry.id); + } + + if (json) { + body.append( + "file", + new Blob([JSON.stringify(json)], { type: "text/plain" }), + "groupcall.txt" + ); + } + } + + if (opts.rageshakeRequestId) { + body.append( + "group_call_rageshake_request_id", + opts.rageshakeRequestId + ); + } + + await fetch( + import.meta.env.VITE_RAGESHAKE_SUBMIT_URL || + "https://element.io/bugreports/submit", + { + method: "POST", + body, + } + ); + + setState({ sending: false, sent: true, error: null }); + } catch (error) { + setState({ sending: false, sent: false, error }); + console.error(error); + } + }, + [client] + ); + + return { + submitRageshake, + sending, + sent, + error, + }; +} + +export function useDownloadDebugLog() { + const [{ json }] = useContext(InspectorContext); + + const downloadDebugLog = useCallback(() => { + const blob = new Blob([JSON.stringify(json)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const el = document.createElement("a"); + el.href = url; + el.download = "groupcall.json"; + el.style.display = "none"; + document.body.appendChild(el); + el.click(); + setTimeout(() => { + URL.revokeObjectURL(url); + el.parentNode.removeChild(el); + }, 0); + }, [json]); + + return downloadDebugLog; +} + +export function useRageshakeRequest() { + const { client } = useClient(); + + const sendRageshakeRequest = useCallback( + (roomId, rageshakeRequestId) => { + client.sendEvent(roomId, "org.matrix.rageshake_request", { + request_id: rageshakeRequestId, + }); + }, + [client] + ); + + return sendRageshakeRequest; +} + +export function useRageshakeRequestModal(roomId) { + const { modalState, modalProps } = useModalTriggerState(); + const { client } = useClient(); + const [rageshakeRequestId, setRageshakeRequestId] = useState(); + + useEffect(() => { + const onEvent = (event) => { + const type = event.getType(); + + if ( + type === "org.matrix.rageshake_request" && + roomId === event.getRoomId() && + client.getUserId() !== event.getSender() + ) { + setRageshakeRequestId(event.getContent().request_id); + modalState.open(); + } + }; + + client.on("event", onEvent); + + return () => { + client.removeListener("event", onEvent); + }; + }, [modalState.open, roomId]); + + return { modalState, modalProps: { ...modalProps, rageshakeRequestId } }; +} diff --git a/src/video-grid/VideoGrid.jsx b/src/video-grid/VideoGrid.jsx new file mode 100644 index 000000000..c6df8180d --- /dev/null +++ b/src/video-grid/VideoGrid.jsx @@ -0,0 +1,1017 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useDrag, useGesture } from "@use-gesture/react"; +import { useSprings } from "@react-spring/web"; +import useMeasure from "react-use-measure"; +import { ResizeObserver } from "@juggle/resize-observer"; +import moveArrItem from "lodash-move"; +import styles from "./VideoGrid.module.css"; + +export function useVideoGridLayout(hasScreenshareFeeds) { + const layoutRef = useRef("freedom"); + const revertLayoutRef = useRef("freedom"); + const prevHasScreenshareFeeds = useRef(hasScreenshareFeeds); + const [, forceUpdate] = useState({}); + + const setLayout = useCallback((layout) => { + // Store the user's set layout to revert to after a screenshare is finished + revertLayoutRef.current = layout; + layoutRef.current = layout; + forceUpdate({}); + }, []); + + // Note: We need the returned layout to update synchronously with a change in hasScreenshareFeeds + // so use refs and avoid useEffect. + if (prevHasScreenshareFeeds.current !== hasScreenshareFeeds) { + if (hasScreenshareFeeds) { + // Automatically switch to spotlight layout when there's a screenshare + layoutRef.current = "spotlight"; + } else { + // When the screenshares have ended, revert to the previous layout + layoutRef.current = revertLayoutRef.current; + } + } + + prevHasScreenshareFeeds.current = hasScreenshareFeeds; + + return [layoutRef.current, setLayout]; +} + +function useIsMounted() { + const isMountedRef = useRef(false); + + useEffect(() => { + isMountedRef.current = true; + + return () => { + isMountedRef.current = false; + }; + }, []); + + return isMountedRef; +} + +function isInside([x, y], targetTile) { + const left = targetTile.x; + const top = targetTile.y; + const bottom = targetTile.y + targetTile.height; + const right = targetTile.x + targetTile.width; + + if (x < left || x > right || y < top || y > bottom) { + return false; + } + + return true; +} + +function getTilePositions( + tileCount, + presenterTileCount, + gridWidth, + gridHeight, + layout +) { + if (layout === "freedom") { + if (tileCount === 2 && presenterTileCount === 0) { + return getOneOnOneLayoutTilePositions(gridWidth, gridHeight); + } + + return getFreedomLayoutTilePositions( + tileCount, + presenterTileCount, + gridWidth, + gridHeight + ); + } else { + return getSpotlightLayoutTilePositions(tileCount, gridWidth, gridHeight); + } +} + +function getOneOnOneLayoutTilePositions(gridWidth, gridHeight) { + const gap = 8; + const gridAspectRatio = gridWidth / gridHeight; + + const pipWidth = gridAspectRatio < 1 ? 114 : 230; + const pipHeight = gridAspectRatio < 1 ? 163 : 155; + const pipGap = gridAspectRatio < 1 ? 12 : 24; + + return [ + { + x: gridWidth - pipWidth - gap - pipGap, + y: gridHeight - pipHeight - gap - pipGap, + width: pipWidth, + height: pipHeight, + zIndex: 1, + }, + { + x: gap, + y: gap, + width: gridWidth - gap * 2, + height: gridHeight - gap * 2, + zIndex: 0, + }, + ]; +} + +function getSpotlightLayoutTilePositions(tileCount, gridWidth, gridHeight) { + const gap = 8; + const tilePositions = []; + + const gridAspectRatio = gridWidth / gridHeight; + + if (gridAspectRatio < 1) { + // Vertical layout (mobile) + const spotlightTileHeight = + tileCount > 1 ? (gridHeight - gap * 3) * (4 / 5) : gridHeight - gap * 2; + const spectatorTileSize = + tileCount > 1 ? gridHeight - gap * 3 - spotlightTileHeight : 0; + + for (let i = 0; i < tileCount; i++) { + if (i === 0) { + // Spotlight tile + tilePositions.push({ + x: gap, + y: gap, + width: gridWidth - gap * 2, + height: spotlightTileHeight, + zIndex: 0, + }); + } else { + // Spectator tile + tilePositions.push({ + x: (gap + spectatorTileSize) * (i - 1) + gap, + y: spotlightTileHeight + gap * 2, + width: spectatorTileSize, + height: spectatorTileSize, + zIndex: 0, + }); + } + } + } else { + // Horizontal layout (desktop) + const spotlightTileWidth = + tileCount > 1 ? ((gridWidth - gap * 3) * 4) / 5 : gridWidth - gap * 2; + const spectatorTileWidth = + tileCount > 1 ? gridWidth - gap * 3 - spotlightTileWidth : 0; + const spectatorTileHeight = spectatorTileWidth * (9 / 16); + + for (let i = 0; i < tileCount; i++) { + if (i === 0) { + tilePositions.push({ + x: gap, + y: gap, + width: spotlightTileWidth, + height: gridHeight - gap * 2, + zIndex: 0, + }); + } else { + tilePositions.push({ + x: gap * 2 + spotlightTileWidth, + y: (gap + spectatorTileHeight) * (i - 1) + gap, + width: spectatorTileWidth, + height: spectatorTileHeight, + zIndex: 0, + }); + } + } + } + + return tilePositions; +} + +function getFreedomLayoutTilePositions( + tileCount, + presenterTileCount, + gridWidth, + gridHeight +) { + if (tileCount === 0) { + return []; + } + + if (tileCount > 12) { + console.warn("Over 12 tiles is not currently supported"); + } + + const gap = 8; + + const { layoutDirection, itemGridRatio } = getGridLayout( + tileCount, + presenterTileCount, + gridWidth, + gridHeight + ); + + let itemGridWidth; + let itemGridHeight; + + if (layoutDirection === "vertical") { + itemGridWidth = gridWidth; + itemGridHeight = Math.round(gridHeight * itemGridRatio); + } else { + itemGridWidth = Math.round(gridWidth * itemGridRatio); + itemGridHeight = gridHeight; + } + + const itemTileCount = tileCount - presenterTileCount; + + const { + columnCount: itemColumnCount, + rowCount: itemRowCount, + tileAspectRatio: itemTileAspectRatio, + } = getSubGridLayout(itemTileCount, itemGridWidth, itemGridHeight); + + const itemGridPositions = getSubGridPositions( + itemTileCount, + itemColumnCount, + itemRowCount, + itemTileAspectRatio, + itemGridWidth, + itemGridHeight, + gap + ); + const itemGridBounds = getSubGridBoundingBox(itemGridPositions); + + let presenterGridWidth; + let presenterGridHeight; + + if (presenterTileCount === 0) { + presenterGridWidth = 0; + presenterGridHeight = 0; + } else if (layoutDirection === "vertical") { + presenterGridWidth = gridWidth; + presenterGridHeight = + gridHeight - (itemGridBounds.height + (itemTileCount ? gap * 2 : 0)); + } else { + presenterGridWidth = + gridWidth - (itemGridBounds.width + (itemTileCount ? gap * 2 : 0)); + presenterGridHeight = gridHeight; + } + + const { + columnCount: presenterColumnCount, + rowCount: presenterRowCount, + tileAspectRatio: presenterTileAspectRatio, + } = getSubGridLayout( + presenterTileCount, + presenterGridWidth, + presenterGridHeight + ); + + const presenterGridPositions = getSubGridPositions( + presenterTileCount, + presenterColumnCount, + presenterRowCount, + presenterTileAspectRatio, + presenterGridWidth, + presenterGridHeight, + gap + ); + + const tilePositions = [...presenterGridPositions, ...itemGridPositions]; + + centerTiles( + presenterGridPositions, + presenterGridWidth, + presenterGridHeight, + 0, + 0 + ); + + if (layoutDirection === "vertical") { + centerTiles( + itemGridPositions, + gridWidth, + gridHeight - presenterGridHeight, + 0, + presenterGridHeight + ); + } else { + centerTiles( + itemGridPositions, + gridWidth - presenterGridWidth, + gridHeight, + presenterGridWidth, + 0 + ); + } + + return tilePositions; +} + +function getSubGridBoundingBox(positions) { + let left = 0; + let right = 0; + let top = 0; + let bottom = 0; + + for (let i = 0; i < positions.length; i++) { + const { x, y, width, height } = positions[i]; + + if (i === 0) { + left = x; + right = x + width; + top = y; + bottom = y + height; + } else { + if (x < left) { + left = x; + } + + if (y < top) { + top = y; + } + + if (x + width > right) { + right = x + width; + } + + if (y + height > bottom) { + bottom = y + height; + } + } + } + + return { + left, + right, + top, + bottom, + width: right - left, + height: bottom - top, + }; +} + +function isMobileBreakpoint(gridWidth, gridHeight) { + const gridAspectRatio = gridWidth / gridHeight; + return gridAspectRatio < 1; +} + +function getGridLayout(tileCount, presenterTileCount, gridWidth, gridHeight) { + let layoutDirection = "horizontal"; + let itemGridRatio = 1; + + if (presenterTileCount === 0) { + return { itemGridRatio, layoutDirection }; + } + + if (isMobileBreakpoint(gridWidth, gridHeight)) { + layoutDirection = "vertical"; + itemGridRatio = 1 / 3; + } else { + layoutDirection = "horizontal"; + itemGridRatio = 1 / 3; + } + + return { itemGridRatio, layoutDirection }; +} + +function centerTiles(positions, gridWidth, gridHeight, offsetLeft, offsetTop) { + const bounds = getSubGridBoundingBox(positions); + + const leftOffset = Math.round((gridWidth - bounds.width) / 2) + offsetLeft; + const topOffset = Math.round((gridHeight - bounds.height) / 2) + offsetTop; + + applyTileOffsets(positions, leftOffset, topOffset); + + return positions; +} + +function applyTileOffsets(positions, leftOffset, topOffset) { + for (const position of positions) { + position.x += leftOffset; + position.y += topOffset; + } + + return positions; +} + +function getSubGridLayout(tileCount, gridWidth, gridHeight) { + const gridAspectRatio = gridWidth / gridHeight; + + let columnCount; + let rowCount; + let tileAspectRatio = 16 / 9; + + if (gridAspectRatio < 3 / 4) { + // Phone + if (tileCount === 1) { + columnCount = 1; + rowCount = 1; + tileAspectRatio = 0; + } else if (tileCount <= 4) { + columnCount = 1; + rowCount = tileCount; + } else if (tileCount <= 12) { + columnCount = 2; + rowCount = Math.ceil(tileCount / columnCount); + tileAspectRatio = 0; + } else { + // Unsupported + columnCount = 3; + rowCount = Math.ceil(tileCount / columnCount); + tileAspectRatio = 1; + } + } else if (gridAspectRatio < 1) { + // Tablet + if (tileCount === 1) { + columnCount = 1; + rowCount = 1; + tileAspectRatio = 0; + } else if (tileCount <= 4) { + columnCount = 1; + rowCount = tileCount; + } else if (tileCount <= 12) { + columnCount = 2; + rowCount = Math.ceil(tileCount / columnCount); + } else { + // Unsupported + columnCount = 3; + rowCount = Math.ceil(tileCount / columnCount); + tileAspectRatio = 1; + } + } else if (gridAspectRatio < 17 / 9) { + // Computer + if (tileCount === 1) { + columnCount = 1; + rowCount = 1; + } else if (tileCount === 2) { + columnCount = 2; + rowCount = 1; + } else if (tileCount <= 4) { + columnCount = 2; + rowCount = 2; + } else if (tileCount <= 6) { + columnCount = 3; + rowCount = 2; + } else if (tileCount <= 8) { + columnCount = 4; + rowCount = 2; + tileAspectRatio = 1; + } else if (tileCount <= 12) { + columnCount = 4; + rowCount = 3; + tileAspectRatio = 1; + } else { + // Unsupported + columnCount = 4; + rowCount = 4; + } + } else if (gridAspectRatio <= 32 / 9) { + // Ultrawide + if (tileCount === 1) { + columnCount = 1; + rowCount = 1; + } else if (tileCount === 2) { + columnCount = 2; + rowCount = 1; + } else if (tileCount <= 4) { + columnCount = 2; + rowCount = 2; + } else if (tileCount <= 6) { + columnCount = 3; + rowCount = 2; + } else if (tileCount <= 8) { + columnCount = 4; + rowCount = 2; + } else if (tileCount <= 12) { + columnCount = 4; + rowCount = 3; + } else { + // Unsupported + columnCount = 4; + rowCount = 4; + } + } else { + // Super Ultrawide + if (tileCount <= 6) { + columnCount = tileCount; + rowCount = 1; + } else { + columnCount = Math.ceil(tileCount / 2); + rowCount = 2; + } + } + + return { columnCount, rowCount, tileAspectRatio }; +} + +function getSubGridPositions( + tileCount, + columnCount, + rowCount, + tileAspectRatio, + gridWidth, + gridHeight, + gap +) { + if (tileCount === 0) { + return []; + } + + const newTilePositions = []; + + const boxWidth = Math.round( + (gridWidth - gap * (columnCount + 1)) / columnCount + ); + const boxHeight = Math.round((gridHeight - gap * (rowCount + 1)) / rowCount); + + let tileWidth; + let tileHeight; + + if (tileAspectRatio) { + const boxAspectRatio = boxWidth / boxHeight; + + if (boxAspectRatio > tileAspectRatio) { + tileWidth = boxHeight * tileAspectRatio; + tileHeight = boxHeight; + } else { + tileWidth = boxWidth; + tileHeight = boxWidth / tileAspectRatio; + } + } else { + tileWidth = boxWidth; + tileHeight = boxHeight; + } + + for (let i = 0; i < tileCount; i++) { + const verticalIndex = Math.floor(i / columnCount); + const top = verticalIndex * gap + verticalIndex * tileHeight; + + let rowItemCount; + + if (verticalIndex + 1 === rowCount && tileCount % columnCount !== 0) { + rowItemCount = tileCount % columnCount; + } else { + rowItemCount = columnCount; + } + + const horizontalIndex = i % columnCount; + + let centeringPadding = 0; + + if (rowItemCount < columnCount) { + const subgridWidth = tileWidth * columnCount + (gap * columnCount - 1); + centeringPadding = Math.round( + (subgridWidth - (tileWidth * rowItemCount + (gap * rowItemCount - 1))) / + 2 + ); + } + + const left = + centeringPadding + gap * horizontalIndex + tileWidth * horizontalIndex; + + newTilePositions.push({ + width: tileWidth, + height: tileHeight, + x: left, + y: top, + zIndex: 0, + }); + } + + return newTilePositions; +} + +function sortTiles(layout, tiles) { + const is1on1Freedom = layout === "freedom" && tiles.length === 2; + + tiles.sort((a, b) => { + if (is1on1Freedom && a.item.isLocal !== b.item.isLocal) { + return (b.item.isLocal ? 1 : 0) - (a.item.isLocal ? 1 : 0); + } else if (a.focused !== b.focused) { + return (b.focused ? 1 : 0) - (a.focused ? 1 : 0); + } else if (a.presenter !== b.presenter) { + return (b.presenter ? 1 : 0) - (a.presenter ? 1 : 0); + } + + return 0; + }); +} + +export function VideoGrid({ + items, + layout, + onFocusTile, + disableAnimations, + children, +}) { + const [{ tiles, tilePositions, scrollPosition }, setTileState] = useState({ + tiles: [], + tilePositions: [], + scrollPosition: 0, + }); + const draggingTileRef = useRef(null); + const lastTappedRef = useRef({}); + const lastLayoutRef = useRef(layout); + const isMounted = useIsMounted(); + + const [gridRef, gridBounds] = useMeasure({ polyfill: ResizeObserver }); + + useEffect(() => { + setTileState(({ tiles, ...rest }) => { + const newTiles = []; + const removedTileKeys = []; + + for (const tile of tiles) { + let item = items.find((item) => item.id === tile.key); + + let remove = false; + + if (!item) { + remove = true; + item = tile.item; + removedTileKeys.push(tile.key); + } + + let focused; + let presenter = false; + + if (layout === "spotlight") { + focused = item.focused; + presenter = item.presenter; + } else { + focused = layout === lastLayoutRef.current ? tile.focused : false; + } + + newTiles.push({ + key: item.id, + item, + remove, + focused, + presenter, + }); + } + + for (const item of items) { + const existingTileIndex = newTiles.findIndex( + ({ key }) => item.id === key + ); + + const existingTile = newTiles[existingTileIndex]; + + if (existingTile && !existingTile.remove) { + continue; + } + + const newTile = { + key: item.id, + item, + remove: false, + focused: layout === "spotlight" && item.focused, + presenter: layout === "spotlight" && item.presenter, + }; + + if (existingTile) { + // Replace an existing tile + newTiles.splice(existingTileIndex, 1, newTile); + } else { + // Added tiles + newTiles.push(newTile); + } + } + + sortTiles(layout, newTiles); + + if (removedTileKeys.length > 0) { + setTimeout(() => { + if (!isMounted.current) { + return; + } + + setTileState(({ tiles, ...rest }) => { + const newTiles = tiles.filter( + (tile) => !removedTileKeys.includes(tile.key) + ); + + // TODO: When we remove tiles, we reuse the order of the tiles vs calling sort on the + // items array. This can cause the local feed to display large in the room. + // To fix this we need to move to using a reducer and sorting the input items + + const presenterTileCount = newTiles.reduce( + (count, tile) => count + (tile.focused ? 1 : 0), + 0 + ); + + return { + ...rest, + tiles: newTiles, + tilePositions: getTilePositions( + newTiles.length, + presenterTileCount, + gridBounds.width, + gridBounds.height, + layout + ), + }; + }); + }, 250); + } + + const presenterTileCount = newTiles.reduce( + (count, tile) => count + (tile.focused ? 1 : 0), + 0 + ); + + lastLayoutRef.current = layout; + + return { + ...rest, + tiles: newTiles, + tilePositions: getTilePositions( + newTiles.length, + presenterTileCount, + gridBounds.width, + gridBounds.height, + layout + ), + }; + }); + }, [items, gridBounds, layout, isMounted]); + + const animate = useCallback( + (tiles) => (tileIndex) => { + const tile = tiles[tileIndex]; + const tilePosition = tilePositions[tileIndex]; + const draggingTile = draggingTileRef.current; + const dragging = draggingTile && tile.key === draggingTile.key; + const remove = tile.remove; + + if (dragging) { + return { + width: tilePosition.width, + height: tilePosition.height, + x: draggingTile.offsetX + draggingTile.x, + y: draggingTile.offsetY + draggingTile.y, + scale: 1.1, + opacity: 1, + zIndex: 2, + shadow: 15, + immediate: (key) => + disableAnimations || + key === "zIndex" || + key === "x" || + key === "y" || + key === "shadow", + from: { + shadow: 0, + scale: 0, + opacity: 0, + }, + reset: false, + }; + } else { + const isMobile = isMobileBreakpoint( + gridBounds.width, + gridBounds.height + ); + + return { + x: + tilePosition.x + + (layout === "spotlight" && tileIndex !== 0 && isMobile + ? scrollPosition + : 0), + y: + tilePosition.y + + (layout === "spotlight" && tileIndex !== 0 && !isMobile + ? scrollPosition + : 0), + width: tilePosition.width, + height: tilePosition.height, + scale: remove ? 0 : 1, + opacity: remove ? 0 : 1, + zIndex: tilePosition.zIndex, + shadow: 1, + from: { + shadow: 1, + scale: 0, + opacity: 0, + }, + reset: false, + immediate: (key) => + disableAnimations || key === "zIndex" || key === "shadow", + }; + } + }, + [tilePositions, disableAnimations, scrollPosition, layout, gridBounds] + ); + + const [springs, api] = useSprings(tiles.length, animate(tiles), [ + tilePositions, + tiles, + scrollPosition, + ]); + + const onTap = useCallback( + (tileKey) => { + const lastTapped = lastTappedRef.current[tileKey]; + + if (!lastTapped || Date.now() - lastTapped > 500) { + lastTappedRef.current[tileKey] = Date.now(); + return; + } + + lastTappedRef.current[tileKey] = 0; + + const tile = tiles.find((tile) => tile.key === tileKey); + + if (!tile) { + return; + } + + const item = tile.item; + + setTileState((state) => { + let presenterTileCount = 0; + + let newTiles; + + if (onFocusTile) { + newTiles = onFocusTile(state.tiles, tile); + + for (const tile of newTiles) { + if (tile.focused) { + presenterTileCount++; + } + } + } else { + newTiles = state.tiles.map((tile) => { + let newTile = tile; + + if (tile.item === item) { + newTile = { ...tile, focused: !tile.focused }; + } + + if (newTile.focused) { + presenterTileCount++; + } + + return newTile; + }); + } + + sortTiles(layout, newTiles); + + return { + ...state, + tiles: newTiles, + tilePositions: getTilePositions( + newTiles.length, + presenterTileCount, + gridBounds.width, + gridBounds.height, + layout + ), + }; + }); + }, + [tiles, gridBounds, onFocusTile, layout] + ); + + const bindTile = useDrag( + ({ args: [key], active, xy, movement, tap, event }) => { + event.preventDefault(); + + if (tap) { + onTap(key); + return; + } + + if (layout !== "freedom") { + return; + } + + if (layout === "freedom" && tiles.length === 2) { + return; + } + + const dragTileIndex = tiles.findIndex((tile) => tile.key === key); + const dragTile = tiles[dragTileIndex]; + const dragTilePosition = tilePositions[dragTileIndex]; + + let newTiles = tiles; + + const cursorPosition = [xy[0] - gridBounds.left, xy[1] - gridBounds.top]; + + for ( + let hoverTileIndex = 0; + hoverTileIndex < tiles.length; + hoverTileIndex++ + ) { + const hoverTile = tiles[hoverTileIndex]; + const hoverTilePosition = tilePositions[hoverTileIndex]; + + if (hoverTile.key === key) { + continue; + } + + if (isInside(cursorPosition, hoverTilePosition)) { + newTiles = moveArrItem(tiles, dragTileIndex, hoverTileIndex); + + newTiles = newTiles.map((tile) => { + if (tile === hoverTile) { + return { ...tile, focused: dragTile.focused }; + } else if (tile === dragTile) { + return { ...tile, focused: hoverTile.focused }; + } else { + return tile; + } + }); + + sortTiles(layout, newTiles); + + setTileState((state) => ({ ...state, tiles: newTiles })); + break; + } + } + + if (active) { + if (!draggingTileRef.current) { + draggingTileRef.current = { + key: dragTile.key, + offsetX: dragTilePosition.x, + offsetY: dragTilePosition.y, + x: movement[0], + y: movement[1], + }; + } else { + draggingTileRef.current.x = movement[0]; + draggingTileRef.current.y = movement[1]; + } + } else { + draggingTileRef.current = null; + } + + api.start(animate(newTiles)); + }, + { filterTaps: true, pointer: { buttons: [1] } } + ); + + const onGridGesture = useCallback( + (e, isWheel) => { + if (layout !== "spotlight") { + return; + } + + const isMobile = isMobileBreakpoint(gridBounds.width, gridBounds.height); + let movement = e.delta[isMobile ? 0 : 1]; + + if (isWheel) { + movement = -movement; + } + + let min = 0; + + if (tilePositions.length > 1) { + const lastTile = tilePositions[tilePositions.length - 1]; + min = isMobile + ? gridBounds.width - lastTile.x - lastTile.width - 8 + : gridBounds.height - lastTile.y - lastTile.height - 8; + } + + setTileState((state) => ({ + ...state, + scrollPosition: Math.min( + Math.max(movement + state.scrollPosition, min), + 0 + ), + })); + }, + [layout, gridBounds, tilePositions] + ); + + const bindGrid = useGesture( + { + onWheel: (e) => onGridGesture(e, true), + onDrag: (e) => onGridGesture(e, false), + }, + {} + ); + + return ( +
+ {springs.map(({ shadow, ...style }, i) => { + const tile = tiles[i]; + const tilePosition = tilePositions[i]; + + return children({ + ...bindTile(tile.key), + key: tile.key, + style: { + boxShadow: shadow.to( + (s) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px 0px` + ), + ...style, + }, + width: tilePosition.width, + height: tilePosition.height, + item: tile.item, + }); + })} +
+ ); +} + +VideoGrid.defaultProps = { + layout: "freedom", +}; diff --git a/src/video-grid/VideoGrid.module.css b/src/video-grid/VideoGrid.module.css new file mode 100644 index 000000000..484202462 --- /dev/null +++ b/src/video-grid/VideoGrid.module.css @@ -0,0 +1,7 @@ +.videoGrid { + position: relative; + overflow: hidden; + flex: 1; + touch-action: none; +} + diff --git a/src/video-grid/VideoGrid.stories.jsx b/src/video-grid/VideoGrid.stories.jsx index 3e97001a9..b0be97f76 100644 --- a/src/video-grid/VideoGrid.stories.jsx +++ b/src/video-grid/VideoGrid.stories.jsx @@ -1,9 +1,6 @@ import React, { useState } from "react"; -import VideoGrid, { - useVideoGridLayout, -} from "matrix-react-sdk/src/components/views/voip/GroupCallView/VideoGrid"; -import VideoTile from "matrix-react-sdk/src/components/views/voip/GroupCallView/VideoTile"; -import "matrix-react-sdk/res/css/views/voip/GroupCallView/_VideoGrid.scss"; +import { VideoGrid, useVideoGridLayout } from "./VideoGrid"; +import { VideoTile } from "./VideoTile"; import { useMemo } from "react"; import { Button } from "../button"; diff --git a/src/video-grid/VideoTile.jsx b/src/video-grid/VideoTile.jsx new file mode 100644 index 000000000..bb4d5b165 --- /dev/null +++ b/src/video-grid/VideoTile.jsx @@ -0,0 +1,54 @@ +import React from "react"; +import { animated } from "@react-spring/web"; +import classNames from "classnames"; +import styles from "./VideoTile.module.css"; +import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg"; +import { ReactComponent as VideoMutedIcon } from "../icons/VideoMuted.svg"; + +export function VideoTile({ + className, + isLocal, + speaking, + audioMuted, + noVideo, + videoMuted, + screenshare, + avatar, + name, + showName, + mediaRef, + ...rest +}) { + return ( + + {(videoMuted || noVideo) && ( + <> +
+ {avatar} + + )} + {screenshare ? ( +
+ {`${name} is presenting`} +
+ ) : ( + (showName || audioMuted || (videoMuted && !noVideo)) && ( +
+ {audioMuted && !(videoMuted && !noVideo) && } + {videoMuted && !noVideo && } + {showName && {name}} +
+ ) + )} +