Compare commits

...

46 Commits

Author SHA1 Message Date
David Baker
dbef06269b Merge pull request #310 from vector-im/ptt
Add feature-flagged support for Radio/PTT Mode
2022-05-04 11:40:26 +01:00
David Baker
894815268a Merge remote-tracking branch 'origin/main' into ptt 2022-05-04 11:37:52 +01:00
David Baker
8ecec0bc7e 3 more warnings in the PTT stuff 2022-05-04 11:35:33 +01:00
David Baker
66839e02f6 Add ESLint support too 2022-05-04 11:35:15 +01:00
David Baker
bad8f36bf5 Add prettier support
+ CI to check formatting, and fix the couple of instances that
were not in prettier format (case in HTML clour codes).
2022-05-04 11:35:15 +01:00
Robert Long
f5c50230a9 Enable source-maps 2022-05-04 11:34:28 +01:00
David Baker
0136fd3cab Run prettier 2022-05-04 11:24:25 +01:00
Robert Long
2d18953344 Merge pull request #309 from vector-im/dbkr/prettier
Add prettier & ESLint support
2022-05-03 10:36:08 -07:00
Robert Long
d930ab869a Merge pull request #308 from vector-im/dbkr/ptt_enable_flag
Put PTT behind 'feature flag'
2022-05-03 10:34:01 -07:00
Robert Long
dbdb82bd74 Switch to useShouldShowPtt hook 2022-05-03 10:32:06 -07:00
David Baker
61309bacd9 Add ESLint support too 2022-05-03 15:32:16 +01:00
David Baker
b3e88d33a7 Add prettier support
+ CI to check formatting, and fix the couple of instances that
were not in prettier format (case in HTML clour codes).
2022-05-03 14:24:04 +01:00
David Baker
73fda641c8 Switch js-sdk depdendency back to group-call branch 2022-05-03 13:19:57 +01:00
David Baker
be01a4bd81 Commit missed file 2022-05-03 12:05:40 +01:00
David Baker
0814e3c905 Revert unintentional commit 2022-05-03 12:05:22 +01:00
Robert Long
c7dd2e2093 Merge pull request #307 from vector-im/dbkr/fix_toggle
Fix toggle button toggling
2022-05-02 11:30:05 -07:00
Robert Long
cfa525f957 Merge pull request #306 from vector-im/dbkr/button_for_ptt
Wire up pressing the PTT button to unmute as well as spacebar
2022-05-02 11:28:00 -07:00
David Baker
43d579744f Put PTT behind 'feature flag'
AKA does the URL hash start with '#ptt'

This will let us merge PTT back to the main branch
2022-04-29 19:25:00 +01:00
David Baker
48a008093b Fix toggle button toggling
Just use isSelected directly rather than makking the button have its
own state. Also, the isPressed from useToggleButton looks like its
whether the user has the mouse button down on it or not rather than
whether the toggle switch is on, which was making the state wrong.
2022-04-29 19:08:32 +01:00
David Baker
70c099c4b5 Wire up pressing the PTT button to unmute as well as spacebar 2022-04-29 18:56:17 +01:00
Robert Long
363f2340a0 Finish basic ptt implemenation 2022-04-28 17:44:50 -07:00
Robert Long
3a6346aa63 Create a voice group call when using ptt 2022-04-28 11:13:20 -07:00
Robert Long
9ef9680e07 Fix PTT button alignment 2022-04-28 11:13:01 -07:00
Robert Long
e3cec93669 Add basic mobile styling 2022-04-27 17:19:58 -07:00
Robert Long
b6c926d2c8 Additional in-room PTT styling 2022-04-27 16:47:23 -07:00
Robert Long
c430ebb3a3 Finish first pass at PTT lobby UI 2022-04-27 15:18:55 -07:00
Robert Long
ae13814449 Merge pull request #305 from vector-im/enable-source-maps
Enable source-maps
2022-04-27 13:51:40 -07:00
Robert Long
dc75c1cfb4 Enable source-maps 2022-04-27 12:11:59 -07:00
Robert Long
38f9a79bd3 Initial PTT designs 2022-04-22 18:05:48 -07:00
Robert Long
fc1aaf02bf Use dbkr/ptt matrix-js-sdk package 2022-04-22 11:15:39 -07:00
Robert Long
c05b6c5118 Merge pull request #291 from vector-im/remove-matrix-react-sdk-dep
Remove dependency on matrix-react-sdk
2022-04-07 15:43:55 -07:00
Robert Long
72197c1a0a Remove dependency on matrix-react-sdk 2022-04-07 14:22:36 -07:00
Robert Long
46bcb8ac75 Merge pull request #285 from vector-im/fix-title
Fix Title
2022-03-29 11:15:42 -07:00
Robert Long
2ba1bab82d Fix title 2022-03-29 11:14:31 -07:00
Matthew Hodgson
3c56f7f481 Merge pull request #274 from vector-im/travis/idea-gitignore
Add .idea to gitignore
2022-03-20 11:03:15 +00:00
Travis Ralston
fcd8a41fc9 Add .idea to gitignore
For those of us using WebStorm
2022-03-16 13:44:38 -06:00
Matthew Hodgson
35f8b1ed85 link to #webrtc:matrix.org 2022-03-04 14:55:24 +00:00
Matthew Hodgson
7969e13fc1 copyright 2022-03-04 14:50:36 +00:00
Matthew Hodgson
4d433ab22d more renaming 2022-03-04 14:48:57 +00:00
Matthew Hodgson
d7f46607ad link 3401 2022-03-04 14:48:21 +00:00
Matthew Hodgson
1e59390599 s/matrix-video-chat/element-call/ 2022-03-04 14:47:44 +00:00
Robert Long
2457476bae Still capitalize words in snake case room ids 2022-03-03 17:09:31 -08:00
Robert Long
35fb1e710b Create room when not found and lowercase name 2022-03-03 16:56:45 -08:00
Robert Long
014b740e47 Update logo 2022-03-03 16:14:07 -08:00
Robert Long
2b3c04592b Only show remove button when there is an avatar to remove 2022-03-02 19:18:23 -08:00
Robert Long
ae50d57814 Set display name after interactive registration 2022-03-02 19:12:18 -08:00
68 changed files with 4328 additions and 1511 deletions

35
.eslintrc.js Normal file
View File

@@ -0,0 +1,35 @@
module.exports = {
plugins: [
"matrix-org",
],
extends: [
"plugin:matrix-org/react",
"plugin:matrix-org/a11y",
"prettier",
],
env: {
browser: true,
node: true,
},
parserOptions: {
"ecmaVersion": "latest",
"sourceType": "module",
},
rules: {
// We break this rule in a few places: dial it back to a warning
// (and run with max warnings) to tolerate the existing code
"react-hooks/exhaustive-deps": ["warn"],
},
overrides: [
{
files: [
"src/**/*.{ts,tsx}",
],
},
],
settings: {
react: {
version: "detect",
},
},
};

20
.github/workflows/lint.yaml vendored Normal file
View File

@@ -0,0 +1,20 @@
name: Lint and format
on:
pull_request: {}
jobs:
prettier:
name: Lint and format
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Yarn cache
uses: actions/setup-node@v3
with:
cache: 'yarn'
- name: Install dependencies
run: "yarn install"
- name: Prettier
run: "yarn run prettier:check"
- name: ESLint
run: "yarn run lint"

3
.gitignore vendored
View File

@@ -2,4 +2,5 @@ node_modules
.DS_Store
dist
dist-ssr
*.local
*.local
.idea/

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

1
.prettierrc.json Normal file
View File

@@ -0,0 +1 @@
{}

View File

@@ -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;

View File

@@ -2,4 +2,4 @@
"editor.formatOnSave": true,
"editor.insertSpaces": true,
"editor.tabSize": 2
}
}

View File

@@ -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

View File

