Compare commits
119 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d7f52d2d6 | ||
|
|
d77d953f84 | ||
|
|
6456a6b0c0 | ||
|
|
996c5f86c1 | ||
|
|
daeecc9b68 | ||
|
|
982398b32f | ||
|
|
fae4c504c9 | ||
|
|
b4a56f6dd7 | ||
|
|
034552a063 | ||
|
|
bb505273f4 | ||
|
|
f876df6acc | ||
|
|
d097223d41 | ||
|
|
d01f7be58a | ||
|
|
cc7a44dc17 | ||
|
|
873e68e1e1 | ||
|
|
4f44a68198 | ||
|
|
1eab957d85 | ||
|
|
4c145af7a3 | ||
|
|
c1e45c4a30 | ||
|
|
5784a005dc | ||
|
|
a3e4d6998f | ||
|
|
32907764b3 | ||
|
|
cb34b1634d | ||
|
|
5199fd2566 | ||
|
|
b31c6c6780 | ||
|
|
aeec2c076e | ||
|
|
8bbce188ef | ||
|
|
dbdc010764 | ||
|
|
a81c48cc22 | ||
|
|
6eb77b7c2f | ||
|
|
92a50fe51d | ||
|
|
572caf6826 | ||
|
|
b0c8ceb302 | ||
|
|
c9ae6532a0 | ||
|
|
e5cfcb601b | ||
|
|
2b92bf3694 | ||
|
|
cd42d09ea9 | ||
|
|
c56b1c8a86 | ||
|
|
e8d99e15f7 | ||
|
|
4dcec504ca | ||
|
|
1308e52e42 | ||
|
|
f6d356c5ce | ||
|
|
eb2de869b8 | ||
|
|
c6030d33ca | ||
|
|
655058a7e6 | ||
|
|
16d4ffbe3a | ||
|
|
775125c8a7 | ||
|
|
631e63a0b5 | ||
|
|
4cb2306de0 | ||
|
|
f15ee439a9 | ||
|
|
b9a2473d19 | ||
|
|
5b58223f9d | ||
|
|
f34fd0bd00 | ||
|
|
984b02700e | ||
|
|
e310392800 | ||
|
|
2cc291dccd | ||
|
|
2dcf043787 | ||
|
|
6b03ae0dc3 | ||
|
|
5dd5668389 | ||
|
|
8380894692 | ||
|
|
94f16b986a | ||
|
|
2928df8b8c | ||
|
|
71a819fcf0 | ||
|
|
713136672a | ||
|
|
f1bd47be8c | ||
|
|
2e82960ae6 | ||
|
|
a31fcd7346 | ||
|
|
4a1a53d3ab | ||
|
|
be173a838d | ||
|
|
623bd52e1f | ||
|
|
5ebdf3e878 | ||
|
|
761eee2cdc | ||
|
|
831e49919b | ||
|
|
6d90586aee | ||
|
|
a7f0ade83a | ||
|
|
c49e300247 | ||
|
|
6d8e34762e | ||
|
|
33461f5ac2 | ||
|
|
4e3345482f | ||
|
|
7dc6fb27ea | ||
|
|
5ced94755b | ||
|
|
0ffd860fdb | ||
|
|
05e786e3d6 | ||
|
|
122ffeeab5 | ||
|
|
1448eac7c1 | ||
|
|
f2dbd5ff96 | ||
|
|
dcae5ad5f2 | ||
|
|
9bd3ade93d | ||
|
|
22dcb883b3 | ||
|
|
2e945780de | ||
|
|
9033b688ab | ||
|
|
1d4ed6609d | ||
|
|
b0269e310f | ||
|
|
74ccf7d820 | ||
|
|
2eae6243bb | ||
|
|
276532e2e1 | ||
|
|
fc07dd2af9 | ||
|
|
989712c2d5 | ||
|
|
ee43fcc91f | ||
|
|
18ca92cec4 | ||
|
|
dc11814695 | ||
|
|
17a31e0904 | ||
|
|
f990530031 | ||
|
|
46f1f0f8e9 | ||
|
|
885e933948 | ||
|
|
9b2e99c559 | ||
|
|
60ed54d6d3 | ||
|
|
939398b277 | ||
|
|
d2c820f080 | ||
|
|
375578177b | ||
|
|
eb9f2ccbaa | ||
|
|
d4b211e678 | ||
|
|
9fc4fbc3e7 | ||
|
|
1f5ac411f6 | ||
|
|
0aa29f775c | ||
|
|
a4a6105bc9 | ||
|
|
23098131b8 | ||
|
|
190c57e853 | ||
|
|
785eca7289 |
@@ -22,6 +22,7 @@
|
|||||||
# VITE_THEME_PRIMARY_CONTENT=#ffffff
|
# VITE_THEME_PRIMARY_CONTENT=#ffffff
|
||||||
# VITE_THEME_SECONDARY_CONTENT=#a9b2bc
|
# VITE_THEME_SECONDARY_CONTENT=#a9b2bc
|
||||||
# VITE_THEME_TERTIARY_CONTENT=#8e99a4
|
# VITE_THEME_TERTIARY_CONTENT=#8e99a4
|
||||||
|
# VITE_THEME_TERTIARY_CONTENT_20=#8e99a433
|
||||||
# VITE_THEME_QUATERNARY_CONTENT=#6f7882
|
# VITE_THEME_QUATERNARY_CONTENT=#6f7882
|
||||||
# VITE_THEME_QUINARY_CONTENT=#394049
|
# VITE_THEME_QUINARY_CONTENT=#394049
|
||||||
# VITE_THEME_SYSTEM=#21262c
|
# VITE_THEME_SYSTEM=#21262c
|
||||||
67
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
67
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Create a report to help us improve
|
||||||
|
labels: [T-Defect]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to fill out this bug report!
|
||||||
|
|
||||||
|
Please report security issues by email to security@matrix.org
|
||||||
|
- type: textarea
|
||||||
|
id: reproduction-steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: Please attach screenshots, videos or logs if you can.
|
||||||
|
placeholder: Tell us what you see!
|
||||||
|
value: |
|
||||||
|
1. Where are you starting? What can you see?
|
||||||
|
2. What do you click?
|
||||||
|
3. More steps…
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: result
|
||||||
|
attributes:
|
||||||
|
label: Outcome
|
||||||
|
placeholder: Tell us what went wrong
|
||||||
|
value: |
|
||||||
|
#### What did you expect?
|
||||||
|
|
||||||
|
#### What happened instead?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: Operating system
|
||||||
|
placeholder: Windows, macOS, Ubuntu, Android…
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: input
|
||||||
|
id: browser
|
||||||
|
attributes:
|
||||||
|
label: Browser information
|
||||||
|
description: Which browser are you using? Which version?
|
||||||
|
placeholder: e.g. Chromium Version 92.0.4515.131
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: input
|
||||||
|
id: webapp-url
|
||||||
|
attributes:
|
||||||
|
label: URL for webapp
|
||||||
|
description: Which URL are you using to access the webapp? If a private server, tell us what version of Element Call you are using.
|
||||||
|
placeholder: e.g. call.element.io
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: dropdown
|
||||||
|
id: rageshake
|
||||||
|
attributes:
|
||||||
|
label: Will you send logs?
|
||||||
|
description: |
|
||||||
|
To send them, press the 'Submit Feedback' button and check 'Include Debug Logs'. Please link to this issue in the description field.
|
||||||
|
options:
|
||||||
|
- 'Yes'
|
||||||
|
- 'No'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Questions & support
|
||||||
|
url: https://matrix.to/#/#webrtc:matrix.org
|
||||||
|
about: Please ask and answer questions here.
|
||||||
36
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
36
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: Enhancement request
|
||||||
|
description: Do you have a suggestion or feature request?
|
||||||
|
labels: [T-Enhancement]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thank you for taking the time to propose a new feature or make a suggestion.
|
||||||
|
- type: textarea
|
||||||
|
id: usecase
|
||||||
|
attributes:
|
||||||
|
label: Your use case
|
||||||
|
description: What would you like to be able to do? Please feel welcome to include screenshots or mock ups.
|
||||||
|
placeholder: Tell us what you would like to do!
|
||||||
|
value: |
|
||||||
|
#### What would you like to do?
|
||||||
|
|
||||||
|
#### Why would you like to do it?
|
||||||
|
|
||||||
|
#### How would you like to achieve it?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternative
|
||||||
|
attributes:
|
||||||
|
label: Have you considered any alternatives?
|
||||||
|
placeholder: A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
placeholder: Is there anything else you'd like to add?
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.env
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|||||||
18
.vscode/settings.json
vendored
18
.vscode/settings.json
vendored
@@ -2,5 +2,21 @@
|
|||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.insertSpaces": true,
|
"editor.insertSpaces": true,
|
||||||
"editor.tabSize": 2
|
"editor.tabSize": 2,
|
||||||
|
"[typescriptreact]": {
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
},
|
||||||
|
"[javascriptreact]": {
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
},
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ git clone https://github.com/vector-im/element-call.git
|
|||||||
cd element-call
|
cd element-call
|
||||||
yarn
|
yarn
|
||||||
yarn link matrix-js-sdk
|
yarn link matrix-js-sdk
|
||||||
|
cp .env.example .env
|
||||||
yarn dev
|
yarn dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -33,15 +33,16 @@
|
|||||||
"@sentry/react": "^6.13.3",
|
"@sentry/react": "^6.13.3",
|
||||||
"@sentry/tracing": "^6.13.3",
|
"@sentry/tracing": "^6.13.3",
|
||||||
"@types/grecaptcha": "^3.0.4",
|
"@types/grecaptcha": "^3.0.4",
|
||||||
|
"@types/sdp-transform": "^2.4.5",
|
||||||
"@use-gesture/react": "^10.2.11",
|
"@use-gesture/react": "^10.2.11",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"color-hash": "^2.0.1",
|
"color-hash": "^2.0.1",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#5e766978b8cf80d943f796df1067722a6a5918a7",
|
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#984dd26a138411ef73903ff4e635f2752e0829f2",
|
||||||
"mermaid": "^8.13.8",
|
"mermaid": "^8.13.8",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
"pako": "^2.0.4",
|
"pako": "^2.0.4",
|
||||||
"postcss-preset-env": "^6.7.0",
|
"postcss-preset-env": "^7",
|
||||||
"re-resizable": "^6.9.0",
|
"re-resizable": "^6.9.0",
|
||||||
"react": "^17.0.0",
|
"react": "^17.0.0",
|
||||||
"react-dom": "^17.0.0",
|
"react-dom": "^17.0.0",
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"react-use-clipboard": "^1.0.7",
|
"react-use-clipboard": "^1.0.7",
|
||||||
"react-use-measure": "^2.1.1",
|
"react-use-measure": "^2.1.1",
|
||||||
|
"sdp-transform": "^2.14.1",
|
||||||
"unique-names-generator": "^4.6.0"
|
"unique-names-generator": "^4.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
6
src/@types/global.d.ts
vendored
6
src/@types/global.d.ts
vendored
@@ -21,4 +21,10 @@ declare global {
|
|||||||
// TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
|
// TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
|
||||||
OLM_OPTIONS: Record<string, string>;
|
OLM_OPTIONS: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TypeScript doesn't know about the experimental setSinkId method, so we
|
||||||
|
// declare it ourselves
|
||||||
|
interface MediaElement extends HTMLMediaElement {
|
||||||
|
setSinkId: (id: string) => void;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,11 @@ type ClientProviderState = Omit<
|
|||||||
"changePassword" | "logout" | "setClient"
|
"changePassword" | "logout" | "setClient"
|
||||||
> & { error?: Error };
|
> & { error?: Error };
|
||||||
|
|
||||||
export const ClientProvider: FC = ({ children }) => {
|
interface Props {
|
||||||
|
children: JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClientProvider: FC<Props> = ({ children }) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const [
|
const [
|
||||||
{ loading, isAuthenticated, isPasswordlessUser, client, userName, error },
|
{ loading, isAuthenticated, isPasswordlessUser, client, userName, error },
|
||||||
@@ -97,12 +101,15 @@ export const ClientProvider: FC = ({ children }) => {
|
|||||||
const { user_id, device_id, access_token, passwordlessUser } =
|
const { user_id, device_id, access_token, passwordlessUser } =
|
||||||
session;
|
session;
|
||||||
|
|
||||||
const client = await initClient({
|
const client = await initClient(
|
||||||
baseUrl: defaultHomeserver,
|
{
|
||||||
accessToken: access_token,
|
baseUrl: defaultHomeserver,
|
||||||
userId: user_id,
|
accessToken: access_token,
|
||||||
deviceId: device_id,
|
userId: user_id,
|
||||||
});
|
deviceId: device_id,
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
/* eslint-enable camelcase */
|
/* eslint-enable camelcase */
|
||||||
|
|
||||||
return { client, isPasswordlessUser: passwordlessUser };
|
return { client, isPasswordlessUser: passwordlessUser };
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ export function Facepile({
|
|||||||
<div
|
<div
|
||||||
className={classNames(styles.facepile, styles[size], className)}
|
className={classNames(styles.facepile, styles[size], className)}
|
||||||
title={participants.map((member) => member.name).join(", ")}
|
title={participants.map((member) => member.name).join(", ")}
|
||||||
style={{ width: participants.length * (_size - _overlap) + _overlap }}
|
style={{
|
||||||
|
width:
|
||||||
|
Math.min(participants.length, max + 1) * (_size - _overlap) +
|
||||||
|
_overlap,
|
||||||
|
}}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{participants.slice(0, max).map((member, i) => {
|
{participants.slice(0, max).map((member, i) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import React, { useRef } from "react";
|
import React, { useCallback, useRef } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import styles from "./Header.module.css";
|
import styles from "./Header.module.css";
|
||||||
import { ReactComponent as Logo } from "./icons/Logo.svg";
|
import { ReactComponent as Logo } from "./icons/Logo.svg";
|
||||||
@@ -8,6 +8,9 @@ import { ReactComponent as ArrowLeftIcon } from "./icons/ArrowLeft.svg";
|
|||||||
import { useButton } from "@react-aria/button";
|
import { useButton } from "@react-aria/button";
|
||||||
import { Subtitle } from "./typography/Typography";
|
import { Subtitle } from "./typography/Typography";
|
||||||
import { Avatar } from "./Avatar";
|
import { Avatar } from "./Avatar";
|
||||||
|
import { IncompatibleVersionModal } from "./IncompatibleVersionModal";
|
||||||
|
import { useModalTriggerState } from "./Modal";
|
||||||
|
import { Button } from "./button";
|
||||||
|
|
||||||
export function Header({ children, className, ...rest }) {
|
export function Header({ children, className, ...rest }) {
|
||||||
return (
|
return (
|
||||||
@@ -74,9 +77,23 @@ export function RoomHeaderInfo({ roomName, avatarUrl }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoomSetupHeaderInfo({ roomName, avatarUrl, ...rest }) {
|
export function RoomSetupHeaderInfo({
|
||||||
|
roomName,
|
||||||
|
avatarUrl,
|
||||||
|
isEmbedded,
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
const ref = useRef();
|
const ref = useRef();
|
||||||
const { buttonProps } = useButton(rest, ref);
|
const { buttonProps } = useButton(rest, ref);
|
||||||
|
|
||||||
|
if (isEmbedded) {
|
||||||
|
return (
|
||||||
|
<div ref={ref}>
|
||||||
|
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button className={styles.backButton} ref={ref} {...buttonProps}>
|
<button className={styles.backButton} ref={ref} {...buttonProps}>
|
||||||
<ArrowLeftIcon width={16} height={16} />
|
<ArrowLeftIcon width={16} height={16} />
|
||||||
@@ -84,3 +101,25 @@ export function RoomSetupHeaderInfo({ roomName, avatarUrl, ...rest }) {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function VersionMismatchWarning({ users, room }) {
|
||||||
|
const { modalState, modalProps } = useModalTriggerState();
|
||||||
|
|
||||||
|
const onDetailsClick = useCallback(() => {
|
||||||
|
modalState.open();
|
||||||
|
}, [modalState]);
|
||||||
|
|
||||||
|
if (users.size === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={styles.versionMismatchWarning}>
|
||||||
|
Incomaptible versions!
|
||||||
|
<Button variant="link" onClick={onDetailsClick}>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
{modalState.isOpen && (
|
||||||
|
<IncompatibleVersionModal userIds={users} room={room} {...modalProps} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,6 +104,24 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.versionMismatchWarning {
|
||||||
|
padding-left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versionMismatchWarning::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
mask-image: url("./icons/AlertTriangleFilled.svg");
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
background-color: var(--alert);
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 800px) {
|
@media (min-width: 800px) {
|
||||||
.headerLogo,
|
.headerLogo,
|
||||||
.roomAvatar,
|
.roomAvatar,
|
||||||
|
|||||||
48
src/IncompatibleVersionModal.tsx
Normal file
48
src/IncompatibleVersionModal.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
Copyright 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Room } from "matrix-js-sdk";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { Modal, ModalContent } from "./Modal";
|
||||||
|
import { Body } from "./typography/Typography";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userIds: Set<string>;
|
||||||
|
room: Room;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IncompatibleVersionModal: React.FC<Props> = ({
|
||||||
|
userIds,
|
||||||
|
room,
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
|
const userLis = Array.from(userIds).map((u) => (
|
||||||
|
<li>{room.getMember(u).name}</li>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Incompatible Versions" isDismissable {...rest}>
|
||||||
|
<ModalContent>
|
||||||
|
<Body>
|
||||||
|
Other users are trying to join this call from incompatible versions.
|
||||||
|
These users should ensure that they have refreshed their browsers:
|
||||||
|
<ul>{userLis}</ul>
|
||||||
|
</Body>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
adjectives,
|
adjectives,
|
||||||
colors,
|
colors,
|
||||||
animals,
|
animals,
|
||||||
Config,
|
|
||||||
} from "unique-names-generator";
|
} from "unique-names-generator";
|
||||||
|
|
||||||
const elements = [
|
const elements = [
|
||||||
@@ -143,12 +142,11 @@ const elements = [
|
|||||||
"oganesson",
|
"oganesson",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function generateRandomName(config: Config): string {
|
export function generateRandomName(): string {
|
||||||
return uniqueNamesGenerator({
|
return uniqueNamesGenerator({
|
||||||
dictionaries: [colors, adjectives, animals, elements],
|
dictionaries: [colors, adjectives, animals, elements],
|
||||||
style: "lowerCase",
|
style: "lowerCase",
|
||||||
length: 3,
|
length: 3,
|
||||||
separator: "-",
|
separator: "-",
|
||||||
...config,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,12 +57,15 @@ export const useInteractiveLogin = () =>
|
|||||||
passwordlessUser: false,
|
passwordlessUser: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const client = await initClient({
|
const client = await initClient(
|
||||||
baseUrl: defaultHomeserver,
|
{
|
||||||
accessToken: access_token,
|
baseUrl: defaultHomeserver,
|
||||||
userId: user_id,
|
accessToken: access_token,
|
||||||
deviceId: device_id,
|
userId: user_id,
|
||||||
});
|
deviceId: device_id,
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
/* eslint-enable camelcase */
|
/* eslint-enable camelcase */
|
||||||
|
|
||||||
return [client, session];
|
return [client, session];
|
||||||
|
|||||||
@@ -90,12 +90,15 @@ export const useInteractiveRegistration = (): [
|
|||||||
const { user_id, access_token, device_id } =
|
const { user_id, access_token, device_id } =
|
||||||
(await interactiveAuth.attemptAuth()) as any;
|
(await interactiveAuth.attemptAuth()) as any;
|
||||||
|
|
||||||
const client = await initClient({
|
const client = await initClient(
|
||||||
baseUrl: defaultHomeserver,
|
{
|
||||||
accessToken: access_token,
|
baseUrl: defaultHomeserver,
|
||||||
userId: user_id,
|
accessToken: access_token,
|
||||||
deviceId: device_id,
|
userId: user_id,
|
||||||
});
|
deviceId: device_id,
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
await client.setDisplayName(displayName);
|
await client.setDisplayName(displayName);
|
||||||
|
|
||||||
|
|||||||
59
src/auth/useRegisterPasswordlessUser.ts
Normal file
59
src/auth/useRegisterPasswordlessUser.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||||
|
|
||||||
|
import { useClient } from "../ClientContext";
|
||||||
|
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
||||||
|
import { generateRandomName } from "../auth/generateRandomName";
|
||||||
|
import { useRecaptcha } from "../auth/useRecaptcha";
|
||||||
|
|
||||||
|
export interface UseRegisterPasswordlessUserType {
|
||||||
|
privacyPolicyUrl: string;
|
||||||
|
registerPasswordlessUser: (displayName: string) => Promise<void>;
|
||||||
|
recaptchaId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||||
|
const { setClient } = useClient();
|
||||||
|
const [privacyPolicyUrl, recaptchaKey, register] =
|
||||||
|
useInteractiveRegistration();
|
||||||
|
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||||
|
|
||||||
|
const registerPasswordlessUser = useCallback(
|
||||||
|
async (displayName: string) => {
|
||||||
|
try {
|
||||||
|
const recaptchaResponse = await execute();
|
||||||
|
const userName = generateRandomName();
|
||||||
|
const [client, session] = await register(
|
||||||
|
userName,
|
||||||
|
randomString(16),
|
||||||
|
displayName,
|
||||||
|
recaptchaResponse,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
setClient(client, session);
|
||||||
|
} catch (e) {
|
||||||
|
reset();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[execute, reset, register, setClient]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
|
||||||
|
}
|
||||||
@@ -207,3 +207,10 @@ limitations under the License.
|
|||||||
.lg {
|
.lg {
|
||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.linkButton {
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||||||
See the License for the specific language governing permissions and
|
See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef } from "react";
|
||||||
|
import { PressEvent } from "@react-types/shared";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { useButton } from "@react-aria/button";
|
||||||
|
import { mergeProps, useObjectRef } from "@react-aria/utils";
|
||||||
|
|
||||||
import styles from "./Button.module.css";
|
import styles from "./Button.module.css";
|
||||||
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
|
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
|
||||||
import { ReactComponent as MuteMicIcon } from "../icons/MuteMic.svg";
|
import { ReactComponent as MuteMicIcon } from "../icons/MuteMic.svg";
|
||||||
@@ -26,10 +29,21 @@ import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg";
|
|||||||
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
|
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
|
||||||
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
|
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
|
||||||
import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
|
import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
|
||||||
import { useButton } from "@react-aria/button";
|
|
||||||
import { mergeProps, useObjectRef } from "@react-aria/utils";
|
|
||||||
import { TooltipTrigger } from "../Tooltip";
|
import { TooltipTrigger } from "../Tooltip";
|
||||||
|
|
||||||
|
export type ButtonVariant =
|
||||||
|
| "default"
|
||||||
|
| "toolbar"
|
||||||
|
| "toolbarSecondary"
|
||||||
|
| "icon"
|
||||||
|
| "secondary"
|
||||||
|
| "copy"
|
||||||
|
| "secondaryCopy"
|
||||||
|
| "iconCopy"
|
||||||
|
| "secondaryHangup"
|
||||||
|
| "dropdown"
|
||||||
|
| "link";
|
||||||
|
|
||||||
export const variantToClassName = {
|
export const variantToClassName = {
|
||||||
default: [styles.button],
|
default: [styles.button],
|
||||||
toolbar: [styles.toolbarButton],
|
toolbar: [styles.toolbarButton],
|
||||||
@@ -41,13 +55,27 @@ export const variantToClassName = {
|
|||||||
iconCopy: [styles.iconCopyButton],
|
iconCopy: [styles.iconCopyButton],
|
||||||
secondaryHangup: [styles.secondaryHangup],
|
secondaryHangup: [styles.secondaryHangup],
|
||||||
dropdown: [styles.dropdownButton],
|
dropdown: [styles.dropdownButton],
|
||||||
|
link: [styles.linkButton],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sizeToClassName = {
|
export type ButtonSize = "lg";
|
||||||
|
|
||||||
|
export const sizeToClassName: { lg: string[] } = {
|
||||||
lg: [styles.lg],
|
lg: [styles.lg],
|
||||||
};
|
};
|
||||||
|
interface Props {
|
||||||
export const Button = forwardRef(
|
variant: ButtonVariant;
|
||||||
|
size: ButtonSize;
|
||||||
|
on: () => void;
|
||||||
|
off: () => void;
|
||||||
|
iconStyle: string;
|
||||||
|
className: string;
|
||||||
|
children: Element[];
|
||||||
|
onPress: (e: PressEvent) => void;
|
||||||
|
onPressStart: (e: PressEvent) => void;
|
||||||
|
[index: string]: unknown;
|
||||||
|
}
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, Props>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
variant = "default",
|
variant = "default",
|
||||||
@@ -63,7 +91,7 @@ export const Button = forwardRef(
|
|||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const buttonRef = useObjectRef(ref);
|
const buttonRef = useObjectRef<HTMLButtonElement>(ref);
|
||||||
const { buttonProps } = useButton(
|
const { buttonProps } = useButton(
|
||||||
{ onPress, onPressStart, ...rest },
|
{ onPress, onPressStart, ...rest },
|
||||||
buttonRef
|
buttonRef
|
||||||
@@ -74,7 +102,7 @@ export const Button = forwardRef(
|
|||||||
let filteredButtonProps = buttonProps;
|
let filteredButtonProps = buttonProps;
|
||||||
|
|
||||||
if (rest.type === "submit" && !rest.onPress) {
|
if (rest.type === "submit" && !rest.onPress) {
|
||||||
const { onKeyDown, onKeyUp, ...filtered } = buttonProps;
|
const { ...filtered } = buttonProps;
|
||||||
filteredButtonProps = filtered;
|
filteredButtonProps = filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,14 +121,22 @@ export const Button = forwardRef(
|
|||||||
{...mergeProps(rest, filteredButtonProps)}
|
{...mergeProps(rest, filteredButtonProps)}
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
>
|
>
|
||||||
{children}
|
<>
|
||||||
{variant === "dropdown" && <ArrowDownIcon />}
|
{children}
|
||||||
|
{variant === "dropdown" && <ArrowDownIcon />}
|
||||||
|
</>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export function MicButton({ muted, ...rest }) {
|
export function MicButton({
|
||||||
|
muted,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
muted: boolean;
|
||||||
|
[index: string]: unknown;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<Button variant="toolbar" {...rest} off={muted}>
|
<Button variant="toolbar" {...rest} off={muted}>
|
||||||
@@ -111,7 +147,13 @@ export function MicButton({ muted, ...rest }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoButton({ muted, ...rest }) {
|
export function VideoButton({
|
||||||
|
muted,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
muted: boolean;
|
||||||
|
[index: string]: unknown;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<Button variant="toolbar" {...rest} off={muted}>
|
<Button variant="toolbar" {...rest} off={muted}>
|
||||||
@@ -122,7 +164,15 @@ export function VideoButton({ muted, ...rest }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScreenshareButton({ enabled, className, ...rest }) {
|
export function ScreenshareButton({
|
||||||
|
enabled,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
enabled: boolean;
|
||||||
|
className?: string;
|
||||||
|
[index: string]: unknown;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<Button variant="toolbarSecondary" {...rest} on={enabled}>
|
<Button variant="toolbarSecondary" {...rest} on={enabled}>
|
||||||
@@ -133,7 +183,13 @@ export function ScreenshareButton({ enabled, className, ...rest }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HangupButton({ className, ...rest }) {
|
export function HangupButton({
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
[index: string]: unknown;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<Button
|
<Button
|
||||||
@@ -148,7 +204,13 @@ export function HangupButton({ className, ...rest }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsButton({ className, ...rest }) {
|
export function SettingsButton({
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
[index: string]: unknown;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<Button variant="toolbar" {...rest}>
|
<Button variant="toolbar" {...rest}>
|
||||||
@@ -159,7 +221,13 @@ export function SettingsButton({ className, ...rest }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InviteButton({ className, ...rest }) {
|
export function InviteButton({
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
[index: string]: unknown;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<Button variant="toolbar" {...rest}>
|
<Button variant="toolbar" {...rest}>
|
||||||
@@ -16,10 +16,18 @@ limitations under the License.
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import useClipboard from "react-use-clipboard";
|
import useClipboard from "react-use-clipboard";
|
||||||
|
|
||||||
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
|
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
|
||||||
import { ReactComponent as CopyIcon } from "../icons/Copy.svg";
|
import { ReactComponent as CopyIcon } from "../icons/Copy.svg";
|
||||||
import { Button } from "./Button";
|
import { Button, ButtonVariant } from "./Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
children: JSX.Element;
|
||||||
|
className: string;
|
||||||
|
variant: ButtonVariant;
|
||||||
|
copiedMessage: string;
|
||||||
|
}
|
||||||
export function CopyButton({
|
export function CopyButton({
|
||||||
value,
|
value,
|
||||||
children,
|
children,
|
||||||
@@ -27,7 +35,7 @@ export function CopyButton({
|
|||||||
variant,
|
variant,
|
||||||
copiedMessage,
|
copiedMessage,
|
||||||
...rest
|
...rest
|
||||||
}) {
|
}: Props) {
|
||||||
const [isCopied, setCopied] = useClipboard(value, { successDuration: 3000 });
|
const [isCopied, setCopied] = useClipboard(value, { successDuration: 3000 });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -17,9 +17,28 @@ limitations under the License.
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { variantToClassName, sizeToClassName } from "./Button";
|
|
||||||
|
|
||||||
export function LinkButton({ className, variant, size, children, ...rest }) {
|
import {
|
||||||
|
variantToClassName,
|
||||||
|
sizeToClassName,
|
||||||
|
ButtonVariant,
|
||||||
|
ButtonSize,
|
||||||
|
} from "./Button";
|
||||||
|
interface Props {
|
||||||
|
className: string;
|
||||||
|
variant: ButtonVariant;
|
||||||
|
size: ButtonSize;
|
||||||
|
children: JSX.Element;
|
||||||
|
[index: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkButton({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
className={classNames(
|
className={classNames(
|
||||||
3
src/icons/AlertTriangleFilled.svg
Normal file
3
src/icons/AlertTriangleFilled.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2.47012 18H17.5301C19.0701 18 20.0301 16.33 19.2601 15L11.7301 1.98999C10.9601 0.659993 9.04012 0.659993 8.27012 1.98999L0.740121 15C-0.0298788 16.33 0.930121 18 2.47012 18ZM10.0001 11C9.45012 11 9.00012 10.55 9.00012 9.99999V7.99999C9.00012 7.44999 9.45012 6.99999 10.0001 6.99999C10.5501 6.99999 11.0001 7.44999 11.0001 7.99999V9.99999C11.0001 10.55 10.5501 11 10.0001 11ZM11.0001 15H9.00012V13H11.0001V15Z" fill="#737D8C"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 540 B |
@@ -33,6 +33,7 @@ limitations under the License.
|
|||||||
--primary-content: #ffffff;
|
--primary-content: #ffffff;
|
||||||
--secondary-content: #a9b2bc;
|
--secondary-content: #a9b2bc;
|
||||||
--tertiary-content: #8e99a4;
|
--tertiary-content: #8e99a4;
|
||||||
|
--tertiary-content-20: #8e99a433;
|
||||||
--quaternary-content: #6f7882;
|
--quaternary-content: #6f7882;
|
||||||
--quinary-content: #394049;
|
--quinary-content: #394049;
|
||||||
--system: #21262c;
|
--system: #21262c;
|
||||||
|
|||||||
12
src/main.tsx
12
src/main.tsx
@@ -36,6 +36,14 @@ initRageshake();
|
|||||||
|
|
||||||
console.info(`matrix-video-chat ${import.meta.env.VITE_APP_VERSION || "dev"}`);
|
console.info(`matrix-video-chat ${import.meta.env.VITE_APP_VERSION || "dev"}`);
|
||||||
|
|
||||||
|
if (!window.isSecureContext) {
|
||||||
|
throw new Error(
|
||||||
|
"This app cannot run in an insecure context. To fix this, access the app " +
|
||||||
|
"via a local loopback address, or serve it over HTTPS.\n" +
|
||||||
|
"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (import.meta.env.VITE_CUSTOM_THEME) {
|
if (import.meta.env.VITE_CUSTOM_THEME) {
|
||||||
const style = document.documentElement.style;
|
const style = document.documentElement.style;
|
||||||
style.setProperty("--accent", import.meta.env.VITE_THEME_ACCENT as string);
|
style.setProperty("--accent", import.meta.env.VITE_THEME_ACCENT as string);
|
||||||
@@ -61,6 +69,10 @@ if (import.meta.env.VITE_CUSTOM_THEME) {
|
|||||||
"--tertiary-content",
|
"--tertiary-content",
|
||||||
import.meta.env.VITE_THEME_TERTIARY_CONTENT as string
|
import.meta.env.VITE_THEME_TERTIARY_CONTENT as string
|
||||||
);
|
);
|
||||||
|
style.setProperty(
|
||||||
|
"--tertiary-content-20",
|
||||||
|
import.meta.env.VITE_THEME_TERTIARY_CONTENT_20 as string
|
||||||
|
);
|
||||||
style.setProperty(
|
style.setProperty(
|
||||||
"--quaternary-content",
|
"--quaternary-content",
|
||||||
import.meta.env.VITE_THEME_QUATERNARY_CONTENT as string
|
import.meta.env.VITE_THEME_QUATERNARY_CONTENT as string
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import Olm from "@matrix-org/olm";
|
import Olm from "@matrix-org/olm";
|
||||||
import olmWasmPath from "@matrix-org/olm/olm.wasm?url";
|
import olmWasmPath from "@matrix-org/olm/olm.wasm?url";
|
||||||
import { IndexedDBStore } from "matrix-js-sdk/src/store/indexeddb";
|
import { IndexedDBStore } from "matrix-js-sdk/src/store/indexeddb";
|
||||||
import { WebStorageSessionStore } from "matrix-js-sdk/src/store/session/webstorage";
|
|
||||||
import { MemoryStore } from "matrix-js-sdk/src/store/memory";
|
import { MemoryStore } from "matrix-js-sdk/src/store/memory";
|
||||||
import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store";
|
import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store";
|
||||||
|
import { LocalStorageCryptoStore } from "matrix-js-sdk/src/crypto/store/localStorage-crypto-store";
|
||||||
|
import { MemoryCryptoStore } from "matrix-js-sdk/src/crypto/store/memory-crypto-store";
|
||||||
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
|
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
import { ICreateClientOpts } from "matrix-js-sdk/src/matrix";
|
import { ICreateClientOpts } from "matrix-js-sdk/src/matrix";
|
||||||
import { ClientEvent } from "matrix-js-sdk/src/client";
|
import { ClientEvent } from "matrix-js-sdk/src/client";
|
||||||
import { Visibility, Preset } from "matrix-js-sdk/src/@types/partials";
|
import { Visibility, Preset } from "matrix-js-sdk/src/@types/partials";
|
||||||
import {
|
|
||||||
GroupCallIntent,
|
|
||||||
GroupCallType,
|
|
||||||
} from "matrix-js-sdk/src/webrtc/groupCall";
|
|
||||||
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
|
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
|
||||||
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import IndexedDBWorker from "./IndexedDBWorker?worker";
|
import IndexedDBWorker from "./IndexedDBWorker?worker";
|
||||||
|
|
||||||
@@ -22,6 +20,19 @@ export const defaultHomeserver =
|
|||||||
|
|
||||||
export const defaultHomeserverHost = new URL(defaultHomeserver).host;
|
export const defaultHomeserverHost = new URL(defaultHomeserver).host;
|
||||||
|
|
||||||
|
export class CryptoStoreIntegrityError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Crypto store data was expected, but none was found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYNC_STORE_NAME = "element-call-sync";
|
||||||
|
// Note that the crypto store name has changed from previous versions
|
||||||
|
// deliberately in order to force a logout for all users due to
|
||||||
|
// https://github.com/vector-im/element-call/issues/464
|
||||||
|
// (It's a good opportunity to make the database names consistent.)
|
||||||
|
const CRYPTO_STORE_NAME = "element-call-crypto";
|
||||||
|
|
||||||
function waitForSync(client: MatrixClient) {
|
function waitForSync(client: MatrixClient) {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const onSync = (
|
const onSync = (
|
||||||
@@ -41,8 +52,18 @@ function waitForSync(client: MatrixClient) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialises and returns a new Matrix Client
|
||||||
|
* If true is passed for the 'restore' parameter, a check will be made
|
||||||
|
* to ensure that corresponding crypto data is stored and recovered.
|
||||||
|
* If the check fails, CryptoStoreIntegrityError will be thrown.
|
||||||
|
* @param clientOptions Object of options passed through to the client
|
||||||
|
* @param restore Whether the session is being restored from storage
|
||||||
|
* @returns The MatrixClient instance
|
||||||
|
*/
|
||||||
export async function initClient(
|
export async function initClient(
|
||||||
clientOptions: ICreateClientOpts
|
clientOptions: ICreateClientOpts,
|
||||||
|
restore: boolean
|
||||||
): Promise<MatrixClient> {
|
): Promise<MatrixClient> {
|
||||||
// TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
|
// TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
|
||||||
window.OLM_OPTIONS = {};
|
window.OLM_OPTIONS = {};
|
||||||
@@ -59,21 +80,64 @@ export async function initClient(
|
|||||||
if (indexedDB && localStorage && !import.meta.env.DEV) {
|
if (indexedDB && localStorage && !import.meta.env.DEV) {
|
||||||
storeOpts.store = new IndexedDBStore({
|
storeOpts.store = new IndexedDBStore({
|
||||||
indexedDB: window.indexedDB,
|
indexedDB: window.indexedDB,
|
||||||
localStorage: window.localStorage,
|
localStorage,
|
||||||
dbName: "element-call-sync",
|
dbName: SYNC_STORE_NAME,
|
||||||
workerFactory: () => new IndexedDBWorker(),
|
workerFactory: () => new IndexedDBWorker(),
|
||||||
});
|
});
|
||||||
|
} else if (localStorage) {
|
||||||
|
storeOpts.store = new MemoryStore({ localStorage });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (localStorage) {
|
// Check whether we have crypto data store. If we are restoring a session
|
||||||
storeOpts.sessionStore = new WebStorageSessionStore(localStorage);
|
// from storage then we will have started the crypto store and therefore
|
||||||
|
// have generated keys for that device, so if we can't recover those keys,
|
||||||
|
// we must not continue or we'll generate new keys and anyone who saw our
|
||||||
|
// previous keys will not accept our new key.
|
||||||
|
// It's worth mentioning here that if support for indexeddb or localstorage
|
||||||
|
// appears or disappears between sessions (it happens) then the failure mode
|
||||||
|
// here will be that we'll try a different store, not find crypto data and
|
||||||
|
// fail to restore the session. An alternative would be to continue using
|
||||||
|
// whatever we were using before, but that could be confusing since you could
|
||||||
|
// enable indexeddb and but the app would still not be using it.
|
||||||
|
if (restore) {
|
||||||
|
if (indexedDB) {
|
||||||
|
const cryptoStoreExists = await IndexedDBCryptoStore.exists(
|
||||||
|
indexedDB,
|
||||||
|
CRYPTO_STORE_NAME
|
||||||
|
);
|
||||||
|
if (!cryptoStoreExists) throw new CryptoStoreIntegrityError();
|
||||||
|
} else if (localStorage) {
|
||||||
|
if (!LocalStorageCryptoStore.exists(localStorage))
|
||||||
|
throw new CryptoStoreIntegrityError();
|
||||||
|
} else {
|
||||||
|
// if we get here then we're using the memory store, which cannot
|
||||||
|
// possibly have remembered a session, so it's an error.
|
||||||
|
throw new CryptoStoreIntegrityError();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (indexedDB) {
|
if (indexedDB) {
|
||||||
storeOpts.cryptoStore = new IndexedDBCryptoStore(
|
storeOpts.cryptoStore = new IndexedDBCryptoStore(
|
||||||
indexedDB,
|
indexedDB,
|
||||||
"matrix-js-sdk:crypto"
|
CRYPTO_STORE_NAME
|
||||||
);
|
);
|
||||||
|
} else if (localStorage) {
|
||||||
|
storeOpts.cryptoStore = new LocalStorageCryptoStore(localStorage);
|
||||||
|
} else {
|
||||||
|
storeOpts.cryptoStore = new MemoryCryptoStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// XXX: we read from the URL search params in RoomPage too:
|
||||||
|
// it would be much better to read them in one place and pass
|
||||||
|
// the values around, but we initialise the matrix client in
|
||||||
|
// many different places so we'd have to pass it into all of
|
||||||
|
// them.
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
// disable e2e only if enableE2e=false is given
|
||||||
|
const enableE2e = params.get("enableE2e") !== "false";
|
||||||
|
|
||||||
|
if (!enableE2e) {
|
||||||
|
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = createClient({
|
const client = createClient({
|
||||||
@@ -83,6 +147,7 @@ export async function initClient(
|
|||||||
// Use a relatively low timeout for API calls: this is a realtime application
|
// Use a relatively low timeout for API calls: this is a realtime application
|
||||||
// so we don't want API calls taking ages, we'd rather they just fail.
|
// so we don't want API calls taking ages, we'd rather they just fail.
|
||||||
localTimeoutMs: 5000,
|
localTimeoutMs: 5000,
|
||||||
|
useE2eForGroupCall: enableE2e,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -154,10 +219,9 @@ export function isLocalRoomId(roomId: string): boolean {
|
|||||||
|
|
||||||
export async function createRoom(
|
export async function createRoom(
|
||||||
client: MatrixClient,
|
client: MatrixClient,
|
||||||
name: string,
|
name: string
|
||||||
isPtt = false
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const createRoomResult = await client.createRoom({
|
await client.createRoom({
|
||||||
visibility: Visibility.Private,
|
visibility: Visibility.Private,
|
||||||
preset: Preset.PublicChat,
|
preset: Preset.PublicChat,
|
||||||
name,
|
name,
|
||||||
@@ -187,15 +251,6 @@ export async function createRoom(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Creating ${isPtt ? "PTT" : "video"} group call room`);
|
|
||||||
|
|
||||||
await client.createGroupCall(
|
|
||||||
createRoomResult.room_id,
|
|
||||||
isPtt ? GroupCallType.Voice : GroupCallType.Video,
|
|
||||||
isPtt,
|
|
||||||
GroupCallIntent.Prompt
|
|
||||||
);
|
|
||||||
|
|
||||||
return fullAliasFromRoomName(name, client);
|
return fullAliasFromRoomName(name, client);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { MatrixClient } from "matrix-js-sdk";
|
||||||
|
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
import { useProfile } from "./useProfile";
|
import { useProfile } from "./useProfile";
|
||||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||||
@@ -22,7 +24,12 @@ import { Modal, ModalContent } from "../Modal";
|
|||||||
import { AvatarInputField } from "../input/AvatarInputField";
|
import { AvatarInputField } from "../input/AvatarInputField";
|
||||||
import styles from "./ProfileModal.module.css";
|
import styles from "./ProfileModal.module.css";
|
||||||
|
|
||||||
export function ProfileModal({ client, ...rest }) {
|
interface Props {
|
||||||
|
client: MatrixClient;
|
||||||
|
onClose: () => {};
|
||||||
|
[rest: string]: unknown;
|
||||||
|
}
|
||||||
|
export function ProfileModal({ client, ...rest }: Props) {
|
||||||
const { onClose } = rest;
|
const { onClose } = rest;
|
||||||
const {
|
const {
|
||||||
success,
|
success,
|
||||||
@@ -50,13 +57,20 @@ export function ProfileModal({ client, ...rest }) {
|
|||||||
(e) => {
|
(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const data = new FormData(e.target);
|
const data = new FormData(e.target);
|
||||||
const displayName = data.get("displayName");
|
const displayNameDataEntry = data.get("displayName");
|
||||||
const avatar = data.get("avatar");
|
const avatar: File | string = data.get("avatar");
|
||||||
|
|
||||||
|
const avatarSize =
|
||||||
|
typeof avatar == "string" ? avatar.length : avatar.size;
|
||||||
|
const displayName =
|
||||||
|
typeof displayNameDataEntry == "string"
|
||||||
|
? displayNameDataEntry
|
||||||
|
: displayNameDataEntry.name;
|
||||||
|
|
||||||
saveProfile({
|
saveProfile({
|
||||||
displayName,
|
displayName,
|
||||||
avatar: avatar && avatar.size > 0 ? avatar : undefined,
|
avatar: avatar && avatarSize > 0 ? avatar : undefined,
|
||||||
removeAvatar: removeAvatar && (!avatar || avatar.size === 0),
|
removeAvatar: removeAvatar && (!avatar || avatarSize === 0),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[saveProfile, removeAvatar]
|
[saveProfile, removeAvatar]
|
||||||
@@ -14,11 +14,33 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
|
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
|
import { User, UserEvent } from "matrix-js-sdk/src/models/user";
|
||||||
|
import { FileType } from "matrix-js-sdk/src/http-api";
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
|
||||||
export function useProfile(client) {
|
interface ProfileLoadState {
|
||||||
|
success?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileSaveCallback = ({
|
||||||
|
displayName,
|
||||||
|
avatar,
|
||||||
|
removeAvatar,
|
||||||
|
}: {
|
||||||
|
displayName: string;
|
||||||
|
avatar: FileType;
|
||||||
|
removeAvatar: boolean;
|
||||||
|
}) => Promise<void>;
|
||||||
|
|
||||||
|
export function useProfile(client: MatrixClient) {
|
||||||
const [{ loading, displayName, avatarUrl, error, success }, setState] =
|
const [{ loading, displayName, avatarUrl, error, success }, setState] =
|
||||||
useState(() => {
|
useState<ProfileLoadState>(() => {
|
||||||
const user = client?.getUser(client.getUserId());
|
const user = client?.getUser(client.getUserId());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -31,7 +53,10 @@ export function useProfile(client) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onChangeUser = (_event, { displayName, avatarUrl }) => {
|
const onChangeUser = (
|
||||||
|
_event: MatrixEvent,
|
||||||
|
{ displayName, avatarUrl }: User
|
||||||
|
) => {
|
||||||
setState({
|
setState({
|
||||||
success: false,
|
success: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -41,24 +66,24 @@ export function useProfile(client) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let user;
|
let user: User;
|
||||||
|
|
||||||
if (client) {
|
if (client) {
|
||||||
const userId = client.getUserId();
|
const userId = client.getUserId();
|
||||||
user = client.getUser(userId);
|
user = client.getUser(userId);
|
||||||
user.on("User.displayName", onChangeUser);
|
user.on(UserEvent.DisplayName, onChangeUser);
|
||||||
user.on("User.avatarUrl", onChangeUser);
|
user.on(UserEvent.AvatarUrl, onChangeUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
user.removeListener("User.displayName", onChangeUser);
|
user.removeListener(UserEvent.DisplayName, onChangeUser);
|
||||||
user.removeListener("User.avatarUrl", onChangeUser);
|
user.removeListener(UserEvent.AvatarUrl, onChangeUser);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [client]);
|
}, [client]);
|
||||||
|
|
||||||
const saveProfile = useCallback(
|
const saveProfile = useCallback<ProfileSaveCallback>(
|
||||||
async ({ displayName, avatar, removeAvatar }) => {
|
async ({ displayName, avatar, removeAvatar }) => {
|
||||||
if (client) {
|
if (client) {
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
@@ -71,7 +96,7 @@ export function useProfile(client) {
|
|||||||
try {
|
try {
|
||||||
await client.setDisplayName(displayName);
|
await client.setDisplayName(displayName);
|
||||||
|
|
||||||
let mxcAvatarUrl;
|
let mxcAvatarUrl: string;
|
||||||
|
|
||||||
if (removeAvatar) {
|
if (removeAvatar) {
|
||||||
await client.setAvatarUrl("");
|
await client.setAvatarUrl("");
|
||||||
@@ -87,11 +112,11 @@ export function useProfile(client) {
|
|||||||
loading: false,
|
loading: false,
|
||||||
success: true,
|
success: true,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
loading: false,
|
loading: false,
|
||||||
error,
|
error: error instanceof Error ? error : Error(error as string),
|
||||||
success: false,
|
success: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -102,5 +127,12 @@ export function useProfile(client) {
|
|||||||
[client]
|
[client]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { loading, error, displayName, avatarUrl, saveProfile, success };
|
return {
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
displayName,
|
||||||
|
avatarUrl,
|
||||||
|
saveProfile,
|
||||||
|
success,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -53,8 +53,12 @@ export function AudioPreview({
|
|||||||
onSelectionChange={setAudioInput}
|
onSelectionChange={setAudioInput}
|
||||||
className={styles.inputField}
|
className={styles.inputField}
|
||||||
>
|
>
|
||||||
{audioInputs.map(({ deviceId, label }) => (
|
{audioInputs.map(({ deviceId, label }, index) => (
|
||||||
<Item key={deviceId}>{label}</Item>
|
<Item key={deviceId}>
|
||||||
|
{!!label && label.trim().length > 0
|
||||||
|
? label
|
||||||
|
: `Microphone ${index + 1}`}
|
||||||
|
</Item>
|
||||||
))}
|
))}
|
||||||
</SelectInput>
|
</SelectInput>
|
||||||
{audioOutputs.length > 0 && (
|
{audioOutputs.length > 0 && (
|
||||||
@@ -64,8 +68,12 @@ export function AudioPreview({
|
|||||||
onSelectionChange={setAudioOutput}
|
onSelectionChange={setAudioOutput}
|
||||||
className={styles.inputField}
|
className={styles.inputField}
|
||||||
>
|
>
|
||||||
{audioOutputs.map(({ deviceId, label }) => (
|
{audioOutputs.map(({ deviceId, label }, index) => (
|
||||||
<Item key={deviceId}>{label}</Item>
|
<Item key={deviceId}>
|
||||||
|
{!!label && label.trim().length > 0
|
||||||
|
? label
|
||||||
|
: `Speaker ${index + 1}`}
|
||||||
|
</Item>
|
||||||
))}
|
))}
|
||||||
</SelectInput>
|
</SelectInput>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,12 +19,18 @@ import { useLoadGroupCall } from "./useLoadGroupCall";
|
|||||||
import { ErrorView, FullScreenView } from "../FullScreenView";
|
import { ErrorView, FullScreenView } from "../FullScreenView";
|
||||||
import { usePageTitle } from "../usePageTitle";
|
import { usePageTitle } from "../usePageTitle";
|
||||||
|
|
||||||
export function GroupCallLoader({ client, roomId, viaServers, children }) {
|
export function GroupCallLoader({
|
||||||
|
client,
|
||||||
|
roomId,
|
||||||
|
viaServers,
|
||||||
|
createPtt,
|
||||||
|
children,
|
||||||
|
}) {
|
||||||
const { loading, error, groupCall } = useLoadGroupCall(
|
const { loading, error, groupCall } = useLoadGroupCall(
|
||||||
client,
|
client,
|
||||||
roomId,
|
roomId,
|
||||||
viaServers,
|
viaServers,
|
||||||
true
|
createPtt
|
||||||
);
|
);
|
||||||
|
|
||||||
usePageTitle(groupCall ? groupCall.room.name : "Loading...");
|
usePageTitle(groupCall ? groupCall.room.name : "Loading...");
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { useLocationNavigation } from "../useLocationNavigation";
|
|||||||
export function GroupCallView({
|
export function GroupCallView({
|
||||||
client,
|
client,
|
||||||
isPasswordlessUser,
|
isPasswordlessUser,
|
||||||
|
isEmbedded,
|
||||||
roomId,
|
roomId,
|
||||||
groupCall,
|
groupCall,
|
||||||
}) {
|
}) {
|
||||||
@@ -53,13 +54,17 @@ export function GroupCallView({
|
|||||||
screenshareFeeds,
|
screenshareFeeds,
|
||||||
hasLocalParticipant,
|
hasLocalParticipant,
|
||||||
participants,
|
participants,
|
||||||
|
unencryptedEventsFromUsers,
|
||||||
} = useGroupCall(groupCall);
|
} = useGroupCall(groupCall);
|
||||||
|
|
||||||
const avatarUrl = useRoomAvatar(groupCall.room);
|
const avatarUrl = useRoomAvatar(groupCall.room);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.groupCall = groupCall;
|
window.groupCall = groupCall;
|
||||||
}, [groupCall]);
|
|
||||||
|
// In embedded mode, bypass the lobby and just enter the call straight away
|
||||||
|
if (isEmbedded) groupCall.enter();
|
||||||
|
}, [groupCall, isEmbedded]);
|
||||||
|
|
||||||
useSentryGroupCallHandler(groupCall);
|
useSentryGroupCallHandler(groupCall);
|
||||||
|
|
||||||
@@ -91,6 +96,7 @@ export function GroupCallView({
|
|||||||
participants={participants}
|
participants={participants}
|
||||||
userMediaFeeds={userMediaFeeds}
|
userMediaFeeds={userMediaFeeds}
|
||||||
onLeave={onLeave}
|
onLeave={onLeave}
|
||||||
|
isEmbedded={isEmbedded}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -112,6 +118,7 @@ export function GroupCallView({
|
|||||||
localScreenshareFeed={localScreenshareFeed}
|
localScreenshareFeed={localScreenshareFeed}
|
||||||
screenshareFeeds={screenshareFeeds}
|
screenshareFeeds={screenshareFeeds}
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
|
unencryptedEventsFromUsers={unencryptedEventsFromUsers}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -124,23 +131,32 @@ export function GroupCallView({
|
|||||||
} else if (left) {
|
} else if (left) {
|
||||||
return <CallEndedView client={client} />;
|
return <CallEndedView client={client} />;
|
||||||
} else {
|
} else {
|
||||||
return (
|
if (isEmbedded) {
|
||||||
<LobbyView
|
return (
|
||||||
client={client}
|
<FullScreenView>
|
||||||
groupCall={groupCall}
|
<h1>Loading room...</h1>
|
||||||
hasLocalParticipant={hasLocalParticipant}
|
</FullScreenView>
|
||||||
roomName={groupCall.room.name}
|
);
|
||||||
avatarUrl={avatarUrl}
|
} else {
|
||||||
state={state}
|
return (
|
||||||
onInitLocalCallFeed={initLocalCallFeed}
|
<LobbyView
|
||||||
localCallFeed={localCallFeed}
|
client={client}
|
||||||
onEnter={enter}
|
groupCall={groupCall}
|
||||||
microphoneMuted={microphoneMuted}
|
hasLocalParticipant={hasLocalParticipant}
|
||||||
localVideoMuted={localVideoMuted}
|
roomName={groupCall.room.name}
|
||||||
toggleLocalVideoMuted={toggleLocalVideoMuted}
|
avatarUrl={avatarUrl}
|
||||||
toggleMicrophoneMuted={toggleMicrophoneMuted}
|
state={state}
|
||||||
roomId={roomId}
|
onInitLocalCallFeed={initLocalCallFeed}
|
||||||
/>
|
localCallFeed={localCallFeed}
|
||||||
);
|
onEnter={enter}
|
||||||
|
microphoneMuted={microphoneMuted}
|
||||||
|
localVideoMuted={localVideoMuted}
|
||||||
|
toggleLocalVideoMuted={toggleLocalVideoMuted}
|
||||||
|
toggleMicrophoneMuted={toggleMicrophoneMuted}
|
||||||
|
roomId={roomId}
|
||||||
|
isEmbedded={isEmbedded}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,13 @@ import {
|
|||||||
VideoButton,
|
VideoButton,
|
||||||
ScreenshareButton,
|
ScreenshareButton,
|
||||||
} from "../button";
|
} from "../button";
|
||||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
import {
|
||||||
|
Header,
|
||||||
|
LeftNav,
|
||||||
|
RightNav,
|
||||||
|
RoomHeaderInfo,
|
||||||
|
VersionMismatchWarning,
|
||||||
|
} from "../Header";
|
||||||
import { VideoGrid, useVideoGridLayout } from "../video-grid/VideoGrid";
|
import { VideoGrid, useVideoGridLayout } from "../video-grid/VideoGrid";
|
||||||
import { VideoTileContainer } from "../video-grid/VideoTileContainer";
|
import { VideoTileContainer } from "../video-grid/VideoTileContainer";
|
||||||
import { GroupCallInspector } from "./GroupCallInspector";
|
import { GroupCallInspector } from "./GroupCallInspector";
|
||||||
@@ -36,8 +42,9 @@ import { usePreventScroll } from "@react-aria/overlays";
|
|||||||
import { useMediaHandler } from "../settings/useMediaHandler";
|
import { useMediaHandler } from "../settings/useMediaHandler";
|
||||||
import { useShowInspector } from "../settings/useSetting";
|
import { useShowInspector } from "../settings/useSetting";
|
||||||
import { useModalTriggerState } from "../Modal";
|
import { useModalTriggerState } from "../Modal";
|
||||||
|
import { useAudioContext } from "../video-grid/useMediaStream";
|
||||||
|
|
||||||
const canScreenshare = "getDisplayMedia" in navigator.mediaDevices;
|
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||||
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
|
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
|
||||||
// or with getUsermedia and getDisplaymedia being used within the same session.
|
// or with getUsermedia and getDisplaymedia being used within the same session.
|
||||||
// For now we can disable screensharing in Safari.
|
// For now we can disable screensharing in Safari.
|
||||||
@@ -59,16 +66,15 @@ export function InCallView({
|
|||||||
isScreensharing,
|
isScreensharing,
|
||||||
screenshareFeeds,
|
screenshareFeeds,
|
||||||
roomId,
|
roomId,
|
||||||
|
unencryptedEventsFromUsers,
|
||||||
}) {
|
}) {
|
||||||
usePreventScroll();
|
usePreventScroll();
|
||||||
const [layout, setLayout] = useVideoGridLayout(screenshareFeeds.length > 0);
|
const [layout, setLayout] = useVideoGridLayout(screenshareFeeds.length > 0);
|
||||||
|
|
||||||
|
const [audioContext, audioDestination, audioRef] = useAudioContext();
|
||||||
const { audioOutput } = useMediaHandler();
|
const { audioOutput } = useMediaHandler();
|
||||||
const [showInspector] = useShowInspector();
|
const [showInspector] = useShowInspector();
|
||||||
|
|
||||||
const audioContext = useRef();
|
|
||||||
if (!audioContext.current) audioContext.current = new AudioContext();
|
|
||||||
|
|
||||||
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
|
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
|
||||||
useModalTriggerState();
|
useModalTriggerState();
|
||||||
|
|
||||||
@@ -132,9 +138,14 @@ export function InCallView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.inRoom}>
|
<div className={styles.inRoom}>
|
||||||
|
<audio ref={audioRef} />
|
||||||
<Header>
|
<Header>
|
||||||
<LeftNav>
|
<LeftNav>
|
||||||
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
|
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
|
||||||
|
<VersionMismatchWarning
|
||||||
|
users={unencryptedEventsFromUsers}
|
||||||
|
room={groupCall.room}
|
||||||
|
/>
|
||||||
</LeftNav>
|
</LeftNav>
|
||||||
<RightNav>
|
<RightNav>
|
||||||
<GridLayoutMenu layout={layout} setLayout={setLayout} />
|
<GridLayoutMenu layout={layout} setLayout={setLayout} />
|
||||||
@@ -154,7 +165,8 @@ export function InCallView({
|
|||||||
getAvatar={renderAvatar}
|
getAvatar={renderAvatar}
|
||||||
showName={items.length > 2 || item.focused}
|
showName={items.length > 2 || item.focused}
|
||||||
audioOutputDevice={audioOutput}
|
audioOutputDevice={audioOutput}
|
||||||
audioContext={audioContext.current}
|
audioContext={audioContext}
|
||||||
|
audioDestination={audioDestination}
|
||||||
disableSpeakingIndicator={items.length < 3}
|
disableSpeakingIndicator={items.length < 3}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function LobbyView({
|
|||||||
toggleLocalVideoMuted,
|
toggleLocalVideoMuted,
|
||||||
toggleMicrophoneMuted,
|
toggleMicrophoneMuted,
|
||||||
roomId,
|
roomId,
|
||||||
|
isEmbedded,
|
||||||
}) {
|
}) {
|
||||||
const { stream } = useCallFeed(localCallFeed);
|
const { stream } = useCallFeed(localCallFeed);
|
||||||
const {
|
const {
|
||||||
@@ -122,11 +123,13 @@ export function LobbyView({
|
|||||||
Copy call link and join later
|
Copy call link and join later
|
||||||
</CopyButton>
|
</CopyButton>
|
||||||
</div>
|
</div>
|
||||||
<Body className={styles.joinRoomFooter}>
|
{!isEmbedded && (
|
||||||
<Link color="primary" to="/">
|
<Body className={styles.joinRoomFooter}>
|
||||||
Take me Home
|
<Link color="primary" to="/">
|
||||||
</Link>
|
Take me Home
|
||||||
</Body>
|
</Link>
|
||||||
|
</Body>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,39 @@
|
|||||||
.pttButton {
|
.pttButton {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
aspect-ratio: 1;
|
||||||
max-height: 232px;
|
max-height: min(232px, calc(100vh - 16px));
|
||||||
max-width: 232px;
|
max-width: min(232px, calc(100vw - 16px));
|
||||||
border-radius: 116px;
|
border-radius: 116px;
|
||||||
color: var(--primary-content);
|
color: var(--primary-content);
|
||||||
border: 6px solid var(--accent);
|
border: 6px solid var(--accent);
|
||||||
background-color: #21262c;
|
background-color: #21262c;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
margin: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.micIcon {
|
||||||
|
max-height: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
/* Remove explicit size to allow avatar to scale with the button */
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
.talking {
|
.talking {
|
||||||
background-color: var(--accent);
|
background-color: var(--accent);
|
||||||
cursor: unset;
|
cursor: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.networkWaiting {
|
||||||
|
background-color: var(--tertiary-content);
|
||||||
|
border-color: var(--tertiary-content);
|
||||||
|
cursor: unset;
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
background-color: var(--alert);
|
background-color: var(--alert);
|
||||||
border-color: var(--alert);
|
border-color: var(--alert);
|
||||||
|
|||||||
@@ -14,15 +14,17 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useState, createRef } from "react";
|
import React, { useCallback, useState, useRef } from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useSpring, animated } from "@react-spring/web";
|
import { useSpring, animated } from "@react-spring/web";
|
||||||
|
|
||||||
import styles from "./PTTButton.module.css";
|
import styles from "./PTTButton.module.css";
|
||||||
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
|
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
|
||||||
|
import { useEventTarget } from "../useEvents";
|
||||||
import { Avatar } from "../Avatar";
|
import { Avatar } from "../Avatar";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
enabled: boolean;
|
||||||
showTalkOverError: boolean;
|
showTalkOverError: boolean;
|
||||||
activeSpeakerUserId: string;
|
activeSpeakerUserId: string;
|
||||||
activeSpeakerDisplayName: string;
|
activeSpeakerDisplayName: string;
|
||||||
@@ -32,15 +34,13 @@ interface Props {
|
|||||||
size: number;
|
size: number;
|
||||||
startTalking: () => void;
|
startTalking: () => void;
|
||||||
stopTalking: () => void;
|
stopTalking: () => void;
|
||||||
}
|
networkWaiting: boolean;
|
||||||
|
enqueueNetworkWaiting: (value: boolean, delay: number) => void;
|
||||||
interface State {
|
setNetworkWaiting: (value: boolean) => void;
|
||||||
isHeld: boolean;
|
|
||||||
// If the button is being pressed by touch, the ID of that touch
|
|
||||||
activeTouchID: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PTTButton: React.FC<Props> = ({
|
export const PTTButton: React.FC<Props> = ({
|
||||||
|
enabled,
|
||||||
showTalkOverError,
|
showTalkOverError,
|
||||||
activeSpeakerUserId,
|
activeSpeakerUserId,
|
||||||
activeSpeakerDisplayName,
|
activeSpeakerDisplayName,
|
||||||
@@ -50,89 +50,114 @@ export const PTTButton: React.FC<Props> = ({
|
|||||||
size,
|
size,
|
||||||
startTalking,
|
startTalking,
|
||||||
stopTalking,
|
stopTalking,
|
||||||
|
networkWaiting,
|
||||||
|
enqueueNetworkWaiting,
|
||||||
|
setNetworkWaiting,
|
||||||
}) => {
|
}) => {
|
||||||
const buttonRef = createRef<HTMLButtonElement>();
|
const buttonRef = useRef<HTMLButtonElement>();
|
||||||
|
|
||||||
const [{ isHeld, activeTouchID }, setState] = useState<State>({
|
const [activeTouchId, setActiveTouchId] = useState<number | null>(null);
|
||||||
isHeld: false,
|
const [buttonHeld, setButtonHeld] = useState(false);
|
||||||
activeTouchID: null,
|
|
||||||
});
|
|
||||||
const onWindowMouseUp = useCallback(
|
|
||||||
(e) => {
|
|
||||||
if (isHeld) stopTalking();
|
|
||||||
setState({ isHeld: false, activeTouchID: null });
|
|
||||||
},
|
|
||||||
[isHeld, setState, stopTalking]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onWindowTouchEnd = useCallback(
|
const hold = useCallback(() => {
|
||||||
(e: TouchEvent) => {
|
// This update is delayed so the user only sees it if latency is significant
|
||||||
// ignore any ended touches that weren't the one pressing the
|
if (buttonHeld) return;
|
||||||
// button (bafflingly the TouchList isn't an iterable so we
|
setButtonHeld(true);
|
||||||
// have to do this a really old-school way).
|
enqueueNetworkWaiting(true, 100);
|
||||||
let touchFound = false;
|
startTalking();
|
||||||
for (let i = 0; i < e.changedTouches.length; ++i) {
|
}, [enqueueNetworkWaiting, startTalking, buttonHeld]);
|
||||||
if (e.changedTouches.item(i).identifier === activeTouchID) {
|
const unhold = useCallback(() => {
|
||||||
touchFound = true;
|
setButtonHeld(false);
|
||||||
break;
|
setNetworkWaiting(false);
|
||||||
}
|
stopTalking();
|
||||||
}
|
}, [setNetworkWaiting, stopTalking]);
|
||||||
if (!touchFound) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
if (isHeld) stopTalking();
|
|
||||||
setState({ isHeld: false, activeTouchID: null });
|
|
||||||
},
|
|
||||||
[isHeld, activeTouchID, setState, stopTalking]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onButtonMouseDown = useCallback(
|
const onButtonMouseDown = useCallback(
|
||||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setState({ isHeld: true, activeTouchID: null });
|
hold();
|
||||||
startTalking();
|
|
||||||
},
|
},
|
||||||
[setState, startTalking]
|
[hold]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onButtonTouchStart = useCallback(
|
// These listeners go on the window so even if the user's cursor / finger
|
||||||
(e: TouchEvent) => {
|
// leaves the button while holding it, the button stays pushed until
|
||||||
e.preventDefault();
|
// they stop clicking / tapping.
|
||||||
|
useEventTarget(window, "mouseup", unhold);
|
||||||
|
useEventTarget(
|
||||||
|
window,
|
||||||
|
"touchend",
|
||||||
|
useCallback(
|
||||||
|
(e: TouchEvent) => {
|
||||||
|
// ignore any ended touches that weren't the one pressing the
|
||||||
|
// button (bafflingly the TouchList isn't an iterable so we
|
||||||
|
// have to do this a really old-school way).
|
||||||
|
let touchFound = false;
|
||||||
|
for (let i = 0; i < e.changedTouches.length; ++i) {
|
||||||
|
if (e.changedTouches.item(i).identifier === activeTouchId) {
|
||||||
|
touchFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!touchFound) return;
|
||||||
|
|
||||||
if (isHeld) return;
|
e.preventDefault();
|
||||||
|
unhold();
|
||||||
setState({
|
setActiveTouchId(null);
|
||||||
isHeld: true,
|
},
|
||||||
activeTouchID: e.changedTouches.item(0).identifier,
|
[unhold, activeTouchId, setActiveTouchId]
|
||||||
});
|
)
|
||||||
startTalking();
|
|
||||||
},
|
|
||||||
[isHeld, setState, startTalking]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
// This is a native DOM listener too because we want to preventDefault in it
|
||||||
const currentButtonElement = buttonRef.current;
|
// to stop also getting a click event, so we need it to be non-passive.
|
||||||
|
useEventTarget(
|
||||||
|
buttonRef.current,
|
||||||
|
"touchstart",
|
||||||
|
useCallback(
|
||||||
|
(e: TouchEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
// These listeners go on the window so even if the user's cursor / finger
|
hold();
|
||||||
// leaves the button while holding it, the button stays pushed until
|
setActiveTouchId(e.changedTouches.item(0).identifier);
|
||||||
// they stop clicking / tapping.
|
},
|
||||||
window.addEventListener("mouseup", onWindowMouseUp);
|
[hold, setActiveTouchId]
|
||||||
window.addEventListener("touchend", onWindowTouchEnd);
|
),
|
||||||
// This is a native DOM listener too because we want to preventDefault in it
|
{ passive: false }
|
||||||
// to stop also getting a click event, so we need it to be non-passive.
|
);
|
||||||
currentButtonElement.addEventListener("touchstart", onButtonTouchStart, {
|
|
||||||
passive: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
useEventTarget(
|
||||||
window.removeEventListener("mouseup", onWindowMouseUp);
|
window,
|
||||||
window.removeEventListener("touchend", onWindowTouchEnd);
|
"keydown",
|
||||||
currentButtonElement.removeEventListener(
|
useCallback(
|
||||||
"touchstart",
|
(e: KeyboardEvent) => {
|
||||||
onButtonTouchStart
|
if (e.code === "Space") {
|
||||||
);
|
if (!enabled) return;
|
||||||
};
|
e.preventDefault();
|
||||||
}, [onWindowMouseUp, onWindowTouchEnd, onButtonTouchStart, buttonRef]);
|
|
||||||
|
hold();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enabled, hold]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
useEventTarget(
|
||||||
|
window,
|
||||||
|
"keyup",
|
||||||
|
useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.code === "Space") {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
unhold();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[unhold]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: We will need to disable this for a global PTT hotkey to work
|
||||||
|
useEventTarget(window, "blur", unhold);
|
||||||
|
|
||||||
const { shadow } = useSpring({
|
const { shadow } = useSpring({
|
||||||
shadow: (Math.max(activeSpeakerVolume, -70) + 70) * 0.6,
|
shadow: (Math.max(activeSpeakerVolume, -70) + 70) * 0.6,
|
||||||
@@ -143,12 +168,15 @@ export const PTTButton: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
const shadowColor = showTalkOverError
|
const shadowColor = showTalkOverError
|
||||||
? "var(--alert-20)"
|
? "var(--alert-20)"
|
||||||
|
: networkWaiting
|
||||||
|
? "var(--tertiary-content-20)"
|
||||||
: "var(--accent-20)";
|
: "var(--accent-20)";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<animated.button
|
<animated.button
|
||||||
className={classNames(styles.pttButton, {
|
className={classNames(styles.pttButton, {
|
||||||
[styles.talking]: activeSpeakerUserId,
|
[styles.talking]: activeSpeakerUserId,
|
||||||
|
[styles.networkWaiting]: networkWaiting,
|
||||||
[styles.error]: showTalkOverError,
|
[styles.error]: showTalkOverError,
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin: 20px;
|
margin: 20px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.participants > p {
|
.participants > p {
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
|
|
||||||
.talkingInfo {
|
.talkingInfo {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
import useMeasure from "react-use-measure";
|
import useMeasure from "react-use-measure";
|
||||||
import { ResizeObserver } from "@juggle/resize-observer";
|
import { ResizeObserver } from "@juggle/resize-observer";
|
||||||
import { GroupCall, MatrixClient, RoomMember } from "matrix-js-sdk";
|
import { GroupCall, MatrixClient, RoomMember } from "matrix-js-sdk";
|
||||||
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
|
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
|
||||||
|
|
||||||
|
import { useDelayedState } from "../useDelayedState";
|
||||||
import { useModalTriggerState } from "../Modal";
|
import { useModalTriggerState } from "../Modal";
|
||||||
import { InviteModal } from "./InviteModal";
|
import { InviteModal } from "./InviteModal";
|
||||||
import { HangupButton, InviteButton } from "../button";
|
import { HangupButton, InviteButton } from "../button";
|
||||||
@@ -39,6 +40,7 @@ import { GroupCallInspector } from "./GroupCallInspector";
|
|||||||
import { OverflowMenu } from "./OverflowMenu";
|
import { OverflowMenu } from "./OverflowMenu";
|
||||||
|
|
||||||
function getPromptText(
|
function getPromptText(
|
||||||
|
networkWaiting: boolean,
|
||||||
showTalkOverError: boolean,
|
showTalkOverError: boolean,
|
||||||
pttButtonHeld: boolean,
|
pttButtonHeld: boolean,
|
||||||
activeSpeakerIsLocalUser: boolean,
|
activeSpeakerIsLocalUser: boolean,
|
||||||
@@ -47,10 +49,14 @@ function getPromptText(
|
|||||||
activeSpeakerDisplayName: string,
|
activeSpeakerDisplayName: string,
|
||||||
connected: boolean
|
connected: boolean
|
||||||
): string {
|
): string {
|
||||||
if (!connected) return "Connection Lost";
|
if (!connected) return "Connection lost";
|
||||||
|
|
||||||
const isTouchScreen = Boolean(window.ontouchstart !== undefined);
|
const isTouchScreen = Boolean(window.ontouchstart !== undefined);
|
||||||
|
|
||||||
|
if (networkWaiting) {
|
||||||
|
return "Waiting for network";
|
||||||
|
}
|
||||||
|
|
||||||
if (showTalkOverError) {
|
if (showTalkOverError) {
|
||||||
return "You can't talk at the same time";
|
return "You can't talk at the same time";
|
||||||
}
|
}
|
||||||
@@ -87,6 +93,7 @@ interface Props {
|
|||||||
participants: RoomMember[];
|
participants: RoomMember[];
|
||||||
userMediaFeeds: CallFeed[];
|
userMediaFeeds: CallFeed[];
|
||||||
onLeave: () => void;
|
onLeave: () => void;
|
||||||
|
isEmbedded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PTTCallView: React.FC<Props> = ({
|
export const PTTCallView: React.FC<Props> = ({
|
||||||
@@ -98,6 +105,7 @@ export const PTTCallView: React.FC<Props> = ({
|
|||||||
participants,
|
participants,
|
||||||
userMediaFeeds,
|
userMediaFeeds,
|
||||||
onLeave,
|
onLeave,
|
||||||
|
isEmbedded,
|
||||||
}) => {
|
}) => {
|
||||||
const { modalState: inviteModalState, modalProps: inviteModalProps } =
|
const { modalState: inviteModalState, modalProps: inviteModalProps } =
|
||||||
useModalTriggerState();
|
useModalTriggerState();
|
||||||
@@ -105,6 +113,7 @@ export const PTTCallView: React.FC<Props> = ({
|
|||||||
useModalTriggerState();
|
useModalTriggerState();
|
||||||
const [containerRef, bounds] = useMeasure({ polyfill: ResizeObserver });
|
const [containerRef, bounds] = useMeasure({ polyfill: ResizeObserver });
|
||||||
const facepileSize = bounds.width < 800 ? "sm" : "md";
|
const facepileSize = bounds.width < 800 ? "sm" : "md";
|
||||||
|
const showControls = bounds.height > 500;
|
||||||
const pttButtonSize = 232;
|
const pttButtonSize = 232;
|
||||||
|
|
||||||
const { audioOutput } = useMediaHandler();
|
const { audioOutput } = useMediaHandler();
|
||||||
@@ -128,18 +137,15 @@ export const PTTCallView: React.FC<Props> = ({
|
|||||||
stopTalking,
|
stopTalking,
|
||||||
transmitBlocked,
|
transmitBlocked,
|
||||||
connected,
|
connected,
|
||||||
} = usePTT(
|
} = usePTT(client, groupCall, userMediaFeeds, playClip);
|
||||||
client,
|
|
||||||
groupCall,
|
|
||||||
userMediaFeeds,
|
|
||||||
playClip,
|
|
||||||
!feedbackModalState.isOpen
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const [talkingExpected, enqueueTalkingExpected, setTalkingExpected] =
|
||||||
|
useDelayedState(false);
|
||||||
const showTalkOverError = pttButtonHeld && transmitBlocked;
|
const showTalkOverError = pttButtonHeld && transmitBlocked;
|
||||||
|
const networkWaiting =
|
||||||
|
talkingExpected && !activeSpeakerUserId && !showTalkOverError;
|
||||||
|
|
||||||
const activeSpeakerIsLocalUser =
|
const activeSpeakerIsLocalUser = activeSpeakerUserId === client.getUserId();
|
||||||
activeSpeakerUserId && client.getUserId() === activeSpeakerUserId;
|
|
||||||
const activeSpeakerUser = activeSpeakerUserId
|
const activeSpeakerUser = activeSpeakerUserId
|
||||||
? client.getUser(activeSpeakerUserId)
|
? client.getUser(activeSpeakerUserId)
|
||||||
: null;
|
: null;
|
||||||
@@ -148,6 +154,10 @@ export const PTTCallView: React.FC<Props> = ({
|
|||||||
? activeSpeakerUser.displayName
|
? activeSpeakerUser.displayName
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTalkingExpected(activeSpeakerIsLocalUser);
|
||||||
|
}, [activeSpeakerIsLocalUser, setTalkingExpected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.pttCallView} ref={containerRef}>
|
<div className={styles.pttCallView} ref={containerRef}>
|
||||||
<PTTClips
|
<PTTClips
|
||||||
@@ -163,60 +173,69 @@ export const PTTCallView: React.FC<Props> = ({
|
|||||||
// https://github.com/vector-im/element-call/issues/328
|
// https://github.com/vector-im/element-call/issues/328
|
||||||
show={false}
|
show={false}
|
||||||
/>
|
/>
|
||||||
<Header className={styles.header}>
|
{showControls && (
|
||||||
<LeftNav>
|
<Header className={styles.header}>
|
||||||
<RoomSetupHeaderInfo
|
<LeftNav>
|
||||||
roomName={roomName}
|
<RoomSetupHeaderInfo
|
||||||
avatarUrl={avatarUrl}
|
roomName={roomName}
|
||||||
onPress={onLeave}
|
avatarUrl={avatarUrl}
|
||||||
/>
|
onPress={onLeave}
|
||||||
</LeftNav>
|
isEmbedded={isEmbedded}
|
||||||
<RightNav />
|
/>
|
||||||
</Header>
|
</LeftNav>
|
||||||
|
<RightNav />
|
||||||
|
</Header>
|
||||||
|
)}
|
||||||
<div className={styles.center}>
|
<div className={styles.center}>
|
||||||
<div className={styles.participants}>
|
{showControls && (
|
||||||
<p>{`${participants.length} ${
|
<>
|
||||||
participants.length > 1 ? "people" : "person"
|
<div className={styles.participants}>
|
||||||
} connected`}</p>
|
<p>{`${participants.length} ${
|
||||||
<Facepile
|
participants.length > 1 ? "people" : "person"
|
||||||
size={facepileSize}
|
} connected`}</p>
|
||||||
max={8}
|
<Facepile
|
||||||
className={styles.facepile}
|
size={facepileSize}
|
||||||
client={client}
|
max={8}
|
||||||
participants={participants}
|
className={styles.facepile}
|
||||||
/>
|
client={client}
|
||||||
</div>
|
participants={participants}
|
||||||
<div className={styles.footer}>
|
/>
|
||||||
<OverflowMenu
|
</div>
|
||||||
inCall
|
<div className={styles.footer}>
|
||||||
roomId={roomId}
|
<OverflowMenu
|
||||||
client={client}
|
inCall
|
||||||
groupCall={groupCall}
|
roomId={roomId}
|
||||||
showInvite={false}
|
client={client}
|
||||||
feedbackModalState={feedbackModalState}
|
groupCall={groupCall}
|
||||||
feedbackModalProps={feedbackModalProps}
|
showInvite={false}
|
||||||
/>
|
feedbackModalState={feedbackModalState}
|
||||||
<HangupButton onPress={onLeave} />
|
feedbackModalProps={feedbackModalProps}
|
||||||
<InviteButton onPress={() => inviteModalState.open()} />
|
/>
|
||||||
</div>
|
{!isEmbedded && <HangupButton onPress={onLeave} />}
|
||||||
|
<InviteButton onPress={() => inviteModalState.open()} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.pttButtonContainer}>
|
<div className={styles.pttButtonContainer}>
|
||||||
{activeSpeakerUserId ? (
|
{showControls &&
|
||||||
<div className={styles.talkingInfo}>
|
(activeSpeakerUserId ? (
|
||||||
<h2>
|
<div className={styles.talkingInfo}>
|
||||||
{!activeSpeakerIsLocalUser && (
|
<h2>
|
||||||
<AudioIcon className={styles.speakerIcon} />
|
{!activeSpeakerIsLocalUser && (
|
||||||
)}
|
<AudioIcon className={styles.speakerIcon} />
|
||||||
{activeSpeakerIsLocalUser
|
)}
|
||||||
? "Talking..."
|
{activeSpeakerIsLocalUser
|
||||||
: `${activeSpeakerDisplayName} is talking...`}
|
? "Talking..."
|
||||||
</h2>
|
: `${activeSpeakerDisplayName} is talking...`}
|
||||||
<Timer value={activeSpeakerUserId} />
|
</h2>
|
||||||
</div>
|
<Timer value={activeSpeakerUserId} />
|
||||||
) : (
|
</div>
|
||||||
<div className={styles.talkingInfo} />
|
) : (
|
||||||
)}
|
<div className={styles.talkingInfo} />
|
||||||
|
))}
|
||||||
<PTTButton
|
<PTTButton
|
||||||
|
enabled={!feedbackModalState.isOpen}
|
||||||
showTalkOverError={showTalkOverError}
|
showTalkOverError={showTalkOverError}
|
||||||
activeSpeakerUserId={activeSpeakerUserId}
|
activeSpeakerUserId={activeSpeakerUserId}
|
||||||
activeSpeakerDisplayName={activeSpeakerDisplayName}
|
activeSpeakerDisplayName={activeSpeakerDisplayName}
|
||||||
@@ -226,18 +245,24 @@ export const PTTCallView: React.FC<Props> = ({
|
|||||||
size={pttButtonSize}
|
size={pttButtonSize}
|
||||||
startTalking={startTalking}
|
startTalking={startTalking}
|
||||||
stopTalking={stopTalking}
|
stopTalking={stopTalking}
|
||||||
|
networkWaiting={networkWaiting}
|
||||||
|
enqueueNetworkWaiting={enqueueTalkingExpected}
|
||||||
|
setNetworkWaiting={setTalkingExpected}
|
||||||
/>
|
/>
|
||||||
<p className={styles.actionTip}>
|
{showControls && (
|
||||||
{getPromptText(
|
<p className={styles.actionTip}>
|
||||||
showTalkOverError,
|
{getPromptText(
|
||||||
pttButtonHeld,
|
networkWaiting,
|
||||||
activeSpeakerIsLocalUser,
|
showTalkOverError,
|
||||||
talkOverEnabled,
|
pttButtonHeld,
|
||||||
activeSpeakerUserId,
|
activeSpeakerIsLocalUser,
|
||||||
activeSpeakerDisplayName,
|
talkOverEnabled,
|
||||||
connected
|
activeSpeakerUserId,
|
||||||
)}
|
activeSpeakerDisplayName,
|
||||||
</p>
|
connected
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{userMediaFeeds.map((callFeed) => (
|
{userMediaFeeds.map((callFeed) => (
|
||||||
<PTTFeed
|
<PTTFeed
|
||||||
key={callFeed.userId}
|
key={callFeed.userId}
|
||||||
@@ -245,7 +270,7 @@ export const PTTCallView: React.FC<Props> = ({
|
|||||||
audioOutputDevice={audioOutput}
|
audioOutputDevice={audioOutput}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{isAdmin && (
|
{isAdmin && showControls && (
|
||||||
<Toggle
|
<Toggle
|
||||||
isSelected={talkOverEnabled}
|
isSelected={talkOverEnabled}
|
||||||
onChange={setTalkOverEnabled}
|
onChange={setTalkOverEnabled}
|
||||||
@@ -256,7 +281,7 @@ export const PTTCallView: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{inviteModalState.isOpen && (
|
{inviteModalState.isOpen && showControls && (
|
||||||
<InviteModal roomId={roomId} {...inviteModalProps} />
|
<InviteModal roomId={roomId} {...inviteModalProps} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,26 +16,21 @@ limitations under the License.
|
|||||||
|
|
||||||
import React, { useCallback, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
import styles from "./RoomAuthView.module.css";
|
import styles from "./RoomAuthView.module.css";
|
||||||
import { useClient } from "../ClientContext";
|
|
||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
import { Body, Caption, Link, Headline } from "../typography/Typography";
|
import { Body, Caption, Link, Headline } from "../typography/Typography";
|
||||||
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { useRecaptcha } from "../auth/useRecaptcha";
|
|
||||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
|
||||||
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
|
||||||
import { Form } from "../form/Form";
|
import { Form } from "../form/Form";
|
||||||
import { UserMenuContainer } from "../UserMenuContainer";
|
import { UserMenuContainer } from "../UserMenuContainer";
|
||||||
import { generateRandomName } from "../auth/generateRandomName";
|
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||||
|
|
||||||
export function RoomAuthView() {
|
export function RoomAuthView() {
|
||||||
const { setClient } = useClient();
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState();
|
const [error, setError] = useState();
|
||||||
const [privacyPolicyUrl, recaptchaKey, register] =
|
|
||||||
useInteractiveRegistration();
|
const { registerPasswordlessUser, recaptchaId, privacyPolicyUrl } =
|
||||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
useRegisterPasswordlessUser();
|
||||||
|
|
||||||
const onSubmit = useCallback(
|
const onSubmit = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
@@ -43,29 +38,13 @@ export function RoomAuthView() {
|
|||||||
const data = new FormData(e.target);
|
const data = new FormData(e.target);
|
||||||
const displayName = data.get("displayName");
|
const displayName = data.get("displayName");
|
||||||
|
|
||||||
async function submit() {
|
registerPasswordlessUser(displayName).catch((error) => {
|
||||||
setError(undefined);
|
console.error("Failed to register passwordless user", e);
|
||||||
setLoading(true);
|
|
||||||
const recaptchaResponse = await execute();
|
|
||||||
const userName = generateRandomName();
|
|
||||||
const [client, session] = await register(
|
|
||||||
userName,
|
|
||||||
randomString(16),
|
|
||||||
displayName,
|
|
||||||
recaptchaResponse,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
setClient(client, session);
|
|
||||||
}
|
|
||||||
|
|
||||||
submit().catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(error);
|
setError(error);
|
||||||
reset();
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[register, reset, execute]
|
[registerPasswordlessUser]
|
||||||
);
|
);
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useLocation, useParams } from "react-router-dom";
|
import { useLocation, useParams } from "react-router-dom";
|
||||||
import { useClient } from "../ClientContext";
|
import { useClient } from "../ClientContext";
|
||||||
import { ErrorView, LoadingView } from "../FullScreenView";
|
import { ErrorView, LoadingView } from "../FullScreenView";
|
||||||
@@ -22,6 +22,7 @@ import { RoomAuthView } from "./RoomAuthView";
|
|||||||
import { GroupCallLoader } from "./GroupCallLoader";
|
import { GroupCallLoader } from "./GroupCallLoader";
|
||||||
import { GroupCallView } from "./GroupCallView";
|
import { GroupCallView } from "./GroupCallView";
|
||||||
import { MediaHandlerProvider } from "../settings/useMediaHandler";
|
import { MediaHandlerProvider } from "../settings/useMediaHandler";
|
||||||
|
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||||
|
|
||||||
export function RoomPage() {
|
export function RoomPage() {
|
||||||
const { loading, isAuthenticated, error, client, isPasswordlessUser } =
|
const { loading, isAuthenticated, error, client, isPasswordlessUser } =
|
||||||
@@ -29,13 +30,37 @@ export function RoomPage() {
|
|||||||
|
|
||||||
const { roomId: maybeRoomId } = useParams();
|
const { roomId: maybeRoomId } = useParams();
|
||||||
const { hash, search } = useLocation();
|
const { hash, search } = useLocation();
|
||||||
const [viaServers] = useMemo(() => {
|
const [viaServers, isEmbedded, isPtt, displayName] = useMemo(() => {
|
||||||
const params = new URLSearchParams(search);
|
const params = new URLSearchParams(search);
|
||||||
return [params.getAll("via")];
|
return [
|
||||||
|
params.getAll("via"),
|
||||||
|
params.has("embed"),
|
||||||
|
params.get("ptt") === "true",
|
||||||
|
params.get("displayName"),
|
||||||
|
];
|
||||||
}, [search]);
|
}, [search]);
|
||||||
const roomId = (maybeRoomId || hash || "").toLowerCase();
|
const roomId = (maybeRoomId || hash || "").toLowerCase();
|
||||||
|
const { registerPasswordlessUser, recaptchaId } =
|
||||||
|
useRegisterPasswordlessUser();
|
||||||
|
const [isRegistering, setIsRegistering] = useState(false);
|
||||||
|
|
||||||
if (loading) {
|
useEffect(() => {
|
||||||
|
// If we're not already authed and we've been given a display name as
|
||||||
|
// a URL param, automatically register a passwordless user
|
||||||
|
if (!isAuthenticated && displayName) {
|
||||||
|
setIsRegistering(true);
|
||||||
|
registerPasswordlessUser(displayName).finally(() => {
|
||||||
|
setIsRegistering(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isAuthenticated,
|
||||||
|
displayName,
|
||||||
|
setIsRegistering,
|
||||||
|
registerPasswordlessUser,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (loading || isRegistering) {
|
||||||
return <LoadingView />;
|
return <LoadingView />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,13 +74,19 @@ export function RoomPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MediaHandlerProvider client={client}>
|
<MediaHandlerProvider client={client}>
|
||||||
<GroupCallLoader client={client} roomId={roomId} viaServers={viaServers}>
|
<GroupCallLoader
|
||||||
|
client={client}
|
||||||
|
roomId={roomId}
|
||||||
|
viaServers={viaServers}
|
||||||
|
createPtt={isPtt}
|
||||||
|
>
|
||||||
{(groupCall) => (
|
{(groupCall) => (
|
||||||
<GroupCallView
|
<GroupCallView
|
||||||
client={client}
|
client={client}
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
groupCall={groupCall}
|
groupCall={groupCall}
|
||||||
isPasswordlessUser={isPasswordlessUser}
|
isPasswordlessUser={isPasswordlessUser}
|
||||||
|
isEmbedded={isEmbedded}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</GroupCallLoader>
|
</GroupCallLoader>
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||||
import {
|
import {
|
||||||
GroupCallEvent,
|
GroupCallEvent,
|
||||||
GroupCallState,
|
GroupCallState,
|
||||||
GroupCall,
|
GroupCall,
|
||||||
|
GroupCallErrorCode,
|
||||||
|
GroupCallUnknownDeviceError,
|
||||||
|
GroupCallError,
|
||||||
} from "matrix-js-sdk/src/webrtc/groupCall";
|
} from "matrix-js-sdk/src/webrtc/groupCall";
|
||||||
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||||
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
|
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
|
||||||
@@ -48,6 +51,7 @@ export interface UseGroupCallType {
|
|||||||
localDesktopCapturerSourceId: string;
|
localDesktopCapturerSourceId: string;
|
||||||
participants: RoomMember[];
|
participants: RoomMember[];
|
||||||
hasLocalParticipant: boolean;
|
hasLocalParticipant: boolean;
|
||||||
|
unencryptedEventsFromUsers: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@@ -106,6 +110,13 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallType {
|
|||||||
hasLocalParticipant: false,
|
hasLocalParticipant: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [unencryptedEventsFromUsers, addUnencryptedEventUser] = useReducer(
|
||||||
|
(state: Set<string>, newVal: string) => {
|
||||||
|
return new Set(state).add(newVal);
|
||||||
|
},
|
||||||
|
new Set<string>()
|
||||||
|
);
|
||||||
|
|
||||||
const updateState = (state: Partial<State>) =>
|
const updateState = (state: Partial<State>) =>
|
||||||
setState((prevState) => ({ ...prevState, ...state }));
|
setState((prevState) => ({ ...prevState, ...state }));
|
||||||
|
|
||||||
@@ -180,6 +191,13 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallType {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onError(e: GroupCallError): void {
|
||||||
|
if (e.code === GroupCallErrorCode.UnknownDevice) {
|
||||||
|
const unknownDeviceError = e as GroupCallUnknownDeviceError;
|
||||||
|
addUnencryptedEventUser(unknownDeviceError.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
groupCall.on(GroupCallEvent.GroupCallStateChanged, onGroupCallStateChanged);
|
groupCall.on(GroupCallEvent.GroupCallStateChanged, onGroupCallStateChanged);
|
||||||
groupCall.on(GroupCallEvent.UserMediaFeedsChanged, onUserMediaFeedsChanged);
|
groupCall.on(GroupCallEvent.UserMediaFeedsChanged, onUserMediaFeedsChanged);
|
||||||
groupCall.on(
|
groupCall.on(
|
||||||
@@ -194,6 +212,7 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallType {
|
|||||||
);
|
);
|
||||||
groupCall.on(GroupCallEvent.CallsChanged, onCallsChanged);
|
groupCall.on(GroupCallEvent.CallsChanged, onCallsChanged);
|
||||||
groupCall.on(GroupCallEvent.ParticipantsChanged, onParticipantsChanged);
|
groupCall.on(GroupCallEvent.ParticipantsChanged, onParticipantsChanged);
|
||||||
|
groupCall.on(GroupCallEvent.Error, onError);
|
||||||
|
|
||||||
updateState({
|
updateState({
|
||||||
error: null,
|
error: null,
|
||||||
@@ -242,6 +261,7 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallType {
|
|||||||
GroupCallEvent.ParticipantsChanged,
|
GroupCallEvent.ParticipantsChanged,
|
||||||
onParticipantsChanged
|
onParticipantsChanged
|
||||||
);
|
);
|
||||||
|
groupCall.removeListener(GroupCallEvent.Error, onError);
|
||||||
groupCall.leave();
|
groupCall.leave();
|
||||||
};
|
};
|
||||||
}, [groupCall]);
|
}, [groupCall]);
|
||||||
@@ -319,5 +339,6 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallType {
|
|||||||
localDesktopCapturerSourceId,
|
localDesktopCapturerSourceId,
|
||||||
participants,
|
participants,
|
||||||
hasLocalParticipant,
|
hasLocalParticipant,
|
||||||
|
unencryptedEventsFromUsers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2022 Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
|
|
||||||
|
|
||||||
async function fetchGroupCall(
|
|
||||||
client,
|
|
||||||
roomIdOrAlias,
|
|
||||||
viaServers = undefined,
|
|
||||||
timeout = 5000
|
|
||||||
) {
|
|
||||||
const { roomId } = await client.joinRoom(roomIdOrAlias, { viaServers });
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let timeoutId;
|
|
||||||
|
|
||||||
function onGroupCallIncoming(groupCall) {
|
|
||||||
if (groupCall && groupCall.room.roomId === roomId) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
client.removeListener("GroupCall.incoming", onGroupCallIncoming);
|
|
||||||
resolve(groupCall);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupCall = client.getGroupCallForRoom(roomId);
|
|
||||||
|
|
||||||
if (groupCall) {
|
|
||||||
resolve(groupCall);
|
|
||||||
}
|
|
||||||
|
|
||||||
client.on("GroupCall.incoming", onGroupCallIncoming);
|
|
||||||
|
|
||||||
if (timeout) {
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
client.removeListener("GroupCall.incoming", onGroupCallIncoming);
|
|
||||||
reject(new Error("Fetching group call timed out."));
|
|
||||||
}, timeout);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLoadGroupCall(client, roomId, viaServers, createIfNotFound) {
|
|
||||||
const [state, setState] = useState({
|
|
||||||
loading: true,
|
|
||||||
error: undefined,
|
|
||||||
groupCall: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
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 });
|
|
||||||
|
|
||||||
fetchOrCreateGroupCall()
|
|
||||||
.then((groupCall) =>
|
|
||||||
setState((prevState) => ({ ...prevState, loading: false, groupCall }))
|
|
||||||
)
|
|
||||||
.catch((error) =>
|
|
||||||
setState((prevState) => ({ ...prevState, loading: false, error }))
|
|
||||||
);
|
|
||||||
}, [client, roomId, state.reloadId, createIfNotFound, viaServers]);
|
|
||||||
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
120
src/room/useLoadGroupCall.ts
Normal file
120
src/room/useLoadGroupCall.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||||
|
import {
|
||||||
|
GroupCallType,
|
||||||
|
GroupCallIntent,
|
||||||
|
} from "matrix-js-sdk/src/webrtc/groupCall";
|
||||||
|
import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler";
|
||||||
|
|
||||||
|
import type { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
|
import type { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||||
|
import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
|
||||||
|
|
||||||
|
export interface GroupCallLoadState {
|
||||||
|
loading: boolean;
|
||||||
|
error?: Error;
|
||||||
|
groupCall?: GroupCall;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLoadGroupCall = (
|
||||||
|
client: MatrixClient,
|
||||||
|
roomIdOrAlias: string,
|
||||||
|
viaServers: string[],
|
||||||
|
createPtt: boolean
|
||||||
|
): GroupCallLoadState => {
|
||||||
|
const [state, setState] = useState<GroupCallLoadState>({ loading: true });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState({ loading: true });
|
||||||
|
|
||||||
|
const fetchOrCreateRoom = async (): Promise<Room> => {
|
||||||
|
try {
|
||||||
|
return await client.joinRoom(roomIdOrAlias, { viaServers });
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
isLocalRoomId(roomIdOrAlias) &&
|
||||||
|
(error.errcode === "M_NOT_FOUND" ||
|
||||||
|
(error.message &&
|
||||||
|
error.message.indexOf("Failed to fetch alias") !== -1))
|
||||||
|
) {
|
||||||
|
// The room doesn't exist, but we can create it
|
||||||
|
await createRoom(client, roomNameFromRoomId(roomIdOrAlias));
|
||||||
|
return await client.joinRoom(roomIdOrAlias, { viaServers });
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchOrCreateGroupCall = async (): Promise<GroupCall> => {
|
||||||
|
const room = await fetchOrCreateRoom();
|
||||||
|
const groupCall = client.getGroupCallForRoom(room.roomId);
|
||||||
|
|
||||||
|
if (groupCall) return groupCall;
|
||||||
|
|
||||||
|
if (
|
||||||
|
room.currentState.mayClientSendStateEvent(
|
||||||
|
EventType.GroupCallPrefix,
|
||||||
|
client
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// The call doesn't exist, but we can create it
|
||||||
|
console.log(`Creating ${createPtt ? "PTT" : "video"} group call room`);
|
||||||
|
return await client.createGroupCall(
|
||||||
|
room.roomId,
|
||||||
|
createPtt ? GroupCallType.Voice : GroupCallType.Video,
|
||||||
|
createPtt,
|
||||||
|
GroupCallIntent.Room
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We don't have permission to create the call, so all we can do is wait
|
||||||
|
// for one to come in
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const onGroupCallIncoming = (groupCall: GroupCall) => {
|
||||||
|
if (groupCall?.room.roomId === room.roomId) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
client.off(
|
||||||
|
GroupCallEventHandlerEvent.Incoming,
|
||||||
|
onGroupCallIncoming
|
||||||
|
);
|
||||||
|
resolve(groupCall);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
client.on(GroupCallEventHandlerEvent.Incoming, onGroupCallIncoming);
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
client.off(GroupCallEventHandlerEvent.Incoming, onGroupCallIncoming);
|
||||||
|
reject(new Error("Fetching group call timed out."));
|
||||||
|
}, 30000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchOrCreateGroupCall()
|
||||||
|
.then((groupCall) =>
|
||||||
|
setState((prevState) => ({ ...prevState, loading: false, groupCall }))
|
||||||
|
)
|
||||||
|
.catch((error) =>
|
||||||
|
setState((prevState) => ({ ...prevState, loading: false, error }))
|
||||||
|
);
|
||||||
|
}, [client, roomIdOrAlias, viaServers, createPtt]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
@@ -80,8 +80,7 @@ export const usePTT = (
|
|||||||
client: MatrixClient,
|
client: MatrixClient,
|
||||||
groupCall: GroupCall,
|
groupCall: GroupCall,
|
||||||
userMediaFeeds: CallFeed[],
|
userMediaFeeds: CallFeed[],
|
||||||
playClip: PlayClipFunction,
|
playClip: PlayClipFunction
|
||||||
enablePTTButton: boolean
|
|
||||||
): PTTState => {
|
): PTTState => {
|
||||||
// Used to serialise all the mute calls so they don't race. It has
|
// Used to serialise all the mute calls so they don't race. It has
|
||||||
// its own state as its always set separately from anything else.
|
// its own state as its always set separately from anything else.
|
||||||
@@ -131,7 +130,7 @@ export const usePTT = (
|
|||||||
const onMuteStateChanged = useCallback(() => {
|
const onMuteStateChanged = useCallback(() => {
|
||||||
const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall);
|
const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall);
|
||||||
|
|
||||||
let blocked = false;
|
let blocked = transmitBlocked;
|
||||||
if (activeSpeakerUserId === null && activeSpeakerFeed !== null) {
|
if (activeSpeakerUserId === null && activeSpeakerFeed !== null) {
|
||||||
if (activeSpeakerFeed.userId === client.getUserId()) {
|
if (activeSpeakerFeed.userId === client.getUserId()) {
|
||||||
playClip(PTTClipID.START_TALKING_LOCAL);
|
playClip(PTTClipID.START_TALKING_LOCAL);
|
||||||
@@ -142,8 +141,8 @@ export const usePTT = (
|
|||||||
playClip(PTTClipID.END_TALKING);
|
playClip(PTTClipID.END_TALKING);
|
||||||
} else if (
|
} else if (
|
||||||
pttButtonHeld &&
|
pttButtonHeld &&
|
||||||
activeSpeakerUserId === client.getUserId() &&
|
activeSpeakerFeed?.userId !== client.getUserId() &&
|
||||||
activeSpeakerFeed?.userId !== client.getUserId()
|
!transmitBlocked
|
||||||
) {
|
) {
|
||||||
// We were talking but we've been cut off: mute our own mic
|
// We were talking but we've been cut off: mute our own mic
|
||||||
// (this is the easier way of cutting other speakers off if an
|
// (this is the easier way of cutting other speakers off if an
|
||||||
@@ -168,6 +167,7 @@ export const usePTT = (
|
|||||||
client,
|
client,
|
||||||
userMediaFeeds,
|
userMediaFeeds,
|
||||||
setMicMuteWrapper,
|
setMicMuteWrapper,
|
||||||
|
transmitBlocked,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -258,59 +258,6 @@ export const usePTT = (
|
|||||||
[setConnected]
|
[setConnected]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function onKeyDown(event: KeyboardEvent): void {
|
|
||||||
if (event.code === "Space") {
|
|
||||||
if (!enablePTTButton) return;
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (pttButtonHeld) return;
|
|
||||||
|
|
||||||
startTalking();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeyUp(event: KeyboardEvent): void {
|
|
||||||
if (event.code === "Space") {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
stopTalking();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onBlur(): void {
|
|
||||||
// TODO: We will need to disable this for a global PTT hotkey to work
|
|
||||||
if (!groupCall.isMicrophoneMuted()) {
|
|
||||||
setMicMuteWrapper(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);
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
groupCall,
|
|
||||||
startTalking,
|
|
||||||
stopTalking,
|
|
||||||
activeSpeakerUserId,
|
|
||||||
isAdmin,
|
|
||||||
talkOverEnabled,
|
|
||||||
pttButtonHeld,
|
|
||||||
enablePTTButton,
|
|
||||||
setMicMuteWrapper,
|
|
||||||
client,
|
|
||||||
onClientSync,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
client.on(ClientEvent.Sync, onClientSync);
|
client.on(ClientEvent.Sync, onClientSync);
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { Item } from "@react-stately/collections";
|
||||||
|
|
||||||
import { Modal } from "../Modal";
|
import { Modal } from "../Modal";
|
||||||
import styles from "./SettingsModal.module.css";
|
import styles from "./SettingsModal.module.css";
|
||||||
import { TabContainer, TabItem } from "../tabs/Tabs";
|
import { TabContainer, TabItem } from "../tabs/Tabs";
|
||||||
@@ -22,7 +24,6 @@ import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
|
|||||||
import { ReactComponent as VideoIcon } from "../icons/Video.svg";
|
import { ReactComponent as VideoIcon } from "../icons/Video.svg";
|
||||||
import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
|
import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
|
||||||
import { SelectInput } from "../input/SelectInput";
|
import { SelectInput } from "../input/SelectInput";
|
||||||
import { Item } from "@react-stately/collections";
|
|
||||||
import { useMediaHandler } from "./useMediaHandler";
|
import { useMediaHandler } from "./useMediaHandler";
|
||||||
import { useSpatialAudio, useShowInspector } from "./useSetting";
|
import { useSpatialAudio, useShowInspector } from "./useSetting";
|
||||||
import { FieldRow, InputField } from "../input/Input";
|
import { FieldRow, InputField } from "../input/Input";
|
||||||
@@ -30,7 +31,13 @@ import { Button } from "../button";
|
|||||||
import { useDownloadDebugLog } from "./submit-rageshake";
|
import { useDownloadDebugLog } from "./submit-rageshake";
|
||||||
import { Body } from "../typography/Typography";
|
import { Body } from "../typography/Typography";
|
||||||
|
|
||||||
export const SettingsModal = (props) => {
|
interface Props {
|
||||||
|
setShowInspector: boolean;
|
||||||
|
showInspector: boolean;
|
||||||
|
[rest: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsModal = (props: Props) => {
|
||||||
const {
|
const {
|
||||||
audioInput,
|
audioInput,
|
||||||
audioInputs,
|
audioInputs,
|
||||||
@@ -42,6 +49,7 @@ export const SettingsModal = (props) => {
|
|||||||
audioOutputs,
|
audioOutputs,
|
||||||
setAudioOutput,
|
setAudioOutput,
|
||||||
} = useMediaHandler();
|
} = useMediaHandler();
|
||||||
|
|
||||||
const [spatialAudio, setSpatialAudio] = useSpatialAudio();
|
const [spatialAudio, setSpatialAudio] = useSpatialAudio();
|
||||||
const [showInspector, setShowInspector] = useShowInspector();
|
const [showInspector, setShowInspector] = useShowInspector();
|
||||||
|
|
||||||
@@ -69,8 +77,12 @@ export const SettingsModal = (props) => {
|
|||||||
selectedKey={audioInput}
|
selectedKey={audioInput}
|
||||||
onSelectionChange={setAudioInput}
|
onSelectionChange={setAudioInput}
|
||||||
>
|
>
|
||||||
{audioInputs.map(({ deviceId, label }) => (
|
{audioInputs.map(({ deviceId, label }, index) => (
|
||||||
<Item key={deviceId}>{label}</Item>
|
<Item key={deviceId}>
|
||||||
|
{!!label && label.trim().length > 0
|
||||||
|
? label
|
||||||
|
: `Microphone ${index + 1}`}
|
||||||
|
</Item>
|
||||||
))}
|
))}
|
||||||
</SelectInput>
|
</SelectInput>
|
||||||
{audioOutputs.length > 0 && (
|
{audioOutputs.length > 0 && (
|
||||||
@@ -79,8 +91,12 @@ export const SettingsModal = (props) => {
|
|||||||
selectedKey={audioOutput}
|
selectedKey={audioOutput}
|
||||||
onSelectionChange={setAudioOutput}
|
onSelectionChange={setAudioOutput}
|
||||||
>
|
>
|
||||||
{audioOutputs.map(({ deviceId, label }) => (
|
{audioOutputs.map(({ deviceId, label }, index) => (
|
||||||
<Item key={deviceId}>{label}</Item>
|
<Item key={deviceId}>
|
||||||
|
{!!label && label.trim().length > 0
|
||||||
|
? label
|
||||||
|
: `Speaker ${index + 1}`}
|
||||||
|
</Item>
|
||||||
))}
|
))}
|
||||||
</SelectInput>
|
</SelectInput>
|
||||||
)}
|
)}
|
||||||
@@ -91,7 +107,9 @@ export const SettingsModal = (props) => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={spatialAudio}
|
checked={spatialAudio}
|
||||||
description="This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)"
|
description="This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)"
|
||||||
onChange={(e) => setSpatialAudio(e.target.checked)}
|
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setSpatialAudio(event.target.checked)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
@@ -108,8 +126,12 @@ export const SettingsModal = (props) => {
|
|||||||
selectedKey={videoInput}
|
selectedKey={videoInput}
|
||||||
onSelectionChange={setVideoInput}
|
onSelectionChange={setVideoInput}
|
||||||
>
|
>
|
||||||
{videoInputs.map(({ deviceId, label }) => (
|
{videoInputs.map(({ deviceId, label }, index) => (
|
||||||
<Item key={deviceId}>{label}</Item>
|
<Item key={deviceId}>
|
||||||
|
{!!label && label.trim().length > 0
|
||||||
|
? label
|
||||||
|
: `Camera ${index + 1}`}
|
||||||
|
</Item>
|
||||||
))}
|
))}
|
||||||
</SelectInput>
|
</SelectInput>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
@@ -133,7 +155,9 @@ export const SettingsModal = (props) => {
|
|||||||
label="Show Call Inspector"
|
label="Show Call Inspector"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showInspector}
|
checked={showInspector}
|
||||||
onChange={(e) => setShowInspector(e.target.checked)}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setShowInspector(e.target.checked)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||||
/*
|
/*
|
||||||
Copyright 2017 OpenMarket Ltd
|
Copyright 2017 OpenMarket Ltd
|
||||||
Copyright 2018 New Vector Ltd
|
Copyright 2018 New Vector Ltd
|
||||||
@@ -37,19 +38,33 @@ limitations under the License.
|
|||||||
// actually timestamps. We then purge the remaining logs. We also do this
|
// actually timestamps. We then purge the remaining logs. We also do this
|
||||||
// purge on startup to prevent logs from accumulating.
|
// purge on startup to prevent logs from accumulating.
|
||||||
|
|
||||||
// the frequency with which we flush to indexeddb
|
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||||
|
|
||||||
|
// the frequency with which we flush to indexeddb
|
||||||
const FLUSH_RATE_MS = 30 * 1000;
|
const FLUSH_RATE_MS = 30 * 1000;
|
||||||
|
|
||||||
// the length of log data we keep in indexeddb (and include in the reports)
|
// the length of log data we keep in indexeddb (and include in the reports)
|
||||||
const MAX_LOG_SIZE = 1024 * 1024 * 5; // 5 MB
|
const MAX_LOG_SIZE = 1024 * 1024 * 5; // 5 MB
|
||||||
|
|
||||||
// A class which monkey-patches the global console and stores log lines.
|
type LogFunction = (
|
||||||
export class ConsoleLogger {
|
...args: (Error | DOMException | object | string)[]
|
||||||
logs = "";
|
) => void;
|
||||||
|
type LogFunctionName = "log" | "info" | "warn" | "error";
|
||||||
|
|
||||||
monkeyPatch(consoleObj) {
|
// A class which monkey-patches the global console and stores log lines.
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
id: string;
|
||||||
|
lines: string;
|
||||||
|
index?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConsoleLogger {
|
||||||
|
private logs = "";
|
||||||
|
private originalFunctions: { [key in LogFunctionName]?: LogFunction } = {};
|
||||||
|
|
||||||
|
public monkeyPatch(consoleObj: Console): void {
|
||||||
// Monkey-patch console logging
|
// Monkey-patch console logging
|
||||||
const consoleFunctionsToLevels = {
|
const consoleFunctionsToLevels = {
|
||||||
log: "I",
|
log: "I",
|
||||||
@@ -60,6 +75,7 @@ export class ConsoleLogger {
|
|||||||
Object.keys(consoleFunctionsToLevels).forEach((fnName) => {
|
Object.keys(consoleFunctionsToLevels).forEach((fnName) => {
|
||||||
const level = consoleFunctionsToLevels[fnName];
|
const level = consoleFunctionsToLevels[fnName];
|
||||||
const originalFn = consoleObj[fnName].bind(consoleObj);
|
const originalFn = consoleObj[fnName].bind(consoleObj);
|
||||||
|
this.originalFunctions[fnName] = originalFn;
|
||||||
consoleObj[fnName] = (...args) => {
|
consoleObj[fnName] = (...args) => {
|
||||||
this.log(level, ...args);
|
this.log(level, ...args);
|
||||||
originalFn(...args);
|
originalFn(...args);
|
||||||
@@ -67,7 +83,17 @@ export class ConsoleLogger {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
log(level, ...args) {
|
public bypassRageshake(
|
||||||
|
fnName: LogFunctionName,
|
||||||
|
...args: (Error | DOMException | object | string)[]
|
||||||
|
): void {
|
||||||
|
this.originalFunctions[fnName](...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public log(
|
||||||
|
level: string,
|
||||||
|
...args: (Error | DOMException | object | string)[]
|
||||||
|
): void {
|
||||||
// We don't know what locale the user may be running so use ISO strings
|
// We don't know what locale the user may be running so use ISO strings
|
||||||
const ts = new Date().toISOString();
|
const ts = new Date().toISOString();
|
||||||
|
|
||||||
@@ -78,21 +104,7 @@ export class ConsoleLogger {
|
|||||||
} else if (arg instanceof Error) {
|
} else if (arg instanceof Error) {
|
||||||
return arg.message + (arg.stack ? `\n${arg.stack}` : "");
|
return arg.message + (arg.stack ? `\n${arg.stack}` : "");
|
||||||
} else if (typeof arg === "object") {
|
} else if (typeof arg === "object") {
|
||||||
try {
|
return JSON.stringify(arg, getCircularReplacer());
|
||||||
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 {
|
} else {
|
||||||
return arg;
|
return arg;
|
||||||
}
|
}
|
||||||
@@ -116,7 +128,7 @@ export class ConsoleLogger {
|
|||||||
* @param {boolean} keepLogs True to not delete logs after flushing.
|
* @param {boolean} keepLogs True to not delete logs after flushing.
|
||||||
* @return {string} \n delimited log lines to flush.
|
* @return {string} \n delimited log lines to flush.
|
||||||
*/
|
*/
|
||||||
flush(keepLogs) {
|
public flush(keepLogs?: boolean): string {
|
||||||
// The ConsoleLogger doesn't care how these end up on disk, it just
|
// The ConsoleLogger doesn't care how these end up on disk, it just
|
||||||
// flushes them to the caller.
|
// flushes them to the caller.
|
||||||
if (keepLogs) {
|
if (keepLogs) {
|
||||||
@@ -130,24 +142,23 @@ export class ConsoleLogger {
|
|||||||
|
|
||||||
// A class which stores log lines in an IndexedDB instance.
|
// A class which stores log lines in an IndexedDB instance.
|
||||||
export class IndexedDBLogStore {
|
export class IndexedDBLogStore {
|
||||||
index = 0;
|
private index = 0;
|
||||||
db = null;
|
private db: IDBDatabase = null;
|
||||||
flushPromise = null;
|
private flushPromise: Promise<void> = null;
|
||||||
flushAgainPromise = null;
|
private flushAgainPromise: Promise<void> = null;
|
||||||
|
private id: string;
|
||||||
|
|
||||||
constructor(indexedDB, logger) {
|
constructor(private indexedDB: IDBFactory, private logger: ConsoleLogger) {
|
||||||
this.indexedDB = indexedDB;
|
this.id = "instance-" + randomString(16);
|
||||||
this.logger = logger;
|
|
||||||
this.id = "instance-" + Math.random() + Date.now();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return {Promise} Resolves when the store is ready.
|
* @return {Promise} Resolves when the store is ready.
|
||||||
*/
|
*/
|
||||||
connect() {
|
public connect(): Promise<void> {
|
||||||
const req = this.indexedDB.open("logs");
|
const req = this.indexedDB.open("logs");
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
req.onsuccess = (event) => {
|
req.onsuccess = (event: Event) => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
this.db = event.target.result;
|
this.db = event.target.result;
|
||||||
// Periodically flush logs to local storage / indexeddb
|
// Periodically flush logs to local storage / indexeddb
|
||||||
@@ -206,7 +217,7 @@ export class IndexedDBLogStore {
|
|||||||
*
|
*
|
||||||
* @return {Promise} Resolved when the logs have been flushed.
|
* @return {Promise} Resolved when the logs have been flushed.
|
||||||
*/
|
*/
|
||||||
flush() {
|
public flush(): Promise<void> {
|
||||||
// check if a flush() operation is ongoing
|
// check if a flush() operation is ongoing
|
||||||
if (this.flushPromise) {
|
if (this.flushPromise) {
|
||||||
if (this.flushAgainPromise) {
|
if (this.flushAgainPromise) {
|
||||||
@@ -225,7 +236,7 @@ export class IndexedDBLogStore {
|
|||||||
}
|
}
|
||||||
// there is no flush promise or there was but it has finished, so do
|
// 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.
|
// a brand new one, destroying the chain which may have been built up.
|
||||||
this.flushPromise = new Promise((resolve, reject) => {
|
this.flushPromise = new Promise<void>((resolve, reject) => {
|
||||||
if (!this.db) {
|
if (!this.db) {
|
||||||
// not connected yet or user rejected access for us to r/w to the db.
|
// not connected yet or user rejected access for us to r/w to the db.
|
||||||
reject(new Error("No connected database"));
|
reject(new Error("No connected database"));
|
||||||
@@ -243,6 +254,7 @@ export class IndexedDBLogStore {
|
|||||||
};
|
};
|
||||||
txn.onerror = (event) => {
|
txn.onerror = (event) => {
|
||||||
logger.error("Failed to flush logs : ", event);
|
logger.error("Failed to flush logs : ", event);
|
||||||
|
// @ts-ignore
|
||||||
reject(new Error("Failed to write logs: " + event.target.errorCode));
|
reject(new Error("Failed to write logs: " + event.target.errorCode));
|
||||||
};
|
};
|
||||||
objStore.add(this.generateLogEntry(lines));
|
objStore.add(this.generateLogEntry(lines));
|
||||||
@@ -264,12 +276,12 @@ export class IndexedDBLogStore {
|
|||||||
* log ID). The objects have said log ID in an "id" field and "lines" which
|
* 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.
|
* is a big string with all the new-line delimited logs.
|
||||||
*/
|
*/
|
||||||
async consume() {
|
public async consume(): Promise<LogEntry[]> {
|
||||||
const db = this.db;
|
const db = this.db;
|
||||||
|
|
||||||
// Returns: a string representing the concatenated logs for this ID.
|
// Returns: a string representing the concatenated logs for this ID.
|
||||||
// Stops adding log fragments when the size exceeds maxSize
|
// Stops adding log fragments when the size exceeds maxSize
|
||||||
function fetchLogs(id, maxSize) {
|
function fetchLogs(id: string, maxSize: number): Promise<string> {
|
||||||
const objectStore = db
|
const objectStore = db
|
||||||
.transaction("logs", "readonly")
|
.transaction("logs", "readonly")
|
||||||
.objectStore("logs");
|
.objectStore("logs");
|
||||||
@@ -280,9 +292,11 @@ export class IndexedDBLogStore {
|
|||||||
.openCursor(IDBKeyRange.only(id), "prev");
|
.openCursor(IDBKeyRange.only(id), "prev");
|
||||||
let lines = "";
|
let lines = "";
|
||||||
query.onerror = (event) => {
|
query.onerror = (event) => {
|
||||||
|
// @ts-ignore
|
||||||
reject(new Error("Query failed: " + event.target.errorCode));
|
reject(new Error("Query failed: " + event.target.errorCode));
|
||||||
};
|
};
|
||||||
query.onsuccess = (event) => {
|
query.onsuccess = (event) => {
|
||||||
|
// @ts-ignore
|
||||||
const cursor = event.target.result;
|
const cursor = event.target.result;
|
||||||
if (!cursor) {
|
if (!cursor) {
|
||||||
resolve(lines);
|
resolve(lines);
|
||||||
@@ -299,12 +313,12 @@ export class IndexedDBLogStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Returns: A sorted array of log IDs. (newest first)
|
// Returns: A sorted array of log IDs. (newest first)
|
||||||
function fetchLogIds() {
|
function fetchLogIds(): Promise<string[]> {
|
||||||
// To gather all the log IDs, query for all records in logslastmod.
|
// To gather all the log IDs, query for all records in logslastmod.
|
||||||
const o = db
|
const o = db
|
||||||
.transaction("logslastmod", "readonly")
|
.transaction("logslastmod", "readonly")
|
||||||
.objectStore("logslastmod");
|
.objectStore("logslastmod");
|
||||||
return selectQuery(o, undefined, (cursor) => {
|
return selectQuery<{ ts: number; id: string }>(o, undefined, (cursor) => {
|
||||||
return {
|
return {
|
||||||
id: cursor.value.id,
|
id: cursor.value.id,
|
||||||
ts: cursor.value.ts,
|
ts: cursor.value.ts,
|
||||||
@@ -319,13 +333,14 @@ export class IndexedDBLogStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteLogs(id) {
|
function deleteLogs(id: number): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const txn = db.transaction(["logs", "logslastmod"], "readwrite");
|
const txn = db.transaction(["logs", "logslastmod"], "readwrite");
|
||||||
const o = txn.objectStore("logs");
|
const o = txn.objectStore("logs");
|
||||||
// only load the key path, not the data which may be huge
|
// only load the key path, not the data which may be huge
|
||||||
const query = o.index("id").openKeyCursor(IDBKeyRange.only(id));
|
const query = o.index("id").openKeyCursor(IDBKeyRange.only(id));
|
||||||
query.onsuccess = (event) => {
|
query.onsuccess = (event) => {
|
||||||
|
// @ts-ignore
|
||||||
const cursor = event.target.result;
|
const cursor = event.target.result;
|
||||||
if (!cursor) {
|
if (!cursor) {
|
||||||
return;
|
return;
|
||||||
@@ -340,6 +355,7 @@ export class IndexedDBLogStore {
|
|||||||
reject(
|
reject(
|
||||||
new Error(
|
new Error(
|
||||||
"Failed to delete logs for " +
|
"Failed to delete logs for " +
|
||||||
|
// @ts-ignore
|
||||||
`'${id}' : ${event.target.errorCode}`
|
`'${id}' : ${event.target.errorCode}`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -352,7 +368,7 @@ export class IndexedDBLogStore {
|
|||||||
|
|
||||||
const allLogIds = await fetchLogIds();
|
const allLogIds = await fetchLogIds();
|
||||||
let removeLogIds = [];
|
let removeLogIds = [];
|
||||||
const logs = [];
|
const logs: LogEntry[] = [];
|
||||||
let size = 0;
|
let size = 0;
|
||||||
for (let i = 0; i < allLogIds.length; i++) {
|
for (let i = 0; i < allLogIds.length; i++) {
|
||||||
const lines = await fetchLogs(allLogIds[i], MAX_LOG_SIZE - size);
|
const lines = await fetchLogs(allLogIds[i], MAX_LOG_SIZE - size);
|
||||||
@@ -390,7 +406,7 @@ export class IndexedDBLogStore {
|
|||||||
return logs;
|
return logs;
|
||||||
}
|
}
|
||||||
|
|
||||||
generateLogEntry(lines) {
|
private generateLogEntry(lines: string): LogEntry {
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
lines: lines,
|
lines: lines,
|
||||||
@@ -398,7 +414,7 @@ export class IndexedDBLogStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
generateLastModifiedTime() {
|
private generateLastModifiedTime(): { id: string; ts: number } {
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
@@ -416,7 +432,11 @@ export class IndexedDBLogStore {
|
|||||||
* @return {Promise<T[]>} Resolves to an array of whatever you returned from
|
* @return {Promise<T[]>} Resolves to an array of whatever you returned from
|
||||||
* resultMapper.
|
* resultMapper.
|
||||||
*/
|
*/
|
||||||
function selectQuery(store, keyRange, resultMapper) {
|
function selectQuery<T>(
|
||||||
|
store: IDBObjectStore,
|
||||||
|
keyRange: IDBKeyRange,
|
||||||
|
resultMapper: (cursor: IDBCursorWithValue) => T
|
||||||
|
): Promise<T[]> {
|
||||||
const query = store.openCursor(keyRange);
|
const query = store.openCursor(keyRange);
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -437,6 +457,16 @@ function selectQuery(store, keyRange, resultMapper) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var, camelcase
|
||||||
|
var mx_rage_store: IndexedDBLogStore;
|
||||||
|
// eslint-disable-next-line no-var, camelcase
|
||||||
|
var mx_rage_logger: ConsoleLogger;
|
||||||
|
// eslint-disable-next-line no-var, camelcase
|
||||||
|
var mx_rage_initPromise: Promise<void>;
|
||||||
|
// eslint-disable-next-line no-var, camelcase
|
||||||
|
var mx_rage_initStoragePromise: Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure rage shaking support for sending bug reports.
|
* Configure rage shaking support for sending bug reports.
|
||||||
@@ -445,7 +475,7 @@ function selectQuery(store, keyRange, resultMapper) {
|
|||||||
* be set up immediately for the logs.
|
* be set up immediately for the logs.
|
||||||
* @return {Promise} Resolves when set up.
|
* @return {Promise} Resolves when set up.
|
||||||
*/
|
*/
|
||||||
export function init(setUpPersistence = true) {
|
export function init(setUpPersistence = true): Promise<void> {
|
||||||
if (global.mx_rage_initPromise) {
|
if (global.mx_rage_initPromise) {
|
||||||
return global.mx_rage_initPromise;
|
return global.mx_rage_initPromise;
|
||||||
}
|
}
|
||||||
@@ -465,7 +495,7 @@ export function init(setUpPersistence = true) {
|
|||||||
* then this no-ops.
|
* then this no-ops.
|
||||||
* @return {Promise} Resolves when complete.
|
* @return {Promise} Resolves when complete.
|
||||||
*/
|
*/
|
||||||
export function tryInitStorage() {
|
export function tryInitStorage(): Promise<void> {
|
||||||
if (global.mx_rage_initStoragePromise) {
|
if (global.mx_rage_initStoragePromise) {
|
||||||
return global.mx_rage_initStoragePromise;
|
return global.mx_rage_initStoragePromise;
|
||||||
}
|
}
|
||||||
@@ -491,7 +521,7 @@ export function tryInitStorage() {
|
|||||||
return global.mx_rage_initStoragePromise;
|
return global.mx_rage_initStoragePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function flush() {
|
export function flush(): Promise<void> {
|
||||||
if (!global.mx_rage_store) {
|
if (!global.mx_rage_store) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -502,7 +532,7 @@ export function flush() {
|
|||||||
* Clean up old logs.
|
* Clean up old logs.
|
||||||
* @return {Promise} Resolves if cleaned logs.
|
* @return {Promise} Resolves if cleaned logs.
|
||||||
*/
|
*/
|
||||||
export async function cleanup() {
|
export async function cleanup(): Promise<void> {
|
||||||
if (!global.mx_rage_store) {
|
if (!global.mx_rage_store) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -512,9 +542,9 @@ export async function cleanup() {
|
|||||||
/**
|
/**
|
||||||
* Get a recent snapshot of the logs, ready for attaching to a bug report
|
* Get a recent snapshot of the logs, ready for attaching to a bug report
|
||||||
*
|
*
|
||||||
* @return {Array<{lines: string, id, string}>} list of log data
|
* @return {LogEntry[]} list of log data
|
||||||
*/
|
*/
|
||||||
export async function getLogsForReport() {
|
export async function getLogsForReport(): Promise<LogEntry[]> {
|
||||||
if (!global.mx_rage_logger) {
|
if (!global.mx_rage_logger) {
|
||||||
throw new Error("No console logger, did you forget to call init()?");
|
throw new Error("No console logger, did you forget to call init()?");
|
||||||
}
|
}
|
||||||
@@ -523,7 +553,7 @@ export async function getLogsForReport() {
|
|||||||
if (global.mx_rage_store) {
|
if (global.mx_rage_store) {
|
||||||
// flush most recent logs
|
// flush most recent logs
|
||||||
await global.mx_rage_store.flush();
|
await global.mx_rage_store.flush();
|
||||||
return await global.mx_rage_store.consume();
|
return global.mx_rage_store.consume();
|
||||||
} else {
|
} else {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -533,3 +563,24 @@ export async function getLogsForReport() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StringifyReplacer = (
|
||||||
|
this: unknown,
|
||||||
|
key: string,
|
||||||
|
value: unknown
|
||||||
|
) => unknown;
|
||||||
|
|
||||||
|
// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value#circular_references
|
||||||
|
// Injects `<$ cycle-trimmed $>` wherever it cuts a cyclical object relationship
|
||||||
|
const getCircularReplacer = (): StringifyReplacer => {
|
||||||
|
const seen = new WeakSet();
|
||||||
|
return (key: string, value: unknown): unknown => {
|
||||||
|
if (typeof value === "object" && value !== null) {
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return "<$ cycle-trimmed $>";
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -15,14 +15,31 @@ limitations under the License.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useContext, useEffect, useState } from "react";
|
import { useCallback, useContext, useEffect, useState } from "react";
|
||||||
import { getLogsForReport } from "./rageshake";
|
|
||||||
import pako from "pako";
|
import pako from "pako";
|
||||||
|
import { MatrixEvent } from "matrix-js-sdk";
|
||||||
|
import { OverlayTriggerState } from "@react-stately/overlays";
|
||||||
|
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
|
||||||
|
|
||||||
|
import { getLogsForReport } from "./rageshake";
|
||||||
import { useClient } from "../ClientContext";
|
import { useClient } from "../ClientContext";
|
||||||
import { InspectorContext } from "../room/GroupCallInspector";
|
import { InspectorContext } from "../room/GroupCallInspector";
|
||||||
import { useModalTriggerState } from "../Modal";
|
import { useModalTriggerState } from "../Modal";
|
||||||
|
|
||||||
export function useSubmitRageshake() {
|
interface RageShakeSubmitOptions {
|
||||||
const { client } = useClient();
|
description: string;
|
||||||
|
roomId: string;
|
||||||
|
label: string;
|
||||||
|
sendLogs: boolean;
|
||||||
|
rageshakeRequestId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubmitRageshake(): {
|
||||||
|
submitRageshake: (opts: RageShakeSubmitOptions) => Promise<void>;
|
||||||
|
sending: boolean;
|
||||||
|
sent: boolean;
|
||||||
|
error: Error;
|
||||||
|
} {
|
||||||
|
const client: MatrixClient = useClient().client;
|
||||||
const [{ json }] = useContext(InspectorContext);
|
const [{ json }] = useContext(InspectorContext);
|
||||||
|
|
||||||
const [{ sending, sent, error }, setState] = useState({
|
const [{ sending, sent, error }, setState] = useState({
|
||||||
@@ -57,9 +74,12 @@ export function useSubmitRageshake() {
|
|||||||
opts.description || "User did not supply any additional text."
|
opts.description || "User did not supply any additional text."
|
||||||
);
|
);
|
||||||
body.append("app", "matrix-video-chat");
|
body.append("app", "matrix-video-chat");
|
||||||
body.append("version", import.meta.env.VITE_APP_VERSION || "dev");
|
body.append(
|
||||||
|
"version",
|
||||||
|
(import.meta.env.VITE_APP_VERSION as string) || "dev"
|
||||||
|
);
|
||||||
body.append("user_agent", userAgent);
|
body.append("user_agent", userAgent);
|
||||||
body.append("installed_pwa", false);
|
body.append("installed_pwa", "false");
|
||||||
body.append("touch_input", touchInput);
|
body.append("touch_input", touchInput);
|
||||||
|
|
||||||
if (client) {
|
if (client) {
|
||||||
@@ -181,7 +201,11 @@ export function useSubmitRageshake() {
|
|||||||
|
|
||||||
if (navigator.storage && navigator.storage.estimate) {
|
if (navigator.storage && navigator.storage.estimate) {
|
||||||
try {
|
try {
|
||||||
const estimate = await navigator.storage.estimate();
|
const estimate: {
|
||||||
|
quota?: number;
|
||||||
|
usage?: number;
|
||||||
|
usageDetails?: { [x: string]: unknown };
|
||||||
|
} = await navigator.storage.estimate();
|
||||||
body.append("storageManager_quota", String(estimate.quota));
|
body.append("storageManager_quota", String(estimate.quota));
|
||||||
body.append("storageManager_usage", String(estimate.usage));
|
body.append("storageManager_usage", String(estimate.usage));
|
||||||
if (estimate.usageDetails) {
|
if (estimate.usageDetails) {
|
||||||
@@ -201,7 +225,6 @@ export function useSubmitRageshake() {
|
|||||||
for (const entry of logs) {
|
for (const entry of logs) {
|
||||||
// encode as UTF-8
|
// encode as UTF-8
|
||||||
let buf = new TextEncoder().encode(entry.lines);
|
let buf = new TextEncoder().encode(entry.lines);
|
||||||
|
|
||||||
// compress
|
// compress
|
||||||
buf = pako.gzip(buf);
|
buf = pako.gzip(buf);
|
||||||
|
|
||||||
@@ -225,7 +248,7 @@ export function useSubmitRageshake() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await fetch(
|
await fetch(
|
||||||
import.meta.env.VITE_RAGESHAKE_SUBMIT_URL ||
|
(import.meta.env.VITE_RAGESHAKE_SUBMIT_URL as string) ||
|
||||||
"https://element.io/bugreports/submit",
|
"https://element.io/bugreports/submit",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -250,7 +273,7 @@ export function useSubmitRageshake() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDownloadDebugLog() {
|
export function useDownloadDebugLog(): () => void {
|
||||||
const [{ json }] = useContext(InspectorContext);
|
const [{ json }] = useContext(InspectorContext);
|
||||||
|
|
||||||
const downloadDebugLog = useCallback(() => {
|
const downloadDebugLog = useCallback(() => {
|
||||||
@@ -271,7 +294,10 @@ export function useDownloadDebugLog() {
|
|||||||
return downloadDebugLog;
|
return downloadDebugLog;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRageshakeRequest() {
|
export function useRageshakeRequest(): (
|
||||||
|
roomId: string,
|
||||||
|
rageshakeRequestId: string
|
||||||
|
) => void {
|
||||||
const { client } = useClient();
|
const { client } = useClient();
|
||||||
|
|
||||||
const sendRageshakeRequest = useCallback(
|
const sendRageshakeRequest = useCallback(
|
||||||
@@ -285,14 +311,27 @@ export function useRageshakeRequest() {
|
|||||||
|
|
||||||
return sendRageshakeRequest;
|
return sendRageshakeRequest;
|
||||||
}
|
}
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
interface ModalPropsWithId extends ModalProps {
|
||||||
|
rageshakeRequestId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function useRageshakeRequestModal(roomId) {
|
export function useRageshakeRequestModal(roomId: string): {
|
||||||
const { modalState, modalProps } = useModalTriggerState();
|
modalState: OverlayTriggerState;
|
||||||
const { client } = useClient();
|
modalProps: ModalPropsWithId;
|
||||||
const [rageshakeRequestId, setRageshakeRequestId] = useState();
|
} {
|
||||||
|
const { modalState, modalProps } = useModalTriggerState() as {
|
||||||
|
modalState: OverlayTriggerState;
|
||||||
|
modalProps: ModalProps;
|
||||||
|
};
|
||||||
|
const client: MatrixClient = useClient().client;
|
||||||
|
const [rageshakeRequestId, setRageshakeRequestId] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onEvent = (event) => {
|
const onEvent = (event: MatrixEvent) => {
|
||||||
const type = event.getType();
|
const type = event.getType();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -305,10 +344,10 @@ export function useRageshakeRequestModal(roomId) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
client.on("event", onEvent);
|
client.on(ClientEvent.Event, onEvent);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
client.removeListener("event", onEvent);
|
client.removeListener(ClientEvent.Event, onEvent);
|
||||||
};
|
};
|
||||||
}, [modalState.open, roomId, client, modalState]);
|
}, [modalState.open, roomId, client, modalState]);
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||||
/*
|
/*
|
||||||
Copyright 2022 Matrix.org Foundation C.I.C.
|
Copyright 2022 Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { MatrixClient } from "matrix-js-sdk";
|
||||||
|
import { MediaHandlerEvent } from "matrix-js-sdk/src/webrtc/mediaHandler";
|
||||||
import React, {
|
import React, {
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -23,9 +26,27 @@ import React, {
|
|||||||
createContext,
|
createContext,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
const MediaHandlerContext = createContext();
|
export interface MediaHandlerContextInterface {
|
||||||
|
audioInput: string;
|
||||||
|
audioInputs: MediaDeviceInfo[];
|
||||||
|
setAudioInput: (deviceId: string) => void;
|
||||||
|
videoInput: string;
|
||||||
|
videoInputs: MediaDeviceInfo[];
|
||||||
|
setVideoInput: (deviceId: string) => void;
|
||||||
|
audioOutput: string;
|
||||||
|
audioOutputs: MediaDeviceInfo[];
|
||||||
|
setAudioOutput: (deviceId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
function getMediaPreferences() {
|
const MediaHandlerContext =
|
||||||
|
createContext<MediaHandlerContextInterface>(undefined);
|
||||||
|
|
||||||
|
interface MediaPreferences {
|
||||||
|
audioInput?: string;
|
||||||
|
videoInput?: string;
|
||||||
|
audioOutput?: string;
|
||||||
|
}
|
||||||
|
function getMediaPreferences(): MediaPreferences {
|
||||||
const mediaPreferences = localStorage.getItem("matrix-media-preferences");
|
const mediaPreferences = localStorage.getItem("matrix-media-preferences");
|
||||||
|
|
||||||
if (mediaPreferences) {
|
if (mediaPreferences) {
|
||||||
@@ -39,8 +60,8 @@ function getMediaPreferences() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMediaPreferences(newPreferences) {
|
function updateMediaPreferences(newPreferences: MediaPreferences): void {
|
||||||
const oldPreferences = getMediaPreferences(newPreferences);
|
const oldPreferences = getMediaPreferences();
|
||||||
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"matrix-media-preferences",
|
"matrix-media-preferences",
|
||||||
@@ -50,8 +71,11 @@ function updateMediaPreferences(newPreferences) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
interface Props {
|
||||||
export function MediaHandlerProvider({ client, children }) {
|
client: MatrixClient;
|
||||||
|
children: JSX.Element[];
|
||||||
|
}
|
||||||
|
export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
|
||||||
const [
|
const [
|
||||||
{
|
{
|
||||||
audioInput,
|
audioInput,
|
||||||
@@ -72,7 +96,9 @@ export function MediaHandlerProvider({ client, children }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
// @ts-ignore, ignore that audioInput is a private members of mediaHandler
|
||||||
audioInput: mediaHandler.audioInput,
|
audioInput: mediaHandler.audioInput,
|
||||||
|
// @ts-ignore, ignore that videoInput is a private members of mediaHandler
|
||||||
videoInput: mediaHandler.videoInput,
|
videoInput: mediaHandler.videoInput,
|
||||||
audioOutput: undefined,
|
audioOutput: undefined,
|
||||||
audioInputs: [],
|
audioInputs: [],
|
||||||
@@ -84,7 +110,7 @@ export function MediaHandlerProvider({ client, children }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mediaHandler = client.getMediaHandler();
|
const mediaHandler = client.getMediaHandler();
|
||||||
|
|
||||||
function updateDevices() {
|
function updateDevices(): void {
|
||||||
navigator.mediaDevices.enumerateDevices().then((devices) => {
|
navigator.mediaDevices.enumerateDevices().then((devices) => {
|
||||||
const mediaPreferences = getMediaPreferences();
|
const mediaPreferences = getMediaPreferences();
|
||||||
|
|
||||||
@@ -92,9 +118,10 @@ export function MediaHandlerProvider({ client, children }) {
|
|||||||
(device) => device.kind === "audioinput"
|
(device) => device.kind === "audioinput"
|
||||||
);
|
);
|
||||||
const audioConnected = audioInputs.some(
|
const audioConnected = audioInputs.some(
|
||||||
|
// @ts-ignore
|
||||||
(device) => device.deviceId === mediaHandler.audioInput
|
(device) => device.deviceId === mediaHandler.audioInput
|
||||||
);
|
);
|
||||||
|
// @ts-ignore
|
||||||
let audioInput = mediaHandler.audioInput;
|
let audioInput = mediaHandler.audioInput;
|
||||||
|
|
||||||
if (!audioConnected && audioInputs.length > 0) {
|
if (!audioConnected && audioInputs.length > 0) {
|
||||||
@@ -105,9 +132,11 @@ export function MediaHandlerProvider({ client, children }) {
|
|||||||
(device) => device.kind === "videoinput"
|
(device) => device.kind === "videoinput"
|
||||||
);
|
);
|
||||||
const videoConnected = videoInputs.some(
|
const videoConnected = videoInputs.some(
|
||||||
|
// @ts-ignore
|
||||||
(device) => device.deviceId === mediaHandler.videoInput
|
(device) => device.deviceId === mediaHandler.videoInput
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
let videoInput = mediaHandler.videoInput;
|
let videoInput = mediaHandler.videoInput;
|
||||||
|
|
||||||
if (!videoConnected && videoInputs.length > 0) {
|
if (!videoConnected && videoInputs.length > 0) {
|
||||||
@@ -129,7 +158,9 @@ export function MediaHandlerProvider({ client, children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
// @ts-ignore
|
||||||
mediaHandler.videoInput !== videoInput ||
|
mediaHandler.videoInput !== videoInput ||
|
||||||
|
// @ts-ignore
|
||||||
mediaHandler.audioInput !== audioInput
|
mediaHandler.audioInput !== audioInput
|
||||||
) {
|
) {
|
||||||
mediaHandler.setMediaInputs(audioInput, videoInput);
|
mediaHandler.setMediaInputs(audioInput, videoInput);
|
||||||
@@ -149,18 +180,21 @@ export function MediaHandlerProvider({ client, children }) {
|
|||||||
}
|
}
|
||||||
updateDevices();
|
updateDevices();
|
||||||
|
|
||||||
mediaHandler.on("local_streams_changed", updateDevices);
|
mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, updateDevices);
|
||||||
navigator.mediaDevices.addEventListener("devicechange", updateDevices);
|
navigator.mediaDevices.addEventListener("devicechange", updateDevices);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mediaHandler.removeListener("local_streams_changed", updateDevices);
|
mediaHandler.removeListener(
|
||||||
|
MediaHandlerEvent.LocalStreamsChanged,
|
||||||
|
updateDevices
|
||||||
|
);
|
||||||
navigator.mediaDevices.removeEventListener("devicechange", updateDevices);
|
navigator.mediaDevices.removeEventListener("devicechange", updateDevices);
|
||||||
mediaHandler.stopAllStreams();
|
mediaHandler.stopAllStreams();
|
||||||
};
|
};
|
||||||
}, [client]);
|
}, [client]);
|
||||||
|
|
||||||
const setAudioInput = useCallback(
|
const setAudioInput: (deviceId: string) => void = useCallback(
|
||||||
(deviceId) => {
|
(deviceId: string) => {
|
||||||
updateMediaPreferences({ audioInput: deviceId });
|
updateMediaPreferences({ audioInput: deviceId });
|
||||||
setState((prevState) => ({ ...prevState, audioInput: deviceId }));
|
setState((prevState) => ({ ...prevState, audioInput: deviceId }));
|
||||||
client.getMediaHandler().setAudioInput(deviceId);
|
client.getMediaHandler().setAudioInput(deviceId);
|
||||||
@@ -168,7 +202,7 @@ export function MediaHandlerProvider({ client, children }) {
|
|||||||
[client]
|
[client]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setVideoInput = useCallback(
|
const setVideoInput: (deviceId: string) => void = useCallback(
|
||||||
(deviceId) => {
|
(deviceId) => {
|
||||||
updateMediaPreferences({ videoInput: deviceId });
|
updateMediaPreferences({ videoInput: deviceId });
|
||||||
setState((prevState) => ({ ...prevState, videoInput: deviceId }));
|
setState((prevState) => ({ ...prevState, videoInput: deviceId }));
|
||||||
@@ -177,35 +211,36 @@ export function MediaHandlerProvider({ client, children }) {
|
|||||||
[client]
|
[client]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setAudioOutput = useCallback((deviceId) => {
|
const setAudioOutput: (deviceId: string) => void = useCallback((deviceId) => {
|
||||||
updateMediaPreferences({ audioOutput: deviceId });
|
updateMediaPreferences({ audioOutput: deviceId });
|
||||||
setState((prevState) => ({ ...prevState, audioOutput: deviceId }));
|
setState((prevState) => ({ ...prevState, audioOutput: deviceId }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const context = useMemo(
|
const context: MediaHandlerContextInterface =
|
||||||
() => ({
|
useMemo<MediaHandlerContextInterface>(
|
||||||
audioInput,
|
() => ({
|
||||||
audioInputs,
|
audioInput,
|
||||||
setAudioInput,
|
audioInputs,
|
||||||
videoInput,
|
setAudioInput,
|
||||||
videoInputs,
|
videoInput,
|
||||||
setVideoInput,
|
videoInputs,
|
||||||
audioOutput,
|
setVideoInput,
|
||||||
audioOutputs,
|
audioOutput,
|
||||||
setAudioOutput,
|
audioOutputs,
|
||||||
}),
|
setAudioOutput,
|
||||||
[
|
}),
|
||||||
audioInput,
|
[
|
||||||
audioInputs,
|
audioInput,
|
||||||
setAudioInput,
|
audioInputs,
|
||||||
videoInput,
|
setAudioInput,
|
||||||
videoInputs,
|
videoInput,
|
||||||
setVideoInput,
|
videoInputs,
|
||||||
audioOutput,
|
setVideoInput,
|
||||||
audioOutputs,
|
audioOutput,
|
||||||
setAudioOutput,
|
audioOutputs,
|
||||||
]
|
setAudioOutput,
|
||||||
);
|
]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MediaHandlerContext.Provider value={context}>
|
<MediaHandlerContext.Provider value={context}>
|
||||||
@@ -42,7 +42,7 @@ export const PTTClips: React.FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<audio
|
<audio
|
||||||
preload="true"
|
preload="auto"
|
||||||
className={styles.pttClip}
|
className={styles.pttClip}
|
||||||
ref={startTalkingLocalRef}
|
ref={startTalkingLocalRef}
|
||||||
>
|
>
|
||||||
@@ -50,18 +50,18 @@ export const PTTClips: React.FC<Props> = ({
|
|||||||
<source type="audio/mpeg" src={startTalkLocalMp3Url} />
|
<source type="audio/mpeg" src={startTalkLocalMp3Url} />
|
||||||
</audio>
|
</audio>
|
||||||
<audio
|
<audio
|
||||||
preload="true"
|
preload="auto"
|
||||||
className={styles.pttClip}
|
className={styles.pttClip}
|
||||||
ref={startTalkingRemoteRef}
|
ref={startTalkingRemoteRef}
|
||||||
>
|
>
|
||||||
<source type="audio/ogg" src={startTalkRemoteOggUrl} />
|
<source type="audio/ogg" src={startTalkRemoteOggUrl} />
|
||||||
<source type="audio/mpeg" src={startTalkRemoteMp3Url} />
|
<source type="audio/mpeg" src={startTalkRemoteMp3Url} />
|
||||||
</audio>
|
</audio>
|
||||||
<audio preload="true" className={styles.pttClip} ref={endTalkingRef}>
|
<audio preload="auto" className={styles.pttClip} ref={endTalkingRef}>
|
||||||
<source type="audio/ogg" src={endTalkOggUrl} />
|
<source type="audio/ogg" src={endTalkOggUrl} />
|
||||||
<source type="audio/mpeg" src={endTalkMp3Url} />
|
<source type="audio/mpeg" src={endTalkMp3Url} />
|
||||||
</audio>
|
</audio>
|
||||||
<audio preload="true" className={styles.pttClip} ref={blockedRef}>
|
<audio preload="auto" className={styles.pttClip} ref={blockedRef}>
|
||||||
<source type="audio/ogg" src={blockedOggUrl} />
|
<source type="audio/ogg" src={blockedOggUrl} />
|
||||||
<source type="audio/mpeg" src={blockedMp3Url} />
|
<source type="audio/mpeg" src={blockedMp3Url} />
|
||||||
</audio>
|
</audio>
|
||||||
|
|||||||
@@ -58,8 +58,12 @@ export const usePTTSounds = (): PTTSounds => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
ref.current.currentTime = 0;
|
try {
|
||||||
await ref.current.play();
|
ref.current.currentTime = 0;
|
||||||
|
await ref.current.play();
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Couldn't play sound effect", e);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("No media element found");
|
console.log("No media element found");
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/useDelayedState.ts
Normal file
49
src/useDelayedState.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
Copyright 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
// Like useState, except state updates can be enqueued with a configurable delay
|
||||||
|
export const useDelayedState = <T>(
|
||||||
|
initial?: T
|
||||||
|
): [T, (value: T, delay: number) => void, (value: T) => void] => {
|
||||||
|
const [state, setState] = useState<T>(initial);
|
||||||
|
const timers = useRef<Set<ReturnType<typeof setTimeout>>>();
|
||||||
|
if (!timers.current) timers.current = new Set();
|
||||||
|
|
||||||
|
const setStateDelayed = useCallback(
|
||||||
|
(value: T, delay: number) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setState(value);
|
||||||
|
timers.current.delete(timer);
|
||||||
|
}, delay);
|
||||||
|
timers.current.add(timer);
|
||||||
|
},
|
||||||
|
[setState, timers]
|
||||||
|
);
|
||||||
|
const setStateImmediate = useCallback(
|
||||||
|
(value: T) => {
|
||||||
|
// Clear all updates currently in the queue
|
||||||
|
for (const timer of timers.current) clearTimeout(timer);
|
||||||
|
timers.current.clear();
|
||||||
|
|
||||||
|
setState(value);
|
||||||
|
},
|
||||||
|
[setState, timers]
|
||||||
|
);
|
||||||
|
|
||||||
|
return [state, setStateDelayed, setStateImmediate];
|
||||||
|
};
|
||||||
34
src/useEvents.ts
Normal file
34
src/useEvents.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
Copyright 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
// Shortcut for registering a listener on an EventTarget
|
||||||
|
export const useEventTarget = <T extends Event>(
|
||||||
|
target: EventTarget,
|
||||||
|
eventType: string,
|
||||||
|
listener: (event: T) => void,
|
||||||
|
options?: AddEventListenerOptions
|
||||||
|
) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (target) {
|
||||||
|
target.addEventListener(eventType, listener, options);
|
||||||
|
return () => target.removeEventListener(eventType, listener, options);
|
||||||
|
}
|
||||||
|
}, [target, eventType, listener, options]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Have a similar hook for EventEmitters
|
||||||
@@ -29,6 +29,7 @@ export function VideoTileContainer({
|
|||||||
showName,
|
showName,
|
||||||
audioOutputDevice,
|
audioOutputDevice,
|
||||||
audioContext,
|
audioContext,
|
||||||
|
audioDestination,
|
||||||
disableSpeakingIndicator,
|
disableSpeakingIndicator,
|
||||||
...rest
|
...rest
|
||||||
}) {
|
}) {
|
||||||
@@ -47,6 +48,7 @@ export function VideoTileContainer({
|
|||||||
stream,
|
stream,
|
||||||
audioOutputDevice,
|
audioOutputDevice,
|
||||||
audioContext,
|
audioContext,
|
||||||
|
audioDestination,
|
||||||
isLocal
|
isLocal
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2022 Matrix.org Foundation C.I.C.
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useRef, useEffect } from "react";
|
|
||||||
|
|
||||||
import { useSpatialAudio } from "../settings/useSetting";
|
|
||||||
|
|
||||||
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) {
|
|
||||||
const mediaEl = mediaRef.current;
|
|
||||||
|
|
||||||
if (stream) {
|
|
||||||
mediaEl.muted = mute;
|
|
||||||
mediaEl.srcObject = stream;
|
|
||||||
mediaEl.play();
|
|
||||||
|
|
||||||
// Unmuting the tab in Safari causes all video elements to be individually
|
|
||||||
// unmuted, so we need to reset the mute state here to prevent audio loops
|
|
||||||
const onVolumeChange = () => {
|
|
||||||
mediaEl.muted = mute;
|
|
||||||
};
|
|
||||||
mediaEl.addEventListener("volumechange", onVolumeChange);
|
|
||||||
return () =>
|
|
||||||
mediaEl.removeEventListener("volumechange", onVolumeChange);
|
|
||||||
} else {
|
|
||||||
mediaRef.current.srcObject = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [stream, mute]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
mediaRef.current &&
|
|
||||||
audioOutputDevice &&
|
|
||||||
mediaRef.current !== undefined
|
|
||||||
) {
|
|
||||||
console.log(`useMediaStream setSinkId ${audioOutputDevice}`);
|
|
||||||
// Chrome for Android doesn't support this
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSpatialMediaStream = (
|
|
||||||
stream,
|
|
||||||
audioOutputDevice,
|
|
||||||
audioContext,
|
|
||||||
mute = false
|
|
||||||
) => {
|
|
||||||
const tileRef = useRef();
|
|
||||||
const [spatialAudio] = useSpatialAudio();
|
|
||||||
// If spatial audio is enabled, we handle audio separately from the video element
|
|
||||||
const mediaRef = useMediaStream(
|
|
||||||
stream,
|
|
||||||
audioOutputDevice,
|
|
||||||
spatialAudio || mute
|
|
||||||
);
|
|
||||||
|
|
||||||
const pannerNodeRef = useRef();
|
|
||||||
if (!pannerNodeRef.current) {
|
|
||||||
pannerNodeRef.current = new PannerNode(audioContext, {
|
|
||||||
panningModel: "HRTF",
|
|
||||||
refDistance: 3,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceRef = useRef();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (spatialAudio && tileRef.current && !mute) {
|
|
||||||
if (!sourceRef.current) {
|
|
||||||
sourceRef.current = audioContext.createMediaStreamSource(stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tile = tileRef.current;
|
|
||||||
const source = sourceRef.current;
|
|
||||||
const pannerNode = pannerNodeRef.current;
|
|
||||||
|
|
||||||
const updatePosition = () => {
|
|
||||||
const bounds = tile.getBoundingClientRect();
|
|
||||||
const windowSize = Math.max(window.innerWidth, window.innerHeight);
|
|
||||||
// Position the source relative to its placement in the window
|
|
||||||
pannerNodeRef.current.positionX.value =
|
|
||||||
(bounds.x + bounds.width / 2) / windowSize - 0.5;
|
|
||||||
pannerNodeRef.current.positionY.value =
|
|
||||||
(bounds.y + bounds.height / 2) / windowSize - 0.5;
|
|
||||||
// Put the source in front of the listener
|
|
||||||
pannerNodeRef.current.positionZ.value = -2;
|
|
||||||
};
|
|
||||||
|
|
||||||
updatePosition();
|
|
||||||
source.connect(pannerNode);
|
|
||||||
pannerNode.connect(audioContext.destination);
|
|
||||||
// HACK: We abuse the CSS transitionrun event to detect when the tile
|
|
||||||
// moves, because useMeasure, IntersectionObserver, etc. all have no
|
|
||||||
// ability to track changes in the CSS transform property
|
|
||||||
tile.addEventListener("transitionrun", updatePosition);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
tile.removeEventListener("transitionrun", updatePosition);
|
|
||||||
source.disconnect();
|
|
||||||
pannerNode.disconnect();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [stream, spatialAudio, audioContext, mute]);
|
|
||||||
|
|
||||||
return [tileRef, mediaRef];
|
|
||||||
};
|
|
||||||
248
src/video-grid/useMediaStream.ts
Normal file
248
src/video-grid/useMediaStream.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useRef, useEffect, RefObject } from "react";
|
||||||
|
import { parse as parseSdp, write as writeSdp } from "sdp-transform";
|
||||||
|
import {
|
||||||
|
acquireContext,
|
||||||
|
releaseContext,
|
||||||
|
} from "matrix-js-sdk/src/webrtc/audioContext";
|
||||||
|
|
||||||
|
import { useSpatialAudio } from "../settings/useSetting";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
// For detecting whether this browser is Chrome or not
|
||||||
|
chrome?: unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMediaStream = (
|
||||||
|
stream: MediaStream,
|
||||||
|
audioOutputDevice: string,
|
||||||
|
mute = false
|
||||||
|
): RefObject<MediaElement> => {
|
||||||
|
const mediaRef = useRef<MediaElement>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(
|
||||||
|
`useMediaStream update stream mediaRef.current ${!!mediaRef.current} stream ${
|
||||||
|
stream && stream.id
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mediaRef.current) {
|
||||||
|
const mediaEl = mediaRef.current;
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
mediaEl.muted = mute;
|
||||||
|
mediaEl.srcObject = stream;
|
||||||
|
mediaEl.play();
|
||||||
|
|
||||||
|
// Unmuting the tab in Safari causes all video elements to be individually
|
||||||
|
// unmuted, so we need to reset the mute state here to prevent audio loops
|
||||||
|
const onVolumeChange = () => {
|
||||||
|
mediaEl.muted = mute;
|
||||||
|
};
|
||||||
|
mediaEl.addEventListener("volumechange", onVolumeChange);
|
||||||
|
return () =>
|
||||||
|
mediaEl.removeEventListener("volumechange", onVolumeChange);
|
||||||
|
} else {
|
||||||
|
mediaRef.current.srcObject = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [stream, mute]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
mediaRef.current &&
|
||||||
|
audioOutputDevice &&
|
||||||
|
mediaRef.current !== undefined
|
||||||
|
) {
|
||||||
|
if (mediaRef.current.setSinkId) {
|
||||||
|
console.log(
|
||||||
|
`useMediaStream setting output setSinkId ${audioOutputDevice}`
|
||||||
|
);
|
||||||
|
// Chrome for Android doesn't support this
|
||||||
|
mediaRef.current.setSinkId(audioOutputDevice);
|
||||||
|
} else {
|
||||||
|
console.log("Can't set output - no setsinkid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loops the given audio stream back through a local peer connection, to make
|
||||||
|
// AEC work with Web Audio streams on Chrome. The resulting stream should be
|
||||||
|
// played through an audio element.
|
||||||
|
// This hack can be removed once the following bug is resolved:
|
||||||
|
// https://bugs.chromium.org/p/chromium/issues/detail?id=687574
|
||||||
|
const createLoopback = async (stream: MediaStream): Promise<MediaStream> => {
|
||||||
|
// Prepare our local peer connections
|
||||||
|
const conn = new RTCPeerConnection();
|
||||||
|
const loopbackConn = new RTCPeerConnection();
|
||||||
|
const loopbackStream = new MediaStream();
|
||||||
|
|
||||||
|
conn.addEventListener("icecandidate", ({ candidate }) => {
|
||||||
|
if (candidate) loopbackConn.addIceCandidate(new RTCIceCandidate(candidate));
|
||||||
|
});
|
||||||
|
loopbackConn.addEventListener("icecandidate", ({ candidate }) => {
|
||||||
|
if (candidate) conn.addIceCandidate(new RTCIceCandidate(candidate));
|
||||||
|
});
|
||||||
|
loopbackConn.addEventListener("track", ({ track }) =>
|
||||||
|
loopbackStream.addTrack(track)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hook the connections together
|
||||||
|
stream.getTracks().forEach((track) => conn.addTrack(track));
|
||||||
|
const offer = await conn.createOffer({
|
||||||
|
offerToReceiveAudio: false,
|
||||||
|
offerToReceiveVideo: false,
|
||||||
|
});
|
||||||
|
await conn.setLocalDescription(offer);
|
||||||
|
|
||||||
|
await loopbackConn.setRemoteDescription(offer);
|
||||||
|
const answer = await loopbackConn.createAnswer();
|
||||||
|
// Rewrite SDP to be stereo and (variable) max bitrate
|
||||||
|
const parsedSdp = parseSdp(answer.sdp);
|
||||||
|
parsedSdp.media.forEach((m) =>
|
||||||
|
m.fmtp.forEach(
|
||||||
|
(f) => (f.config += `;stereo=1;cbr=0;maxaveragebitrate=510000;`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
answer.sdp = writeSdp(parsedSdp);
|
||||||
|
|
||||||
|
await loopbackConn.setLocalDescription(answer);
|
||||||
|
await conn.setRemoteDescription(answer);
|
||||||
|
|
||||||
|
return loopbackStream;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAudioContext = (): [
|
||||||
|
AudioContext,
|
||||||
|
AudioNode,
|
||||||
|
RefObject<HTMLAudioElement>
|
||||||
|
] => {
|
||||||
|
const context = useRef<AudioContext>();
|
||||||
|
const destination = useRef<AudioNode>();
|
||||||
|
const audioRef = useRef<HTMLAudioElement>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioRef.current && !context.current) {
|
||||||
|
context.current = acquireContext();
|
||||||
|
|
||||||
|
if (window.chrome) {
|
||||||
|
// We're in Chrome, which needs a loopback hack applied to enable AEC
|
||||||
|
const streamDest = context.current.createMediaStreamDestination();
|
||||||
|
destination.current = streamDest;
|
||||||
|
|
||||||
|
const audioEl = audioRef.current;
|
||||||
|
(async () => {
|
||||||
|
audioEl.srcObject = await createLoopback(streamDest.stream);
|
||||||
|
await audioEl.play();
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
audioEl.srcObject = null;
|
||||||
|
releaseContext();
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
destination.current = context.current.destination;
|
||||||
|
return releaseContext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [context.current, destination.current, audioRef];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSpatialMediaStream = (
|
||||||
|
stream: MediaStream,
|
||||||
|
audioOutputDevice: string,
|
||||||
|
audioContext: AudioContext,
|
||||||
|
audioDestination: AudioNode,
|
||||||
|
mute = false
|
||||||
|
): [RefObject<Element>, RefObject<MediaElement>] => {
|
||||||
|
const tileRef = useRef<Element>();
|
||||||
|
const [spatialAudio] = useSpatialAudio();
|
||||||
|
// If spatial audio is enabled, we handle audio separately from the video element
|
||||||
|
const mediaRef = useMediaStream(
|
||||||
|
stream,
|
||||||
|
audioOutputDevice,
|
||||||
|
spatialAudio || mute
|
||||||
|
);
|
||||||
|
|
||||||
|
const pannerNodeRef = useRef<PannerNode>();
|
||||||
|
const sourceRef = useRef<MediaStreamAudioSourceNode>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (spatialAudio && tileRef.current && !mute) {
|
||||||
|
if (!pannerNodeRef.current) {
|
||||||
|
pannerNodeRef.current = new PannerNode(audioContext, {
|
||||||
|
panningModel: "HRTF",
|
||||||
|
refDistance: 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!sourceRef.current) {
|
||||||
|
sourceRef.current = audioContext.createMediaStreamSource(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tile = tileRef.current;
|
||||||
|
const source = sourceRef.current;
|
||||||
|
const pannerNode = pannerNodeRef.current;
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
const bounds = tile.getBoundingClientRect();
|
||||||
|
const windowSize = Math.max(window.innerWidth, window.innerHeight);
|
||||||
|
// Position the source relative to its placement in the window
|
||||||
|
pannerNodeRef.current.positionX.value =
|
||||||
|
(bounds.x + bounds.width / 2) / windowSize - 0.5;
|
||||||
|
pannerNodeRef.current.positionY.value =
|
||||||
|
(bounds.y + bounds.height / 2) / windowSize - 0.5;
|
||||||
|
// Put the source in front of the listener
|
||||||
|
pannerNodeRef.current.positionZ.value = -2;
|
||||||
|
};
|
||||||
|
|
||||||
|
updatePosition();
|
||||||
|
source.connect(pannerNode).connect(audioDestination);
|
||||||
|
// HACK: We abuse the CSS transitionrun event to detect when the tile
|
||||||
|
// moves, because useMeasure, IntersectionObserver, etc. all have no
|
||||||
|
// ability to track changes in the CSS transform property
|
||||||
|
tile.addEventListener("transitionrun", updatePosition);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
tile.removeEventListener("transitionrun", updatePosition);
|
||||||
|
source.disconnect();
|
||||||
|
pannerNode.disconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [stream, spatialAudio, audioContext, audioDestination, mute]);
|
||||||
|
|
||||||
|
return [tileRef, mediaRef];
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user