@@ -1,10 +1,12 @@
# Matrix Video Chat
# Element Call
Testbed for full mesh video chat.
Showcase for full mesh video chat powered by Matrix, implementing [MSC3401](https://github.com/matrix-org/matrix-spec-proposals/blob/matthew/group-voip/proposals/3401-group-voip.md).
Discussion in [#webrtc:matrix.org: ![#webrtc:matrix.org](https://img.shields.io/matrix/webrtc:matrix.org)](https://matrix.to/#/#webrtc:matrix.org)
## Getting Started
`matrix-video-chat` 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`
@@ -16,30 +18,36 @@ 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.
```
git clone https://github.com/vector-im/matrix-video-chat.git
cd matrix-video-chat
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
```
## Config
Configuration options are documented in the `.env` file.
## License
All files in this project are:
Copyright 2021-2022 New Vector Ltd
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.

View File

@@ -5,7 +5,10 @@
"build": "vite build",
"serve": "vite preview",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook"
"build-storybook": "build-storybook",
"prettier:check": "prettier -c src",
"prettier:format": "prettier -w src",
"lint": "eslint --max-warnings 11 src"
},
"dependencies": {
"@juggle/resize-observer": "^3.3.1",
@@ -18,6 +21,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 +29,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",
@@ -48,6 +53,13 @@
"@babel/core": "^7.16.5",
"@storybook/react": "^6.5.0-alpha.5",
"babel-loader": "^8.2.3",
"eslint": "^8.14.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-matrix-org": "^0.4.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.5.0",
"prettier": "^2.6.2",
"sass": "^1.42.1",
"storybook-builder-vite": "^0.1.12",
"vite": "^2.4.2",

View File

@@ -5,6 +5,7 @@ set -ex
export VITE_DEFAULT_HOMESERVER=https://call.ems.host
export VITE_SENTRY_DSN=https://b1e328d49be3402ba96101338989fb35@sentry.matrix.org/41
export VITE_RAGESHAKE_SUBMIT_URL=https://element.io/bugreports/submit
export VITE_PRODUCT_NAME="Element Call"
git clone https://github.com/matrix-org/matrix-js-sdk.git
cd matrix-js-sdk
@@ -12,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

View File

@@ -4,35 +4,63 @@ import classNames from "classnames";
import { Avatar } from "./Avatar";
import { getAvatarUrl } from "./matrix-utils";
export function Facepile({ className, client, participants, ...rest }) {
const overlapMap = {
xs: 2,
sm: 4,
md: 8,
};
const sizeMap = {
xs: 24,
sm: 32,
md: 36,
};
export function Facepile({
className,
client,
participants,
max,
size,
...rest
}) {
const _size = sizeMap[size];
const _overlap = overlapMap[size];
return (
<div
className={classNames(styles.facepile, className)}
className={classNames(styles.facepile, styles[size], className)}
title={participants.map((member) => member.name).join(", ")}
style={{ width: participants.length * (_size - _overlap) + _overlap }}
{...rest}
>
{participants.slice(0, 3).map((member, i) => {
{participants.slice(0, max).map((member, i) => {
const avatarUrl = member.user?.avatarUrl;
return (
<Avatar
key={member.userId}
size="xs"
src={avatarUrl && getAvatarUrl(client, avatarUrl, 22)}
size={size}
src={avatarUrl && getAvatarUrl(client, avatarUrl, _size)}
fallback={member.name.slice(0, 1).toUpperCase()}
className={styles.avatar}
style={{ left: i * 22 }}
style={{ left: i * (_size - _overlap) }}
/>
);
})}
{participants.length > 3 && (
{participants.length > max && (
<Avatar
key="additional"
size="xs"
fallback={`+${participants.length - 3}`}
size={size}
fallback={`+${participants.length - max}`}
className={styles.avatar}
style={{ left: 3 * 22 }}
style={{ left: max * (_size - _overlap) }}
/>
)}
</div>
);
}
Facepile.defaultProps = {
max: 3,
size: "xs",
};

View File

@@ -1,11 +1,26 @@
.facepile {
width: 100%;
height: 24px;
position: relative;
}
.facepile.xs {
height: 24px;
}
.facepile.sm {
height: 32px;
}
.facepile.md {
height: 36px;
}
.facepile .avatar {
position: absolute;
top: 0;
border: 1px solid var(--bgColor2);
}
.facepile.md .avatar {
border-width: 2px;
}

View File

@@ -82,6 +82,11 @@ export function useInteractiveRegistration() {
setClient(client, session);
const user = client.getUser(client.getUserId());
user.setRawDisplayName(displayName);
user.setDisplayName(displayName);
return client;
},
[]

View File

@@ -7,6 +7,8 @@ import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import { ReactComponent as DisableVideoIcon } from "../icons/DisableVideo.svg";
import { ReactComponent as HangupIcon } from "../icons/Hangup.svg";
import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg";
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
import { useButton } from "@react-aria/button";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import { TooltipTrigger } from "../Tooltip";
@@ -20,6 +22,7 @@ export const variantToClassName = {
copy: [styles.copyButton],
iconCopy: [styles.iconCopyButton],
secondaryCopy: [styles.copyButton],
secondaryHangup: [styles.secondaryHangup],
};
export const sizeToClassName = {
@@ -126,3 +129,25 @@ export function HangupButton({ className, ...rest }) {
</TooltipTrigger>
);
}
export function SettingsButton({ className, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest}>
<SettingsIcon />
</Button>
{() => "Settings"}
</TooltipTrigger>
);
}
export function InviteButton({ className, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest}>
<AddUserIcon />
</Button>
{() => "Invite"}
</TooltipTrigger>
);
}

View File

@@ -20,6 +20,7 @@ limitations under the License.
.iconButton,
.iconCopyButton,
.secondary,
.secondaryHangup,
.copyButton {
position: relative;
display: flex;
@@ -34,6 +35,7 @@ limitations under the License.
}
.secondary,
.secondaryHangup,
.button,
.copyButton {
padding: 7px 15px;
@@ -53,6 +55,7 @@ limitations under the License.
.iconButton:focus,
.iconCopyButton:focus,
.secondary:focus,
.secondaryHangup:focus,
.copyButton:focus {
outline: auto;
}
@@ -119,6 +122,12 @@ limitations under the License.
background-color: transparent;
}
.secondaryHangup {
color: #ff5b55;
border: 2px solid #ff5b55;
background-color: transparent;
}
.copyButton.secondaryCopy {
color: var(--textColor1);
border-color: var(--textColor1);

View File

@@ -13,22 +13,25 @@ import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { useHistory } from "react-router-dom";
import { Headline, Title } from "../typography/Typography";
import { Form } from "../form/Form";
import { useShouldShowPtt } from "../useShouldShowPtt";
export function RegisteredView({ client }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const history = useHistory();
const shouldShowPtt = useShouldShowPtt();
const onSubmit = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const roomName = data.get("callName");
const ptt = data.get("ptt") !== null;
async function submit() {
setError(undefined);
setLoading(true);
const roomIdOrAlias = await createRoom(client, roomName);
const roomIdOrAlias = await createRoom(client, roomName, ptt);
if (roomIdOrAlias) {
history.push(`/room/${roomIdOrAlias}`);
@@ -87,6 +90,7 @@ export function RegisteredView({ client }) {
required
autoComplete="off"
/>
<Button
type="submit"
size="lg"
@@ -96,6 +100,16 @@ export function RegisteredView({ client }) {
{loading ? "Loading..." : "Go"}
</Button>
</FieldRow>
{shouldShowPtt && (
<FieldRow className={styles.fieldRow}>
<InputField
id="ptt"
name="ptt"
label="Push to Talk"
type="checkbox"
/>
</FieldRow>
)}
{error && (
<FieldRow className={styles.fieldRow}>
<ErrorMessage>{error.message}</ErrorMessage>

View File

@@ -7,6 +7,10 @@
}
.fieldRow {
margin-bottom: 24px;
}
.fieldRow:last-child {
margin-bottom: 0;
}

View File

@@ -15,8 +15,10 @@ import { Form } from "../form/Form";
import styles from "./UnauthenticatedView.module.css";
import commonStyles from "./common.module.css";
import { generateRandomName } from "../auth/generateRandomName";
import { useShouldShowPtt } from "../useShouldShowPtt";
export function UnauthenticatedView() {
const shouldShowPtt = useShouldShowPtt();
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const [{ privacyPolicyUrl, recaptchaKey }, register] =
@@ -28,6 +30,7 @@ export function UnauthenticatedView() {
const data = new FormData(e.target);
const roomName = data.get("callName");
const displayName = data.get("displayName");
const ptt = data.get("ptt") !== null;
async function submit() {
setError(undefined);
@@ -41,7 +44,7 @@ export function UnauthenticatedView() {
recaptchaResponse,
true
);
const roomIdOrAlias = await createRoom(client, roomName);
const roomIdOrAlias = await createRoom(client, roomName, ptt);
if (roomIdOrAlias) {
history.push(`/room/${roomIdOrAlias}`);
@@ -111,6 +114,16 @@ export function UnauthenticatedView() {
autoComplete="off"
/>
</FieldRow>
{shouldShowPtt && (
<FieldRow>
<InputField
id="ptt"
name="ptt"
label="Push to Talk"
type="checkbox"
/>
</FieldRow>
)}
<Caption>
By clicking "Go", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link>

View File

@@ -1,11 +1,17 @@
<svg width="199" height="30" viewBox="0 0 199 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="260" height="30" viewBox="0 0 260 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="15" cy="15" r="13" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15 30C23.2843 30 30 23.2843 30 15C30 6.71573 23.2843 0 15 0C6.71573 0 0 6.71573 0 15C0 23.2843 6.71573 30 15 30ZM12.2579 6.98923C12.2579 6.38376 12.7497 5.89292 13.3565 5.89292C17.4687 5.89292 20.8024 9.21967 20.8024 13.3234C20.8024 13.9289 20.3106 14.4197 19.7038 14.4197C19.0971 14.4197 18.6052 13.9289 18.6052 13.3234C18.6052 10.4306 16.2553 8.08554 13.3565 8.08554C12.7497 8.08554 12.2579 7.59471 12.2579 6.98923ZM24.1066 13.3235C24.1066 12.7181 23.6148 12.2272 23.008 12.2272C22.4013 12.2272 21.9094 12.7181 21.9094 13.3235C21.9094 16.2163 19.5595 18.5614 16.6607 18.5614C16.0539 18.5614 15.5621 19.0523 15.5621 19.6577C15.5621 20.2632 16.0539 20.754 16.6607 20.754C20.7729 20.754 24.1066 17.4273 24.1066 13.3235ZM17.7601 23.011C17.7601 23.6164 17.2682 24.1073 16.6615 24.1073C12.5492 24.1073 9.21553 20.7805 9.21553 16.6768C9.21553 16.0713 9.70739 15.5805 10.3141 15.5805C10.9209 15.5805 11.4127 16.0713 11.4127 16.6768C11.4127 19.5696 13.7627 21.9146 16.6615 21.9146C17.2682 21.9146 17.7601 22.4055 17.7601 23.011ZM5.89281 16.6769C5.89281 17.2824 6.38467 17.7732 6.9914 17.7732C7.59813 17.7732 8.08999 17.2824 8.08999 16.6769C8.08999 13.7841 10.4399 11.439 13.3388 11.439C13.9455 11.439 14.4373 10.9482 14.4373 10.3427C14.4373 9.73722 13.9455 9.24639 13.3388 9.24639C9.22647 9.24639 5.89281 12.5731 5.89281 16.6769Z" fill="#0DBD8B"/>
<path d="M53.5406 17.2581H42.8052C42.9321 18.3815 43.3398 19.2783 44.0283 19.9487C44.7168 20.601 45.6227 20.9272 46.7461 20.9272C47.4889 20.9272 48.1593 20.746 48.7573 20.3836C49.3552 20.0212 49.781 19.532 50.0346 18.916H53.296C52.8611 20.3474 52.0458 21.507 50.85 22.3948C49.6722 23.2645 48.2771 23.6993 46.6645 23.6993C44.5628 23.6993 42.8596 23.0017 41.5551 21.6066C40.2686 20.2115 39.6254 18.4449 39.6254 16.3069C39.6254 14.2232 40.2777 12.4748 41.5822 11.0615C42.8868 9.64824 44.5718 8.94161 46.6374 8.94161C48.7029 8.94161 50.3698 9.63918 51.6381 11.0343C52.9246 12.4113 53.5678 14.1507 53.5678 16.2525L53.5406 17.2581ZM46.6374 11.5779C45.6227 11.5779 44.7802 11.8768 44.1098 12.4748C43.4394 13.0727 43.0227 13.8699 42.8596 14.8664H50.3608C50.2158 13.8699 49.8172 13.0727 49.1649 12.4748C48.5127 11.8768 47.6701 11.5779 46.6374 11.5779Z" fill="white"/>
<path d="M55.7934 19.1606V2.9896H59.0276V19.2149C59.0276 19.9397 59.4262 20.3021 60.2234 20.3021L60.7942 20.2749V23.346C60.4862 23.4004 60.16 23.4275 59.8158 23.4275C58.4206 23.4275 57.3969 23.0742 56.7446 22.3676C56.1105 21.661 55.7934 20.592 55.7934 19.1606Z" fill="white"/>
<path d="M75.8564 17.2581H65.121C65.2478 18.3815 65.6555 19.2783 66.344 19.9487C67.0325 20.601 67.9385 20.9272 69.0618 20.9272C69.8047 20.9272 70.4751 20.746 71.073 20.3836C71.6709 20.0212 72.0967 19.532 72.3504 18.916H75.6118C75.1769 20.3474 74.3616 21.507 73.1657 22.3948C71.988 23.2645 70.5929 23.6993 68.9803 23.6993C66.8785 23.6993 65.1754 23.0017 63.8708 21.6066C62.5844 20.2115 61.9412 18.4449 61.9412 16.3069C61.9412 14.2232 62.5935 12.4748 63.898 11.0615C65.2026 9.64824 66.8876 8.94161 68.9531 8.94161C71.0187 8.94161 72.6856 9.63918 73.9539 11.0343C75.2403 12.4113 75.8835 14.1507 75.8835 16.2525L75.8564 17.2581ZM68.9531 11.5779C67.9385 11.5779 67.096 11.8768 66.4256 12.4748C65.7552 13.0727 65.3384 13.8699 65.1754 14.8664H72.6765C72.5316 13.8699 72.133 13.0727 71.4807 12.4748C70.8284 11.8768 69.9859 11.5779 68.9531 11.5779Z" fill="white"/>
<path d="M90.448 15.2741V23.3732H87.2138V14.9208C87.2138 12.7828 86.326 11.7138 84.5504 11.7138C83.5901 11.7138 82.82 12.0218 82.2402 12.6378C81.6786 13.2539 81.3977 14.0964 81.3977 15.1654V23.3732H78.1635V9.26775H81.1531V11.143C81.4974 10.5089 82.0228 9.98344 82.7295 9.56671C83.4361 9.14998 84.3148 8.94161 85.3657 8.94161C87.3226 8.94161 88.7358 9.68448 89.6055 11.1702C90.8013 9.68448 92.3958 8.94161 94.3889 8.94161C96.0377 8.94161 97.306 9.45799 98.1938 10.4908C99.0816 11.5054 99.5255 12.8462 99.5255 14.5131V23.3732H96.2913V14.9208C96.2913 12.7828 95.4035 11.7138 93.6279 11.7138C92.6495 11.7138 91.8704 12.0309 91.2906 12.665C90.7289 13.281 90.448 14.1507 90.448 15.2741Z" fill="white"/>
<path d="M115.61 17.2581H104.874C105.001 18.3815 105.409 19.2783 106.097 19.9487C106.786 20.601 107.692 20.9272 108.815 20.9272C109.558 20.9272 110.228 20.746 110.826 20.3836C111.424 20.0212 111.85 19.532 112.104 18.916H115.365C114.93 20.3474 114.115 21.507 112.919 22.3948C111.741 23.2645 110.346 23.6993 108.734 23.6993C106.632 23.6993 104.929 23.0017 103.624 21.6066C102.338 20.2115 101.694 18.4449 101.694 16.3069C101.694 14.2232 102.347 12.4748 103.651 11.0615C104.956 9.64824 106.641 8.94161 108.706 8.94161C110.772 8.94161 112.439 9.63918 113.707 11.0343C114.994 12.4113 115.637 14.1507 115.637 16.2525L115.61 17.2581ZM108.706 11.5779C107.692 11.5779 106.849 11.8768 106.179 12.4748C105.508 13.0727 105.092 13.8699 104.929 14.8664H112.43C112.285 13.8699 111.886 13.0727 111.234 12.4748C110.582 11.8768 109.739 11.5779 108.706 11.5779Z" fill="white"/>
<path d="M120.906 9.26775V11.143C121.233 10.527 121.767 10.0106 122.51 9.59388C123.271 9.15903 124.186 8.94161 125.255 8.94161C126.922 8.94161 128.208 9.44893 129.114 10.4636C130.038 11.4782 130.5 12.8281 130.5 14.5131V23.3732H127.266V14.9208C127.266 13.9243 127.031 13.1452 126.559 12.5835C126.106 12.0037 125.409 11.7138 124.467 11.7138C123.434 11.7138 122.619 12.0218 122.021 12.6378C121.441 13.2539 121.151 14.1054 121.151 15.1926V23.3732H117.917V9.26775H120.906Z" fill="white"/>
<path d="M139.946 20.4923V23.2916C139.547 23.4004 138.985 23.4547 138.261 23.4547C135.507 23.4547 134.13 22.0686 134.13 19.2965V11.8497H131.983V9.26775H134.13V5.5987H137.364V9.26775H140V11.8497H137.364V18.9703C137.364 20.0756 137.889 20.6282 138.94 20.6282L139.946 20.4923Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15 30C23.2843 30 30 23.2843 30 15C30 6.71573 23.2843 0 15 0C6.71573 0 0 6.71573 0 15C0 23.2843 6.71573 30 15 30ZM12.2579 6.98923C12.2579 6.38376 12.7497 5.89292 13.3565 5.89292C17.4687 5.89292 20.8024 9.21967 20.8024 13.3234C20.8024 13.9289 20.3106 14.4197 19.7038 14.4197C19.0971 14.4197 18.6052 13.9289 18.6052 13.3234C18.6052 10.4306 16.2553 8.08554 13.3565 8.08554C12.7497 8.08554 12.2579 7.59471 12.2579 6.98923ZM24.1066 13.3235C24.1066 12.7181 23.6148 12.2272 23.008 12.2272C22.4013 12.2272 21.9094 12.7181 21.9094 13.3235C21.9094 16.2163 19.5595 18.5614 16.6607 18.5614C16.0539 18.5614 15.5621 19.0523 15.5621 19.6577C15.5621 20.2632 16.0539 20.754 16.6607 20.754C20.7729 20.754 24.1066 17.4273 24.1066 13.3235ZM17.7601 23.011C17.7601 23.6164 17.2682 24.1073 16.6615 24.1073C12.5492 24.1073 9.21553 20.7805 9.21553 16.6768C9.21553 16.0713 9.70739 15.5805 10.3141 15.5805C10.9209 15.5805 11.4127 16.0713 11.4127 16.6768C11.4127 19.5696 13.7627 21.9146 16.6615 21.9146C17.2682 21.9146 17.7601 22.4055 17.7601 23.011ZM5.89281 16.6769C5.89281 17.2824 6.38466 17.7732 6.9914 17.7732C7.59813 17.7732 8.08999 17.2824 8.08999 16.6769C8.08999 13.7841 10.4399 11.439 13.3388 11.439C13.9455 11.439 14.4373 10.9482 14.4373 10.3427C14.4373 9.73722 13.9455 9.24639 13.3388 9.24639C9.22647 9.24639 5.89281 12.5731 5.89281 16.6769Z" fill="#0DBD8B"/>
<path d="M53.5406 17.258H42.8052C42.932 18.3814 43.3397 19.2782 44.0282 19.9486C44.7167 20.6009 45.6227 20.927 46.746 20.927C47.4889 20.927 48.1593 20.7459 48.7572 20.3835C49.3551 20.0211 49.7809 19.5319 50.0346 18.9159H53.296C52.8611 20.3472 52.0458 21.5068 50.8499 22.3947C49.6722 23.2644 48.2771 23.6992 46.6645 23.6992C44.5627 23.6992 42.8596 23.0016 41.555 21.6065C40.2686 20.2114 39.6254 18.4448 39.6254 16.3068C39.6254 14.2231 40.2776 12.4747 41.5822 11.0614C42.8867 9.64814 44.5718 8.94151 46.6373 8.94151C48.7029 8.94151 50.3698 9.63908 51.6381 11.0342C52.9245 12.4112 53.5677 14.1506 53.5677 16.2524L53.5406 17.258ZM46.6373 11.5778C45.6227 11.5778 44.7801 11.8767 44.1098 12.4747C43.4394 13.0726 43.0226 13.8698 42.8596 14.8663H50.3607C50.2158 13.8698 49.8172 13.0726 49.1649 12.4747C48.5126 11.8767 47.6701 11.5778 46.6373 11.5778Z" fill="white"/>
<path d="M55.7934 19.1605V2.9895H59.0276V19.2148C59.0276 19.9396 59.4262 20.302 60.2234 20.302L60.7941 20.2748V23.3459C60.4861 23.4003 60.16 23.4274 59.8157 23.4274C58.4206 23.4274 57.3969 23.0741 56.7446 22.3675C56.1104 21.6609 55.7934 20.5919 55.7934 19.1605Z" fill="white"/>
<path d="M75.8563 17.258H65.121C65.2478 18.3814 65.6555 19.2782 66.344 19.9486C67.0325 20.6009 67.9384 20.927 69.0618 20.927C69.8047 20.927 70.4751 20.7459 71.073 20.3835C71.6709 20.0211 72.0967 19.5319 72.3503 18.9159H75.6117C75.1769 20.3472 74.3615 21.5068 73.1657 22.3947C71.988 23.2644 70.5928 23.6992 68.9803 23.6992C66.8785 23.6992 65.1753 23.0016 63.8708 21.6065C62.5843 20.2114 61.9411 18.4448 61.9411 16.3068C61.9411 14.2231 62.5934 12.4747 63.898 11.0614C65.2025 9.64814 66.8875 8.94151 68.9531 8.94151C71.0186 8.94151 72.6855 9.63908 73.9539 11.0342C75.2403 12.4112 75.8835 14.1506 75.8835 16.2524L75.8563 17.258ZM68.9531 11.5778C67.9384 11.5778 67.0959 11.8767 66.4255 12.4747C65.7551 13.0726 65.3384 13.8698 65.1753 14.8663H72.6765C72.5315 13.8698 72.1329 13.0726 71.4806 12.4747C70.8284 11.8767 69.9859 11.5778 68.9531 11.5778Z" fill="white"/>
<path d="M90.448 15.274V23.3731H87.2138V14.9207C87.2138 12.7827 86.326 11.7137 84.5503 11.7137C83.59 11.7137 82.82 12.0217 82.2402 12.6377C81.6785 13.2538 81.3977 14.0963 81.3977 15.1653V23.3731H78.1635V9.26764H81.1531V11.1429C81.4973 10.5088 82.0228 9.98333 82.7294 9.5666C83.436 9.14987 84.3148 8.94151 85.3657 8.94151C87.3225 8.94151 88.7358 9.68438 89.6055 11.1701C90.8013 9.68438 92.3958 8.94151 94.3888 8.94151C96.0376 8.94151 97.3059 9.45789 98.1937 10.4907C99.0816 11.5053 99.5255 12.8461 99.5255 14.513V23.3731H96.2913V14.9207C96.2913 12.7827 95.4035 11.7137 93.6278 11.7137C92.6494 11.7137 91.8703 12.0308 91.2905 12.6649C90.7288 13.2809 90.448 14.1506 90.448 15.274Z" fill="white"/>
<path d="M115.61 17.258H104.874C105.001 18.3814 105.409 19.2782 106.097 19.9486C106.786 20.6009 107.692 20.927 108.815 20.927C109.558 20.927 110.228 20.7459 110.826 20.3835C111.424 20.0211 111.85 19.5319 112.104 18.9159H115.365C114.93 20.3472 114.115 21.5068 112.919 22.3947C111.741 23.2644 110.346 23.6992 108.734 23.6992C106.632 23.6992 104.929 23.0016 103.624 21.6065C102.338 20.2114 101.694 18.4448 101.694 16.3068C101.694 14.2231 102.347 12.4747 103.651 11.0614C104.956 9.64814 106.641 8.94151 108.706 8.94151C110.772 8.94151 112.439 9.63908 113.707 11.0342C114.994 12.4112 115.637 14.1506 115.637 16.2524L115.61 17.258ZM108.706 11.5778C107.692 11.5778 106.849 11.8767 106.179 12.4747C105.508 13.0726 105.092 13.8698 104.929 14.8663H112.43C112.285 13.8698 111.886 13.0726 111.234 12.4747C110.582 11.8767 109.739 11.5778 108.706 11.5778Z" fill="white"/>
<path d="M120.906 9.26764V11.1429C121.232 10.5269 121.767 10.0105 122.51 9.59378C123.271 9.15893 124.186 8.94151 125.255 8.94151C126.922 8.94151 128.208 9.44883 129.114 10.4635C130.038 11.4781 130.5 12.828 130.5 14.513V23.3731H127.266V14.9207C127.266 13.9242 127.03 13.1451 126.559 12.5834C126.106 12.0036 125.409 11.7137 124.467 11.7137C123.434 11.7137 122.619 12.0217 122.021 12.6377C121.441 13.2538 121.151 14.1053 121.151 15.1925V23.3731H117.917V9.26764H120.906Z" fill="white"/>
<path d="M139.946 20.4922V23.2915C139.547 23.4003 138.985 23.4546 138.261 23.4546C135.507 23.4546 134.13 22.0685 134.13 19.2964V11.8496H131.982V9.26764H134.13V5.5986H137.364V9.26764H140V11.8496H137.364V18.9702C137.364 20.0755 137.889 20.6281 138.94 20.6281L139.946 20.4922Z" fill="white"/>
<path d="M148.304 20.864C146.768 19.184 146 17.056 146 14.48C146 11.904 146.768 9.784 148.304 8.12C149.856 6.44 151.896 5.6 154.424 5.6C156.504 5.6 158.264 6.176 159.704 7.328C161.144 8.48 162.064 10.024 162.464 11.96H160.616C160.28 10.52 159.552 9.376 158.432 8.528C157.312 7.68 155.976 7.256 154.424 7.256C152.44 7.256 150.84 7.92 149.624 9.248C148.424 10.576 147.824 12.32 147.824 14.48C147.824 16.64 148.424 18.384 149.624 19.712C150.84 21.04 152.44 21.704 154.424 21.704C155.976 21.704 157.312 21.28 158.432 20.432C159.552 19.584 160.28 18.44 160.616 17H162.464C162.064 18.936 161.144 20.48 159.704 21.632C158.264 22.784 156.504 23.36 154.424 23.36C151.896 23.36 149.856 22.528 148.304 20.864Z" fill="white"/>
<path d="M173.63 17.192C171.438 17.192 169.942 17.24 169.142 17.336C168.358 17.416 167.782 17.552 167.414 17.744C166.758 18.112 166.43 18.704 166.43 19.52C166.43 21.088 167.358 21.872 169.214 21.872C170.638 21.872 171.726 21.552 172.478 20.912C173.246 20.272 173.63 19.416 173.63 18.344V17.192ZM169.022 23.288C167.63 23.288 166.566 22.952 165.83 22.28C165.11 21.592 164.75 20.688 164.75 19.568C164.75 18.832 164.942 18.176 165.326 17.6C165.726 17.024 166.27 16.6 166.958 16.328C167.534 16.104 168.278 15.96 169.19 15.896C170.102 15.816 171.582 15.776 173.63 15.776V14.984C173.63 12.968 172.478 11.96 170.174 11.96C168.19 11.96 167.038 12.768 166.718 14.384H165.062C165.238 13.2 165.742 12.256 166.574 11.552C167.422 10.848 168.646 10.496 170.246 10.496C171.958 10.496 173.23 10.896 174.062 11.696C174.91 12.496 175.334 13.6 175.334 15.008V23H173.702V21.224C172.854 22.6 171.294 23.288 169.022 23.288Z" fill="white"/>
<path d="M179.418 20.312V5H181.122V20.12C181.122 20.616 181.202 20.96 181.362 21.152C181.538 21.344 181.85 21.44 182.298 21.44L182.778 21.392V22.952C182.506 23 182.21 23.024 181.89 23.024C180.242 23.024 179.418 22.12 179.418 20.312Z" fill="white"/>
<path d="M185.582 20.312V5H187.286V20.12C187.286 20.616 187.366 20.96 187.526 21.152C187.702 21.344 188.014 21.44 188.462 21.44L188.942 21.392V22.952C188.67 23 188.374 23.024 188.054 23.024C186.406 23.024 185.582 22.12 185.582 20.312Z" fill="white"/>
<path d="M201 10C201 5.58172 204.582 2 209 2H252C256.418 2 260 5.58172 260 10V20C260 24.4183 256.418 28 252 28H209C204.582 28 201 24.4183 201 20V10Z" fill="#368BD6"/>
<path d="M212.076 20H216.492C218.99 20 220.215 18.7269 220.215 17.0277C220.215 15.3764 219.043 14.407 217.882 14.3484V14.2418C218.947 13.9915 219.789 13.2457 219.789 11.9194C219.789 10.2947 218.617 9.09091 216.252 9.09091H212.076V20ZM214.052 18.3487V15.1527H216.231C217.451 15.1527 218.207 15.8984 218.207 16.8732C218.207 17.7415 217.61 18.3487 216.178 18.3487H214.052ZM214.052 13.7305V10.7209H216.05C217.211 10.7209 217.813 11.3335 217.813 12.1751C217.813 13.1339 217.035 13.7305 216.007 13.7305H214.052ZM221.934 20H229.072V18.3434H223.911V15.3658H228.662V13.7092H223.911V10.7475H229.03V9.09091H221.934V20ZM230.566 10.7475H233.938V20H235.898V10.7475H239.27V9.09091H230.566V10.7475ZM241.031 20L241.931 17.31H246.032L246.938 20H249.047L245.201 9.09091H242.762L238.921 20H241.031ZM242.464 15.7227L243.939 11.3281H244.024L245.5 15.7227H242.464Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

5
src/icons/MicMuted.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.9206 1.0544C1.68141 0.815201 1.29359 0.815201 1.0544 1.0544C0.815201 1.29359 0.815201 1.68141 1.0544 1.9206L4.55 5.41621V7C4.55 8.3531 5.6469 9.45 7 9.45C7.45436 9.45 7.87983 9.32632 8.24458 9.11079L9.12938 9.99558C8.52863 10.4234 7.7937 10.675 7 10.675C4.97035 10.675 3.325 9.02965 3.325 7C3.325 6.66173 3.05077 6.3875 2.7125 6.3875C2.37423 6.3875 2.1 6.66173 2.1 7C2.1 9.49877 3.97038 11.5607 6.3875 11.8621V12.5125C6.3875 12.8508 6.66173 13.125 7 13.125C7.33827 13.125 7.6125 12.8508 7.6125 12.5125V11.8621C8.50718 11.7505 9.32696 11.3978 10.0047 10.8709L12.0794 12.9456C12.3186 13.1848 12.7064 13.1848 12.9456 12.9456C13.1848 12.7064 13.1848 12.3186 12.9456 12.0794L1.9206 1.0544Z" fill="white"/>
<path d="M10.5474 7.96338L11.5073 8.92525C11.7601 8.33424 11.9 7.68346 11.9 7C11.9 6.66173 11.6258 6.3875 11.2875 6.3875C10.9492 6.3875 10.675 6.66173 10.675 7C10.675 7.33336 10.6306 7.65634 10.5474 7.96338Z" fill="white"/>
<path d="M4.81385 2.21784L9.45 6.86366V3.325C9.45 1.9719 8.3531 0.875 7 0.875C6.04532 0.875 5.21818 1.42104 4.81385 2.21784Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

6
src/icons/VideoMuted.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.20333 0.963373C0.474437 0.690007 0.913989 0.690007 1.1851 0.963373L11.5983 11.4633C11.8694 11.7367 11.8694 12.1799 11.5983 12.4533C11.3272 12.7267 10.8876 12.7267 10.6165 12.4533L0.20333 1.95332C-0.0677768 1.67995 -0.0677768 1.23674 0.20333 0.963373Z" fill="white"/>
<path d="M0.418261 3.63429C0.226267 3.95219 0.115674 4.32557 0.115674 4.725V9.85832C0.115674 11.0181 1.0481 11.9583 2.19831 11.9583H8.65411L0.447396 3.66596C0.437225 3.65568 0.427513 3.64511 0.418261 3.63429Z" fill="white"/>
<path d="M9.95036 4.725V8.33212L4.30219 2.625H7.86772C9.01793 2.625 9.95036 3.5652 9.95036 4.725Z" fill="white"/>
<path d="M12.8721 4.11817L11.1074 5.54167V9.04166L12.8721 10.4652C13.3266 10.8318 14 10.5055 14 9.91855V4.66478C14 4.07782 13.3266 3.7515 12.8721 4.11817Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 892 B

View File

@@ -65,13 +65,15 @@ export const AvatarInputField = forwardRef(
<EditIcon />
</label>
</div>
<Button
className={styles.removeButton}
variant="icon"
onPress={onPressRemoveAvatar}
>
Remove
</Button>
{(avatarUrl || objUrl) && !removed && (
<Button
className={styles.removeButton}
variant="icon"
onPress={onPressRemoveAvatar}
>
Remove
</Button>
)}
</div>
);
}

41
src/input/Toggle.jsx Normal file
View File

@@ -0,0 +1,41 @@
import React, { useCallback, useRef } from "react";
import styles from "./Toggle.module.css";
import { useToggleButton } from "@react-aria/button";
import classNames from "classnames";
import { Field } from "./Input";
export function Toggle({ id, label, className, onChange, isSelected }) {
const buttonRef = useRef();
const toggle = useCallback(() => {
onChange(!isSelected);
});
const { buttonProps } = useToggleButton(
{ isSelected },
{ toggle },
buttonRef
);
return (
<Field
className={classNames(
styles.toggle,
{ [styles.on]: isSelected },
className
)}
>
<button
{...buttonProps}
ref={buttonRef}
id={id}
className={classNames(styles.button, {
[styles.isPressed]: isSelected,
})}
>
<div className={styles.ball} />
</button>
<label className={styles.label} htmlFor={id}>
{label}
</label>
</Field>
);
}

View File

@@ -0,0 +1,46 @@
.toggle {
align-items: center;
margin-bottom: 20px;
}
.button {
position: relative;
padding: 0;
transition: background-color 0.2s ease-out 0.1s;
width: 44px;
height: 24px;
border: none;
border-radius: 21px;
background-color: #6f7882;
cursor: pointer;
margin-right: 8px;
}
.ball {
transition: left 0.15s ease-out 0.1s;
position: absolute;
width: 20px;
height: 20px;
border-radius: 21px;
background-color: #15191e;
left: 2px;
top: 2px;
}
.label {
padding: 10px 8px;
line-height: 24px;
color: #6f7882;
}
.toggle.on .button {
background-color: #0dbd8b;
}
.toggle.on .ball {
left: 22px;
}
.toggle.on .label {
color: #ffffff;
}

View File

@@ -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"}`);

View File

@@ -58,7 +58,8 @@ export function roomNameFromRoomId(roomId) {
.map((part) =>
part.length > 0 ? part.charAt(0).toUpperCase() + part.slice(1) : part
)
.join(" ");
.join(" ")
.toLowerCase();
}
export function isLocalRoomId(roomId) {
@@ -75,7 +76,7 @@ export function isLocalRoomId(roomId) {
return parts[1] === defaultHomeserverHost;
}
export async function createRoom(client, name) {
export async function createRoom(client, name, isPtt = false) {
const { room_id, room_alias } = await client.createRoom({
visibility: "private",
preset: "public_chat",
@@ -106,9 +107,12 @@ export async function createRoom(client, name) {
},
});
console.log({ isPtt });
await client.createGroupCall(
room_id,
GroupCallType.Video,
isPtt ? GroupCallType.Voice : GroupCallType.Video,
isPtt,
GroupCallIntent.Prompt
);

61
src/room/AudioPreview.jsx Normal file
View File

@@ -0,0 +1,61 @@
import React from "react";
import styles from "./AudioPreview.module.css";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { SelectInput } from "../input/SelectInput";
import { Item } from "@react-stately/collections";
import { Body } from "../typography/Typography";
export function AudioPreview({
state,
roomName,
audioInput,
audioInputs,
setAudioInput,
audioOutput,
audioOutputs,
setAudioOutput,
}) {
return (
<>
<h1>{`${roomName} - Radio Call`}</h1>
<div className={styles.preview}>
{state === GroupCallState.LocalCallFeedUninitialized && (
<Body fontWeight="semiBold" className={styles.microphonePermissions}>
Microphone permissions needed to join the call.
</Body>
)}
{state === GroupCallState.InitializingLocalCallFeed && (
<Body fontWeight="semiBold" className={styles.microphonePermissions}>
Accept microphone permissions to join the call.
</Body>
)}
{state === GroupCallState.LocalCallFeedInitialized && (
<>
<SelectInput
label="Microphone"
selectedKey={audioInput}
onSelectionChange={setAudioInput}
className={styles.inputField}
>
{audioInputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
))}
</SelectInput>
{audioOutputs.length > 0 && (
<SelectInput
label="Speaker"
selectedKey={audioOutput}
onSelectionChange={setAudioOutput}
className={styles.inputField}
>
{audioOutputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
))}
</SelectInput>
)}
</>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,27 @@
.preview {
margin: 20px 0;
padding: 24px 20px;
border-radius: 8px;
width: calc(100% - 40px);
max-width: 414px;
}
.inputField {
width: 100%;
}
.inputField:last-child {
margin-bottom: 0;
}
.microphonePermissions {
margin: 20px;
text-align: center;
}
@media (min-width: 800px) {
.preview {
margin-top: 40px;
background-color: #21262c;
}
}

View File

@@ -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";

View File

@@ -2,14 +2,13 @@ import React from "react";
import { useLoadGroupCall } from "./useLoadGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView";
import { usePageTitle } from "../usePageTitle";
import { isLocalRoomId } from "../matrix-utils";
import { RoomNotFoundView } from "./RoomNotFoundView";
export function GroupCallLoader({ client, roomId, viaServers, children }) {
const { loading, error, groupCall, reload } = useLoadGroupCall(
const { loading, error, groupCall } = useLoadGroupCall(
client,
roomId,
viaServers
viaServers,
true
);
usePageTitle(groupCall ? groupCall.room.name : "Loading...");
@@ -22,18 +21,6 @@ export function GroupCallLoader({ client, roomId, viaServers, children }) {
);
}
if (
error &&
(error.errcode === "M_NOT_FOUND" ||
(error.message &&
error.message.indexOf("Failed to fetch alias") !== -1)) &&
isLocalRoomId(roomId)
) {
return (
<RoomNotFoundView client={client} roomId={roomId} onReload={reload} />
);
}
if (error) {
return <ErrorView error={error} />;
}

View File

@@ -1,10 +1,11 @@
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";
import { PTTCallView } from "./PTTCallView";
import { CallEndedView } from "./CallEndedView";
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
import { useLocationNavigation } from "../useLocationNavigation";
@@ -14,7 +15,6 @@ export function GroupCallView({
isPasswordlessUser,
roomId,
groupCall,
simpleGrid,
}) {
const [showInspector, setShowInspector] = useState(
() => !!localStorage.getItem("matrix-group-call-inspector")
@@ -48,6 +48,7 @@ export function GroupCallView({
localScreenshareFeed,
screenshareFeeds,
hasLocalParticipant,
participants,
} = useGroupCall(groupCall);
useEffect(() => {
@@ -73,28 +74,43 @@ export function GroupCallView({
if (error) {
return <ErrorView error={error} />;
} else if (state === GroupCallState.Entered) {
return (
<InCallView
groupCall={groupCall}
client={client}
roomName={groupCall.room.name}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
userMediaFeeds={userMediaFeeds}
activeSpeaker={activeSpeaker}
onLeave={onLeave}
toggleScreensharing={toggleScreensharing}
isScreensharing={isScreensharing}
localScreenshareFeed={localScreenshareFeed}
screenshareFeeds={screenshareFeeds}
simpleGrid={simpleGrid}
setShowInspector={onChangeShowInspector}
showInspector={showInspector}
roomId={roomId}
/>
);
if (groupCall.isPtt) {
return (
<PTTCallView
client={client}
roomId={roomId}
roomName={groupCall.room.name}
groupCall={groupCall}
participants={participants}
userMediaFeeds={userMediaFeeds}
onLeave={onLeave}
setShowInspector={onChangeShowInspector}
showInspector={showInspector}
/>
);
} else {
return (
<InCallView
groupCall={groupCall}
client={client}
roomName={groupCall.room.name}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
userMediaFeeds={userMediaFeeds}
activeSpeaker={activeSpeaker}
onLeave={onLeave}
toggleScreensharing={toggleScreensharing}
isScreensharing={isScreensharing}
localScreenshareFeed={localScreenshareFeed}
screenshareFeeds={screenshareFeeds}
setShowInspector={onChangeShowInspector}
showInspector={showInspector}
roomId={roomId}
/>
);
}
} else if (state === GroupCallState.Entering) {
return (
<FullScreenView>
@@ -107,6 +123,7 @@ export function GroupCallView({
return (
<LobbyView
client={client}
groupCall={groupCall}
hasLocalParticipant={hasLocalParticipant}
roomName={groupCall.room.name}
state={state}

View File

@@ -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({
<div className={styles.centerMessage}>
<p>Waiting for other participants...</p>
</div>
) : simpleGrid ? (
<SimpleVideoGrid items={items} />
) : (
<VideoGrid
items={items}

View File

@@ -1,23 +1,20 @@
import React, { useEffect, useRef } from "react";
import styles from "./LobbyView.module.css";
import { Button, CopyButton, MicButton, VideoButton } from "../button";
import { Button, CopyButton } from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { useCallFeed } from "matrix-react-sdk/src/hooks/useCallFeed";
import { useMediaStream } from "matrix-react-sdk/src/hooks/useMediaStream";
import { useCallFeed } from "../video-grid/useCallFeed";
import { getRoomUrl } from "../matrix-utils";
import { OverflowMenu } from "./OverflowMenu";
import { UserMenuContainer } from "../UserMenuContainer";
import { Body, Link } from "../typography/Typography";
import { Avatar } from "../Avatar";
import { useProfile } from "../profile/useProfile";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { useLocationNavigation } from "../useLocationNavigation";
import { useMediaHandler } from "../settings/useMediaHandler";
import { VideoPreview } from "./VideoPreview";
import { AudioPreview } from "./AudioPreview";
export function LobbyView({
client,
groupCall,
roomName,
state,
onInitLocalCallFeed,
@@ -32,11 +29,14 @@ export function LobbyView({
roomId,
}) {
const { stream } = useCallFeed(localCallFeed);
const { audioOutput } = useMediaHandler();
const videoRef = useMediaStream(stream, audioOutput, true);
const { displayName, avatarUrl } = useProfile(client);
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
const avatarSize = (previewBounds.height - 66) / 2;
const {
audioInput,
audioInputs,
setAudioInput,
audioOutput,
audioOutputs,
setAudioOutput,
} = useMediaHandler();
useEffect(() => {
onInitLocalCallFeed();
@@ -64,53 +64,31 @@ export function LobbyView({
</Header>
<div className={styles.joinRoom}>
<div className={styles.joinRoomContent}>
<div className={styles.preview} ref={previewRef}>
<video ref={videoRef} muted playsInline disablePictureInPicture />
{state === GroupCallState.LocalCallFeedUninitialized && (
<Body fontWeight="semiBold" className={styles.webcamPermissions}>
Webcam/microphone permissions needed to join the call.
</Body>
)}
{state === GroupCallState.InitializingLocalCallFeed && (
<Body fontWeight="semiBold" className={styles.webcamPermissions}>
Accept webcam/microphone permissions to join the call.
</Body>
)}
{state === GroupCallState.LocalCallFeedInitialized && (
<>
{localVideoMuted && (
<div className={styles.avatarContainer}>
<Avatar
style={{
width: avatarSize,
height: avatarSize,
borderRadius: avatarSize,
fontSize: Math.round(avatarSize / 2),
}}
src={avatarUrl}
fallback={displayName.slice(0, 1).toUpperCase()}
/>
</div>
)}
<div className={styles.previewButtons}>
<MicButton
muted={microphoneMuted}
onPress={toggleMicrophoneMuted}
/>
<VideoButton
muted={localVideoMuted}
onPress={toggleLocalVideoMuted}
/>
<OverflowMenu
roomId={roomId}
setShowInspector={setShowInspector}
showInspector={showInspector}
client={client}
/>
</div>
</>
)}
</div>
{groupCall.isPtt ? (
<AudioPreview
roomName={roomName}
state={state}
audioInput={audioInput}
audioInputs={audioInputs}
setAudioInput={setAudioInput}
audioOutput={audioOutput}
audioOutputs={audioOutputs}
setAudioOutput={setAudioOutput}
/>
) : (
<VideoPreview
state={state}
client={client}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
setShowInspector={setShowInspector}
showInspector={showInspector}
stream={stream}
audioOutput={audioOutput}
/>
)}
<Button
ref={joinCallButtonRef}
className={styles.copyButton}

View File

@@ -46,58 +46,6 @@ limitations under the License.
margin-top: 50px;
}
.preview {
position: relative;
min-height: 280px;
height: 50vh;
border-radius: 24px;
overflow: hidden;
background-color: var(--bgColor3);
margin: 20px;
}
.preview video {
width: calc(100% + 1px);
height: 100%;
object-fit: contain;
background-color: black;
/* transform scale doesn't perfectly match width, so make -1.01 border issues */
transform: scaleX(-1.01);
}
.avatarContainer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 66px;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--bgColor3);
}
.webcamPermissions {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
text-align: center;
}
.previewButtons {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 66px;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(23, 25, 28, 0.9);
}
.joinCallButton {
position: absolute;
width: 100%;
@@ -118,17 +66,3 @@ limitations under the License.
.copyButton:last-child {
margin-bottom: 0;
}
.previewButtons > * {
margin-right: 30px;
}
.previewButtons > :last-child {
margin-right: 0px;
}
@media (min-width: 800px) {
.preview {
margin-top: 40px;
}
}

48
src/room/PTTButton.jsx Normal file
View File

@@ -0,0 +1,48 @@
import React from "react";
import classNames from "classnames";
import styles from "./PTTButton.module.css";
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
import { Avatar } from "../Avatar";
export function PTTButton({
showTalkOverError,
activeSpeakerUserId,
activeSpeakerDisplayName,
activeSpeakerAvatarUrl,
activeSpeakerIsLocalUser,
size,
startTalking,
stopTalking,
}) {
return (
<button
className={classNames(styles.pttButton, {
[styles.talking]: activeSpeakerUserId,
[styles.error]: showTalkOverError,
})}
onMouseDown={startTalking}
onMouseUp={stopTalking}
>
{activeSpeakerIsLocalUser || !activeSpeakerUserId ? (
<MicIcon
className={styles.micIcon}
width={size / 3}
height={size / 3}
/>
) : (
<Avatar
key={activeSpeakerUserId}
style={{
width: size - 12,
height: size - 12,
borderRadius: size - 12,
fontSize: Math.round((size - 12) / 2),
}}
src={activeSpeakerAvatarUrl}
fallback={activeSpeakerDisplayName.slice(0, 1).toUpperCase()}
className={styles.avatar}
/>
)}
</button>
);
}

View File

@@ -0,0 +1,25 @@
.pttButton {
width: 100vw;
height: 100vh;
max-height: 232px;
max-width: 232px;
border-radius: 116px;
color: ##fff;
border: 6px solid #0dbd8b;
background-color: #21262c;
position: relative;
padding: 0;
}
.talking {
background-color: #0dbd8b;
box-shadow: 0px 0px 0px 17px rgba(13, 189, 139, 0.2),
0px 0px 0px 34px rgba(13, 189, 139, 0.2);
}
.error {
background-color: #ff5b55;
border-color: #ff5b55;
box-shadow: 0px 0px 0px 17px rgba(255, 91, 85, 0.2),
0px 0px 0px 34px rgba(255, 91, 85, 0.2);
}

164
src/room/PTTCallView.jsx Normal file
View File

@@ -0,0 +1,164 @@
import React from "react";
import { useModalTriggerState } from "../Modal";
import { SettingsModal } from "../settings/SettingsModal";
import { InviteModal } from "./InviteModal";
import { HangupButton, InviteButton, SettingsButton } from "../button";
import { Header, LeftNav, RightNav, RoomSetupHeaderInfo } from "../Header";
import styles from "./PTTCallView.module.css";
import { Facepile } from "../Facepile";
import { PTTButton } from "./PTTButton";
import { PTTFeed } from "./PTTFeed";
import { useMediaHandler } from "../settings/useMediaHandler";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { usePTT } from "./usePTT";
import { Timer } from "./Timer";
import { Toggle } from "../input/Toggle";
import { getAvatarUrl } from "../matrix-utils";
import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
export function PTTCallView({
client,
roomId,
roomName,
groupCall,
participants,
userMediaFeeds,
onLeave,
setShowInspector,
showInspector,
}) {
const { modalState: inviteModalState, modalProps: inviteModalProps } =
useModalTriggerState();
const { modalState: settingsModalState, modalProps: settingsModalProps } =
useModalTriggerState();
const [containerRef, bounds] = useMeasure({ polyfill: ResizeObserver });
const facepileSize = bounds.width < 800 ? "sm" : "md";
const pttButtonSize = 232;
const pttBorderWidth = 6;
const { audioOutput } = useMediaHandler();
const {
pttButtonHeld,
isAdmin,
talkOverEnabled,
setTalkOverEnabled,
activeSpeakerUserId,
startTalking,
stopTalking,
} = usePTT(client, groupCall, userMediaFeeds);
const activeSpeakerIsLocalUser =
activeSpeakerUserId && client.getUserId() === activeSpeakerUserId;
const showTalkOverError =
pttButtonHeld && !activeSpeakerIsLocalUser && !talkOverEnabled;
const activeSpeakerUser = activeSpeakerUserId
? client.getUser(activeSpeakerUserId)
: null;
const activeSpeakerAvatarUrl = activeSpeakerUser
? getAvatarUrl(
client,
activeSpeakerUser.avatarUrl,
pttButtonSize - pttBorderWidth * 2
)
: null;
const activeSpeakerDisplayName = activeSpeakerUser
? activeSpeakerUser.displayName
: "";
return (
<div className={styles.pttCallView} ref={containerRef}>
<Header className={styles.header}>
<LeftNav>
<RoomSetupHeaderInfo roomName={roomName} onPress={onLeave} />
</LeftNav>
<RightNav />
</Header>
<div className={styles.center}>
<div className={styles.participants}>
<p>{`${participants.length} ${
participants.length > 1 ? "people" : "person"
} connected`}</p>
<Facepile
size={facepileSize}
max={8}
className={styles.facepile}
client={client}
participants={participants}
/>
</div>
<div className={styles.footer}>
<SettingsButton onPress={() => settingsModalState.open()} />
<HangupButton onPress={onLeave} />
<InviteButton onPress={() => inviteModalState.open()} />
</div>
<div className={styles.pttButtonContainer}>
{activeSpeakerUserId ? (
<div className={styles.talkingInfo}>
<h2>
{!activeSpeakerIsLocalUser && (
<AudioIcon className={styles.speakerIcon} />
)}
{activeSpeakerIsLocalUser
? "Talking..."
: `${activeSpeakerDisplayName} is talking...`}
</h2>
<Timer value={activeSpeakerUserId} />
</div>
) : (
<div className={styles.talkingInfo} />
)}
<PTTButton
showTalkOverError={showTalkOverError}
activeSpeakerUserId={activeSpeakerUserId}
activeSpeakerDisplayName={activeSpeakerDisplayName}
activeSpeakerAvatarUrl={activeSpeakerAvatarUrl}
activeSpeakerIsLocalUser={activeSpeakerIsLocalUser}
size={pttButtonSize}
startTalking={startTalking}
stopTalking={stopTalking}
/>
<p className={styles.actionTip}>
{showTalkOverError
? "You can't talk at the same time"
: pttButtonHeld
? "Release spacebar key to stop"
: talkOverEnabled &&
activeSpeakerUserId &&
!activeSpeakerIsLocalUser
? `Press and hold spacebar to talk over ${activeSpeakerDisplayName}`
: "Press and hold spacebar to talk"}
</p>
{userMediaFeeds.map((callFeed) => (
<PTTFeed
key={callFeed.userId}
callFeed={callFeed}
audioOutputDevice={audioOutput}
/>
))}
{isAdmin && (
<Toggle
isSelected={talkOverEnabled}
onChange={setTalkOverEnabled}
label="Talk over speaker"
id="talkOverEnabled"
/>
)}
</div>
</div>
{settingsModalState.isOpen && (
<SettingsModal
{...settingsModalProps}
setShowInspector={setShowInspector}
showInspector={showInspector}
/>
)}
{inviteModalState.isOpen && (
<InviteModal roomId={roomId} {...inviteModalProps} />
)}
</div>
);
}

View File

@@ -0,0 +1,106 @@
.pttCallView {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
min-height: 100%;
position: fixed;
height: 100%;
width: 100%;
}
.center {
width: 100%;
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
}
.participants {
display: flex;
flex-direction: column;
margin: 20px;
}
.participants > p {
color: #a9b2bc;
margin-bottom: 8px;
}
.facepile {
align-self: center;
}
.talkingInfo {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 20px;
height: 88px;
}
.speakerIcon {
margin-right: 8px;
}
.pttButtonContainer {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
justify-content: center;
}
.actionTip {
margin-top: 20px;
margin-bottom: 20px;
font-size: 17px;
}
.footer {
position: relative;
display: flex;
justify-content: center;
height: 64px;
margin-bottom: 20px;
}
.footer > * {
margin-right: 30px;
}
.footer > :last-child {
margin-right: 0px;
}
@media (min-width: 800px) {
.participants {
margin-bottom: 67px;
}
.talkingInfo {
margin-bottom: 38px;
}
.center {
margin-top: 48px;
}
.actionTip {
margin-top: 42px;
margin-bottom: 45px;
}
.pttButtonContainer {
flex: 0;
margin-bottom: 0;
justify-content: flex-start;
}
.footer {
flex: auto;
order: 4;
}
}

10
src/room/PTTFeed.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import { useCallFeed } from "../video-grid/useCallFeed";
import { useMediaStream } from "../video-grid/useMediaStream";
import styles from "./PTTFeed.module.css";
export function PTTFeed({ callFeed, audioOutputDevice }) {
const { isLocal, stream } = useCallFeed(callFeed);
const mediaRef = useMediaStream(stream, audioOutputDevice, isLocal);
return <audio ref={mediaRef} className={styles.audioFeed} playsInline />;
}

View File

@@ -0,0 +1,3 @@
.audioFeed {
display: none;
}

View File

@@ -2,7 +2,7 @@ import React, { useEffect } from "react";
import { Modal, ModalContent } from "../Modal";
import { Button } from "../button";
import { FieldRow, ErrorMessage } from "../input/Input";
import { useSubmitRageshake } from "../settings/rageshake";
import { useSubmitRageshake } from "../settings/submit-rageshake";
import { Body } from "../typography/Typography";
export function RageshakeRequestModal({ rageshakeRequestId, roomId, ...rest }) {

View File

@@ -1,74 +0,0 @@
import React, { useState, useCallback } from "react";
import { FullScreenView } from "../FullScreenView";
import { Headline, Subtitle } from "../typography/Typography";
import { createRoom, roomNameFromRoomId } from "../matrix-utils";
import { FieldRow, ErrorMessage, InputField } from "../input/Input";
import { Button } from "../button";
import { Form } from "../form/Form";
import { useHistory } from "react-router-dom";
import styles from "./RoomNotFoundView.module.css";
export function RoomNotFoundView({ client, roomId, onReload }) {
const history = useHistory();
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const roomName = roomNameFromRoomId(roomId);
const onSubmit = useCallback(
(e) => {
e.preventDefault();
async function submit() {
setError(undefined);
setLoading(true);
await createRoom(client, roomName);
onReload();
}
submit().catch((error) => {
console.error(error);
setLoading(false);
setError(error);
});
},
[client, roomName]
);
return (
<FullScreenView>
<Headline>Call Not Found</Headline>
<Subtitle>Would you like to create this call?</Subtitle>
<Form onSubmit={onSubmit} className={styles.form}>
<FieldRow>
<InputField
id="callName"
name="callName"
label="Call name"
placeholder="Call name"
type="text"
required
autoComplete="off"
value={roomName}
disabled
/>
</FieldRow>
<FieldRow>
<Button
type="submit"
size="lg"
disabled={loading}
className={styles.button}
>
{loading ? "Loading..." : "Create Room"}
</Button>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
</FieldRow>
)}
</Form>
</FullScreenView>
);
}

View File

@@ -1,11 +0,0 @@
.form {
padding: 0 24px;
justify-content: center;
max-width: 409px;
width: calc(100% - 48px);
margin-bottom: 72px;
}
.button {
width: 100%;
}

View File

@@ -29,9 +29,9 @@ export function RoomPage() {
const { roomId: maybeRoomId } = useParams();
const { hash, search } = useLocation();
const [simpleGrid, viaServers] = useMemo(() => {
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}
/>
)}
</GroupCallLoader>

View File

@@ -18,7 +18,7 @@ export function RoomRedirect() {
roomId = `#${roomId}:${defaultHomeserverHost}`;
}
history.replace(`/room/${roomId}`);
history.replace(`/room/${roomId.toLowerCase()}`);
}, [pathname, history]);
return <LoadingView />;

37
src/room/Timer.jsx Normal file
View File

@@ -0,0 +1,37 @@
import React, { useEffect, useState } from "react";
function leftPad(value) {
return value < 10 ? "0" + value : value;
}
function formatTime(msElapsed) {
const secondsElapsed = msElapsed / 1000;
const hours = Math.floor(secondsElapsed / 3600);
const minutes = Math.floor(secondsElapsed / 60) - hours * 60;
const seconds = Math.floor(secondsElapsed - hours * 3600 - minutes * 60);
return `${leftPad(hours)}:${leftPad(minutes)}:${leftPad(seconds)}`;
}
export function Timer({ value }) {
const [timestamp, setTimestamp] = useState();
useEffect(() => {
const startTimeMs = performance.now();
let animationFrame;
function onUpdate(curTimeMs) {
const msElapsed = curTimeMs - startTimeMs;
setTimestamp(formatTime(msElapsed));
animationFrame = requestAnimationFrame(onUpdate);
}
onUpdate(startTimeMs);
return () => {
cancelAnimationFrame(animationFrame);
};
}, [value]);
return <p>{timestamp}</p>;
}

80
src/room/VideoPreview.jsx Normal file
View File

@@ -0,0 +1,80 @@
import React from "react";
import { MicButton, VideoButton } from "../button";
import { useMediaStream } from "../video-grid/useMediaStream";
import { OverflowMenu } from "./OverflowMenu";
import { Avatar } from "../Avatar";
import { useProfile } from "../profile/useProfile";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import styles from "./VideoPreview.module.css";
import { Body } from "../typography/Typography";
export function VideoPreview({
client,
state,
roomId,
microphoneMuted,
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
setShowInspector,
showInspector,
audioOutput,
stream,
}) {
const videoRef = useMediaStream(stream, audioOutput, true);
const { displayName, avatarUrl } = useProfile(client);
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
const avatarSize = (previewBounds.height - 66) / 2;
return (
<div className={styles.preview} ref={previewRef}>
<video ref={videoRef} muted playsInline disablePictureInPicture />
{state === GroupCallState.LocalCallFeedUninitialized && (
<Body fontWeight="semiBold" className={styles.webcamPermissions}>
Webcam/microphone permissions needed to join the call.
</Body>
)}
{state === GroupCallState.InitializingLocalCallFeed && (
<Body fontWeight="semiBold" className={styles.webcamPermissions}>
Accept webcam/microphone permissions to join the call.
</Body>
)}
{state === GroupCallState.LocalCallFeedInitialized && (
<>
{localVideoMuted && (
<div className={styles.avatarContainer}>
<Avatar
style={{
width: avatarSize,
height: avatarSize,
borderRadius: avatarSize,
fontSize: Math.round(avatarSize / 2),
}}
src={avatarUrl}
fallback={displayName.slice(0, 1).toUpperCase()}
/>
</div>
)}
<div className={styles.previewButtons}>
<MicButton
muted={microphoneMuted}
onPress={toggleMicrophoneMuted}
/>
<VideoButton
muted={localVideoMuted}
onPress={toggleLocalVideoMuted}
/>
<OverflowMenu
roomId={roomId}
setShowInspector={setShowInspector}
showInspector={showInspector}
client={client}
/>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,65 @@
.preview {
position: relative;
min-height: 280px;
height: 50vh;
border-radius: 24px;
overflow: hidden;
background-color: var(--bgColor3);
margin: 20px;
}
.preview video {
width: calc(100% + 1px);
height: 100%;
object-fit: contain;
background-color: black;
/* transform scale doesn't perfectly match width, so make -1.01 border issues */
transform: scaleX(-1.01);
}
.avatarContainer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 66px;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--bgColor3);
}
.webcamPermissions {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
text-align: center;
}
.previewButtons {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 66px;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(23, 25, 28, 0.9);
}
.previewButtons > * {
margin-right: 30px;
}
.previewButtons > :last-child {
margin-right: 0px;
}
@media (min-width: 800px) {
.preview {
margin-top: 40px;
}
}

252
src/room/useGroupCall.js Normal file
View File

@@ -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,
};
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect } from "react";
import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
async function fetchGroupCall(
client,
@@ -36,17 +37,49 @@ async function fetchGroupCall(
});
}
export function useLoadGroupCall(client, roomId, viaServers) {
export function useLoadGroupCall(client, roomId, viaServers, createIfNotFound) {
const [state, setState] = useState({
loading: true,
error: undefined,
groupCall: undefined,
reloadId: 0,
});
useEffect(() => {
async function fetchOrCreateGroupCall() {
try {
const groupCall = await fetchGroupCall(
client,
roomId,
viaServers,
30000
);
return groupCall;
} catch (error) {
if (
createIfNotFound &&
(error.errcode === "M_NOT_FOUND" ||
(error.message &&
error.message.indexOf("Failed to fetch alias") !== -1)) &&
isLocalRoomId(roomId)
) {
const roomName = roomNameFromRoomId(roomId);
await createRoom(client, roomName);
const groupCall = await fetchGroupCall(
client,
roomId,
viaServers,
30000
);
return groupCall;
}
throw error;
}
}
setState({ loading: true });
fetchGroupCall(client, roomId, viaServers, 30000)
fetchOrCreateGroupCall()
.then((groupCall) =>
setState((prevState) => ({ ...prevState, loading: false, groupCall }))
)
@@ -55,9 +88,5 @@ export function useLoadGroupCall(client, roomId, viaServers) {
);
}, [client, roomId, state.reloadId]);
const reload = useCallback(() => {
setState((prevState) => ({ ...prevState, reloadId: prevState.reloadId++ }));
}, []);
return { ...state, reload };
return state;
}

121
src/room/usePTT.js Normal file
View File

@@ -0,0 +1,121 @@
import { useCallback, useEffect, useState } from "react";
export function usePTT(client, groupCall, userMediaFeeds) {
const [
{ pttButtonHeld, isAdmin, talkOverEnabled, activeSpeakerUserId },
setState,
] = useState(() => {
const roomMember = groupCall.room.getMember(client.getUserId());
const activeSpeakerFeed = userMediaFeeds.find((f) => !f.isAudioMuted());
return {
isAdmin: roomMember.powerLevel >= 100,
talkOverEnabled: false,
pttButtonHeld: false,
activeSpeakerUserId: activeSpeakerFeed ? activeSpeakerFeed.userId : null,
};
});
useEffect(() => {
function onMuteStateChanged(...args) {
const activeSpeakerFeed = userMediaFeeds.find((f) => !f.isAudioMuted());
setState((prevState) => ({
...prevState,
activeSpeakerUserId: activeSpeakerFeed
? activeSpeakerFeed.userId
: null,
}));
}
for (const callFeed of userMediaFeeds) {
callFeed.addListener("mute_state_changed", onMuteStateChanged);
}
const activeSpeakerFeed = userMediaFeeds.find((f) => !f.isAudioMuted());
setState((prevState) => ({
...prevState,
activeSpeakerUserId: activeSpeakerFeed ? activeSpeakerFeed.userId : null,
}));
return () => {
for (const callFeed of userMediaFeeds) {
callFeed.removeListener("mute_state_changed", onMuteStateChanged);
}
};
}, [userMediaFeeds]);
const startTalking = useCallback(() => {
if (!activeSpeakerUserId || isAdmin || talkOverEnabled) {
if (groupCall.isMicrophoneMuted()) {
groupCall.setMicrophoneMuted(false);
}
setState((prevState) => ({ ...prevState, pttButtonHeld: true }));
}
}, []);
const stopTalking = useCallback(() => {
if (!groupCall.isMicrophoneMuted()) {
groupCall.setMicrophoneMuted(true);
}
setState((prevState) => ({ ...prevState, pttButtonHeld: false }));
}, []);
useEffect(() => {
function onKeyDown(event) {
if (event.code === "Space") {
event.preventDefault();
startTalking();
}
}
function onKeyUp(event) {
if (event.code === "Space") {
event.preventDefault();
stopTalking();
}
}
function onBlur() {
// TODO: We will need to disable this for a global PTT hotkey to work
if (!groupCall.isMicrophoneMuted()) {
groupCall.setMicrophoneMuted(true);
}
setState((prevState) => ({ ...prevState, pttButtonHeld: false }));
}
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", onBlur);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onBlur);
};
}, [activeSpeakerUserId, isAdmin, talkOverEnabled]);
const setTalkOverEnabled = useCallback((talkOverEnabled) => {
setState((prevState) => ({
...prevState,
talkOverEnabled,
}));
}, []);
return {
pttButtonHeld,
isAdmin,
talkOverEnabled,
setTalkOverEnabled,
activeSpeakerUserId,
startTalking,
stopTalking,
};
}

54
src/room/usePageUnload.js Normal file
View File

@@ -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]);
}

View File

@@ -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 }) {

View File

@@ -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";
/*
Copyright 2017 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
export function useSubmitRageshake() {
const { client } = useClient();
const [{ json }] = useContext(InspectorContext);
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
const [{ sending, sent, error }, setState] = useState({
sending: false,
sent: false,
error: null,
});
http://www.apache.org/licenses/LICENSE-2.0
const submitRageshake = useCallback(
async (opts) => {
if (sending) {
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 {
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 "<object>";
}
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;
}
}
// 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.")
);
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;
}
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 rageshake.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);
const lines = this.logger.flush();
if (lines.length === 0) {
resolve();
return;
}
},
[client]
);
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;
}
return {
submitRageshake,
sending,
sent,
error,
};
}
/**
* 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<Object[]>} 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;
export function useDownloadDebugLog() {
const [{ json }] = useContext(InspectorContext);
// 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");
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,
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
}
lines = cursor.value.lines + lines;
if (lines.length >= maxSize) {
resolve(lines);
} else {
cursor.continue();
}
};
});
},
[client]
);
}
return sendRageshakeRequest;
}
// 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);
});
}
export function useRageshakeRequestModal(roomId) {
const { modalState, modalProps } = useModalTriggerState();
const { client } = useClient();
const [rageshakeRequestId, setRageshakeRequestId] = useState();
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);
});
}
useEffect(() => {
const onEvent = (event) => {
const type = event.getType();
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);
if (
type === "org.matrix.rageshake_request" &&
roomId === event.getRoomId() &&
client.getUserId() !== event.getSender()
) {
setRageshakeRequestId(event.getContent().request_id);
modalState.open();
// 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++,
};
}
client.on("event", onEvent);
return () => {
client.removeListener("event", onEvent);
generateLastModifiedTime() {
return {
id: this.id,
ts: Date.now(),
};
}, [modalState.open, roomId]);
return { modalState, modalProps: { ...modalProps, rageshakeRequestId } };
}
}
/**
* 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<T[]>} 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
}
results.push(resultMapper(cursor));
cursor.continue();
};
});
}
/**
* 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;
}
/**
* 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 flush() {
if (!global.mx_rage_store) {
return;
}
global.mx_rage_store.flush();
}
/**
* 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();
}
/**
* 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: "-",
},
];
}
}

View File

@@ -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 } };
}

6
src/useShouldShowPtt.js Normal file
View File

@@ -0,0 +1,6 @@
import { useLocation } from "react-router-dom";
export function useShouldShowPtt() {
const { hash } = useLocation();
return hash.startsWith("#ptt");
}

1017
src/video-grid/VideoGrid.jsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
.videoGrid {
position: relative;
overflow: hidden;
flex: 1;
touch-action: none;
}

View File

@@ -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";

View File

@@ -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 (
<animated.div
className={classNames(styles.videoTile, className, {
[styles.isLocal]: isLocal,
[styles.speaking]: speaking,
[styles.muted]: audioMuted,
[styles.screenshare]: screenshare,
})}
{...rest}
>
{(videoMuted || noVideo) && (
<>
<div className={styles.videoMutedOverlay} />
{avatar}
</>
)}
{screenshare ? (
<div className={styles.presenterLabel}>
<span>{`${name} is presenting`}</span>
</div>
) : (
(showName || audioMuted || (videoMuted && !noVideo)) && (
<div className={styles.memberName}>
{audioMuted && !(videoMuted && !noVideo) && <MicMutedIcon />}
{videoMuted && !noVideo && <VideoMutedIcon />}
{showName && <span title={name}>{name}</span>}
</div>
)
)}
<video ref={mediaRef} playsInline disablePictureInPicture />
</animated.div>
);
}

View File

@@ -0,0 +1,113 @@
.videoTile {
position: absolute;
will-change: transform, width, height, opacity, box-shadow;
border-radius: 20px;
overflow: hidden;
cursor: pointer;
touch-action: none;
}
.videoTile * {
touch-action: none;
-moz-user-select: none;
-webkit-user-drag: none;
user-select: none;
}
.videoTile video {
width: 100%;
height: 100%;
object-fit: cover;
background-color: #444;
}
.videoTile.isLocal:not(.screenshare) video {
transform: scaleX(-1);
}
.videoTile.speaking::after {
position: absolute;
top: -1px;
left: -1px;
right: -1px;
bottom: -1px;
content: "";
border-radius: 20px;
box-shadow: inset 0 0 0 4px #0dbd8b !important;
}
.videoTile.screenshare > video {
object-fit: contain;
}
.memberName {
position: absolute;
bottom: 16px;
left: 16px;
height: 24px;
padding: 0 8px;
color: white;
background-color: rgba(23, 25, 28, 0.85);
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
user-select: none;
max-width: calc(100% - 48px);
overflow: hidden;
z-index: 1;
}
.memberName > * {
margin-right: 6px;
}
.memberName > :last-child {
margin-right: 0px;
}
.memberName span {
font-size: 12px;
font-weight: 400;
line-height: 16px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.videoMutedAvatar {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.videoMutedOverlay {
width: 100%;
height: 100%;
background-color: #21262c;
}
.presenterLabel {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
background-color: #17191c;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
padding: 4px 8px;
font-weight: normal;
font-size: 12px;
line-height: 15px;
}
.screensharePIP {
bottom: 8px;
right: 8px;
width: 25%;
max-width: 360px;
border-radius: 20px;
}

View File

@@ -0,0 +1,49 @@
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
import React from "react";
import { useCallFeed } from "./useCallFeed";
import { useMediaStream } from "./useMediaStream";
import { useRoomMemberName } from "./useRoomMemberName";
import { VideoTile } from "./VideoTile";
export function VideoTileContainer({
item,
width,
height,
getAvatar,
showName,
audioOutputDevice,
disableSpeakingIndicator,
...rest
}) {
const {
isLocal,
audioMuted,
videoMuted,
noVideo,
speaking,
stream,
purpose,
member,
} = useCallFeed(item.callFeed);
const { rawDisplayName } = useRoomMemberName(member);
const mediaRef = useMediaStream(stream, audioOutputDevice, isLocal);
// Firefox doesn't respect the disablePictureInPicture attribute
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
return (
<VideoTile
isLocal={isLocal}
speaking={speaking && !disableSpeakingIndicator}
audioMuted={audioMuted}
noVideo={noVideo}
videoMuted={videoMuted}
screenshare={purpose === SDPStreamMetadataPurpose.Screenshare}
name={rawDisplayName}
showName={showName}
mediaRef={mediaRef}
avatar={getAvatar && getAvatar(member, width, height)}
{...rest}
/>
);
}

View File

@@ -0,0 +1,56 @@
import { useState, useEffect } from "react";
import { CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed";
function getCallFeedState(callFeed) {
return {
member: callFeed ? callFeed.getMember() : null,
isLocal: callFeed ? callFeed.isLocal() : false,
speaking: callFeed ? callFeed.isSpeaking() : false,
noVideo: callFeed
? !callFeed.stream || callFeed.stream.getVideoTracks().length === 0
: true,
videoMuted: callFeed ? callFeed.isVideoMuted() : true,
audioMuted: callFeed ? callFeed.isAudioMuted() : true,
stream: callFeed ? callFeed.stream : undefined,
purpose: callFeed ? callFeed.purpose : undefined,
};
}
export function useCallFeed(callFeed) {
const [state, setState] = useState(() => getCallFeedState(callFeed));
useEffect(() => {
function onSpeaking(speaking) {
setState((prevState) => ({ ...prevState, speaking }));
}
function onMuteStateChanged(audioMuted, videoMuted) {
setState((prevState) => ({ ...prevState, audioMuted, videoMuted }));
}
function onUpdateCallFeed() {
setState(getCallFeedState(callFeed));
}
if (callFeed) {
callFeed.on(CallFeedEvent.Speaking, onSpeaking);
callFeed.on(CallFeedEvent.MuteStateChanged, onMuteStateChanged);
callFeed.on(CallFeedEvent.NewStream, onUpdateCallFeed);
}
onUpdateCallFeed();
return () => {
if (callFeed) {
callFeed.removeListener(CallFeedEvent.Speaking, onSpeaking);
callFeed.removeListener(
CallFeedEvent.MuteStateChanged,
onMuteStateChanged
);
callFeed.removeListener(CallFeedEvent.NewStream, onUpdateCallFeed);
}
};
}, [callFeed]);
return state;
}

View File

@@ -0,0 +1,48 @@
import { useRef, useEffect } from "react";
export function useMediaStream(stream, audioOutputDevice, mute = false) {
const mediaRef = useRef();
useEffect(() => {
console.log(
`useMediaStream update stream mediaRef.current ${!!mediaRef.current} stream ${
stream && stream.id
}`
);
if (mediaRef.current) {
if (stream) {
mediaRef.current.muted = mute;
mediaRef.current.srcObject = stream;
mediaRef.current.play();
} else {
mediaRef.current.srcObject = null;
}
}
}, [stream, mute]);
useEffect(() => {
if (
mediaRef.current &&
audioOutputDevice &&
mediaRef.current !== undefined
) {
console.log(`useMediaStream setSinkId ${audioOutputDevice}`);
mediaRef.current.setSinkId(audioOutputDevice);
}
}, [audioOutputDevice]);
useEffect(() => {
const mediaEl = mediaRef.current;
return () => {
if (mediaEl) {
// Ensure we set srcObject to null before unmounting to prevent memory leak
// https://webrtchacks.com/srcobject-intervention/
mediaEl.srcObject = null;
}
};
}, []);
return mediaRef;
}

View File

@@ -0,0 +1,24 @@
import { useState, useEffect } from "react";
export function useRoomMemberName(member) {
const [state, setState] = useState({
name: member.name,
rawDisplayName: member.rawDisplayName,
});
useEffect(() => {
function updateName() {
setState({ name: member.name, rawDisplayName: member.rawDisplayName });
}
updateName();
member.on("RoomMember.name", updateName);
return () => {
member.removeListener("RoomMember.name", updateName);
};
}, [member]);
return state;
}

View File

@@ -24,6 +24,9 @@ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd());
return {
build: {
sourcemap: true,
},
plugins: [
svgrPlugin(),
htmlTemplate({
@@ -36,16 +39,8 @@ export default defineConfig(({ mode }) => {
proxy: {
"/_matrix": env.VITE_DEFAULT_HOMESERVER || "http://localhost:8008",
},
fs: {
// Current we're bundling files linked in from matrix-react-sdk
// We should re-enable this if we plan to run Vite outside the dev server mode
strict: false,
},
},
resolve: {
alias: {
"$(res)": path.resolve(__dirname, "node_modules/matrix-react-sdk/res"),
},
dedupe: [
"react",
"react-dom",

1452
yarn.lock

File diff suppressed because it is too large Load Diff