Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
984b02700e | ||
|
|
e310392800 | ||
|
|
2cc291dccd | ||
|
|
2dcf043787 | ||
|
|
6b03ae0dc3 | ||
|
|
5dd5668389 | ||
|
|
8380894692 | ||
|
|
94f16b986a | ||
|
|
2928df8b8c | ||
|
|
71a819fcf0 | ||
|
|
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 | ||
|
|
17a31e0904 | ||
|
|
f990530031 | ||
|
|
46f1f0f8e9 | ||
|
|
885e933948 | ||
|
|
9b2e99c559 | ||
|
|
60ed54d6d3 | ||
|
|
939398b277 | ||
|
|
d2c820f080 | ||
|
|
375578177b | ||
|
|
eb9f2ccbaa | ||
|
|
d4b211e678 | ||
|
|
9fc4fbc3e7 | ||
|
|
1f5ac411f6 | ||
|
|
a7748a8492 | ||
|
|
edbcf95ead | ||
|
|
0aa29f775c | ||
|
|
a4a6105bc9 | ||
|
|
23098131b8 | ||
|
|
fdcedb5592 | ||
|
|
17098cf2ab | ||
|
|
7ef3dcc56c | ||
|
|
8a38276f5d | ||
|
|
21ec08ffbd | ||
|
|
1a7211198b | ||
|
|
190c57e853 | ||
|
|
785eca7289 | ||
|
|
2667e78b43 | ||
|
|
878b48aa7a | ||
|
|
b314e047c1 | ||
|
|
93baa19ba1 |
1
.env
1
.env
@@ -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
|
||||||
4
.github/workflows/publish.yaml
vendored
4
.github/workflows/publish.yaml
vendored
@@ -32,10 +32,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@dc7b9719a96d48369863986a06765841d7ea23f6
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
|
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:16-buster as builder
|
FROM --platform=$BUILDPLATFORM node:16-buster as builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"build-storybook": "build-storybook",
|
"build-storybook": "build-storybook",
|
||||||
"prettier:check": "prettier -c src",
|
"prettier:check": "prettier -c src",
|
||||||
"prettier:format": "prettier -w src",
|
"prettier:format": "prettier -w src",
|
||||||
|
"lint": "yarn lint:types && yarn lint:js",
|
||||||
"lint:js": "eslint --max-warnings 0 src",
|
"lint:js": "eslint --max-warnings 0 src",
|
||||||
"lint:types": "tsc"
|
"lint:types": "tsc"
|
||||||
},
|
},
|
||||||
@@ -32,11 +33,12 @@
|
|||||||
"@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#aa0d3bd1f5a006d151f826e6b8c5f286abb6e960",
|
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#9a15094374f52053ca9f833269d2b1c6c7f964d2",
|
||||||
"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",
|
||||||
@@ -49,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -41,6 +41,7 @@ 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 const sizeToClassName = {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
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;
|
||||||
|
|||||||
@@ -39,7 +39,18 @@ export function Field({ children, className, ...rest }) {
|
|||||||
|
|
||||||
export const InputField = forwardRef(
|
export const InputField = forwardRef(
|
||||||
(
|
(
|
||||||
{ id, label, className, type, checked, prefix, suffix, disabled, ...rest },
|
{
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
type,
|
||||||
|
checked,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
description,
|
||||||
|
disabled,
|
||||||
|
...rest
|
||||||
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
@@ -82,6 +93,7 @@ export const InputField = forwardRef(
|
|||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
{suffix && <span>{suffix}</span>}
|
{suffix && <span>{suffix}</span>}
|
||||||
|
{description && <p className={styles.description}>{description}</p>}
|
||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,13 +118,15 @@
|
|||||||
.checkboxField {
|
.checkboxField {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkboxField label {
|
.checkboxField label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
|
line-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkboxField input {
|
.checkboxField input {
|
||||||
@@ -176,3 +178,9 @@
|
|||||||
color: var(--alert);
|
color: var(--alert);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: var(--secondary-content);
|
||||||
|
margin-left: 26px;
|
||||||
|
width: 100%; /* Ensure that it breaks onto the next row */
|
||||||
|
}
|
||||||
|
|||||||
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,9 +1,10 @@
|
|||||||
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";
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
GroupCallType,
|
GroupCallType,
|
||||||
} from "matrix-js-sdk/src/webrtc/groupCall";
|
} 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";
|
||||||
|
|
||||||
@@ -59,14 +61,12 @@ 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: "element-call-sync",
|
||||||
workerFactory: () => new IndexedDBWorker(),
|
workerFactory: () => new IndexedDBWorker(),
|
||||||
});
|
});
|
||||||
}
|
} else if (localStorage) {
|
||||||
|
storeOpts.store = new MemoryStore({ localStorage });
|
||||||
if (localStorage) {
|
|
||||||
storeOpts.sessionStore = new WebStorageSessionStore(localStorage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (indexedDB) {
|
if (indexedDB) {
|
||||||
@@ -74,6 +74,23 @@ export async function initClient(
|
|||||||
indexedDB,
|
indexedDB,
|
||||||
"matrix-js-sdk:crypto"
|
"matrix-js-sdk:crypto"
|
||||||
);
|
);
|
||||||
|
} 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 +100,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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,7 @@ import { useLocationNavigation } from "../useLocationNavigation";
|
|||||||
export function GroupCallView({
|
export function GroupCallView({
|
||||||
client,
|
client,
|
||||||
isPasswordlessUser,
|
isPasswordlessUser,
|
||||||
|
isEmbedded,
|
||||||
roomId,
|
roomId,
|
||||||
groupCall,
|
groupCall,
|
||||||
}) {
|
}) {
|
||||||
@@ -53,6 +54,7 @@ export function GroupCallView({
|
|||||||
screenshareFeeds,
|
screenshareFeeds,
|
||||||
hasLocalParticipant,
|
hasLocalParticipant,
|
||||||
participants,
|
participants,
|
||||||
|
unencryptedEventsFromUsers,
|
||||||
} = useGroupCall(groupCall);
|
} = useGroupCall(groupCall);
|
||||||
|
|
||||||
const avatarUrl = useRoomAvatar(groupCall.room);
|
const avatarUrl = useRoomAvatar(groupCall.room);
|
||||||
@@ -91,6 +93,7 @@ export function GroupCallView({
|
|||||||
participants={participants}
|
participants={participants}
|
||||||
userMediaFeeds={userMediaFeeds}
|
userMediaFeeds={userMediaFeeds}
|
||||||
onLeave={onLeave}
|
onLeave={onLeave}
|
||||||
|
isEmbedded={isEmbedded}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -112,6 +115,7 @@ export function GroupCallView({
|
|||||||
localScreenshareFeed={localScreenshareFeed}
|
localScreenshareFeed={localScreenshareFeed}
|
||||||
screenshareFeeds={screenshareFeeds}
|
screenshareFeeds={screenshareFeeds}
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
|
unencryptedEventsFromUsers={unencryptedEventsFromUsers}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,6 +17,12 @@
|
|||||||
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,29 +50,47 @@ 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,
|
|
||||||
activeTouchID: null,
|
const hold = useCallback(() => {
|
||||||
});
|
// This update is delayed so the user only sees it if latency is significant
|
||||||
const onWindowMouseUp = useCallback(
|
enqueueNetworkWaiting(true, 100);
|
||||||
(e) => {
|
startTalking();
|
||||||
if (isHeld) stopTalking();
|
}, [enqueueNetworkWaiting, startTalking]);
|
||||||
setState({ isHeld: false, activeTouchID: null });
|
const unhold = useCallback(() => {
|
||||||
|
setNetworkWaiting(false);
|
||||||
|
stopTalking();
|
||||||
|
}, [setNetworkWaiting, stopTalking]);
|
||||||
|
|
||||||
|
const onButtonMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hold();
|
||||||
},
|
},
|
||||||
[isHeld, setState, stopTalking]
|
[hold]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onWindowTouchEnd = useCallback(
|
// These listeners go on the window so even if the user's cursor / finger
|
||||||
|
// leaves the button while holding it, the button stays pushed until
|
||||||
|
// they stop clicking / tapping.
|
||||||
|
useEventTarget(window, "mouseup", unhold);
|
||||||
|
useEventTarget(
|
||||||
|
window,
|
||||||
|
"touchend",
|
||||||
|
useCallback(
|
||||||
(e: TouchEvent) => {
|
(e: TouchEvent) => {
|
||||||
// ignore any ended touches that weren't the one pressing the
|
// ignore any ended touches that weren't the one pressing the
|
||||||
// button (bafflingly the TouchList isn't an iterable so we
|
// button (bafflingly the TouchList isn't an iterable so we
|
||||||
// have to do this a really old-school way).
|
// have to do this a really old-school way).
|
||||||
let touchFound = false;
|
let touchFound = false;
|
||||||
for (let i = 0; i < e.changedTouches.length; ++i) {
|
for (let i = 0; i < e.changedTouches.length; ++i) {
|
||||||
if (e.changedTouches.item(i).identifier === activeTouchID) {
|
if (e.changedTouches.item(i).identifier === activeTouchId) {
|
||||||
touchFound = true;
|
touchFound = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -80,59 +98,62 @@ export const PTTButton: React.FC<Props> = ({
|
|||||||
if (!touchFound) return;
|
if (!touchFound) return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (isHeld) stopTalking();
|
unhold();
|
||||||
setState({ isHeld: false, activeTouchID: null });
|
setActiveTouchId(null);
|
||||||
},
|
},
|
||||||
[isHeld, activeTouchID, setState, stopTalking]
|
[unhold, activeTouchId, setActiveTouchId]
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const onButtonMouseDown = useCallback(
|
// This is a native DOM listener too because we want to preventDefault in it
|
||||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
// to stop also getting a click event, so we need it to be non-passive.
|
||||||
e.preventDefault();
|
useEventTarget(
|
||||||
setState({ isHeld: true, activeTouchID: null });
|
buttonRef.current,
|
||||||
startTalking();
|
"touchstart",
|
||||||
},
|
useCallback(
|
||||||
[setState, startTalking]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onButtonTouchStart = useCallback(
|
|
||||||
(e: TouchEvent) => {
|
(e: TouchEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (isHeld) return;
|
hold();
|
||||||
|
setActiveTouchId(e.changedTouches.item(0).identifier);
|
||||||
setState({
|
|
||||||
isHeld: true,
|
|
||||||
activeTouchID: e.changedTouches.item(0).identifier,
|
|
||||||
});
|
|
||||||
startTalking();
|
|
||||||
},
|
},
|
||||||
[isHeld, setState, startTalking]
|
[hold, setActiveTouchId]
|
||||||
|
),
|
||||||
|
{ passive: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEventTarget(
|
||||||
const currentButtonElement = buttonRef.current;
|
window,
|
||||||
|
"keydown",
|
||||||
|
useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.code === "Space") {
|
||||||
|
if (!enabled) return;
|
||||||
|
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
|
}
|
||||||
// they stop clicking / tapping.
|
},
|
||||||
window.addEventListener("mouseup", onWindowMouseUp);
|
[enabled, hold]
|
||||||
window.addEventListener("touchend", onWindowTouchEnd);
|
)
|
||||||
// This is a native DOM listener too because we want to preventDefault in it
|
|
||||||
// to stop also getting a click event, so we need it to be non-passive.
|
|
||||||
currentButtonElement.addEventListener("touchstart", onButtonTouchStart, {
|
|
||||||
passive: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("mouseup", onWindowMouseUp);
|
|
||||||
window.removeEventListener("touchend", onWindowTouchEnd);
|
|
||||||
currentButtonElement.removeEventListener(
|
|
||||||
"touchstart",
|
|
||||||
onButtonTouchStart
|
|
||||||
);
|
);
|
||||||
};
|
useEventTarget(
|
||||||
}, [onWindowMouseUp, onWindowTouchEnd, onButtonTouchStart, buttonRef]);
|
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 +164,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={{
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -128,18 +136,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 +153,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
|
||||||
@@ -169,6 +178,7 @@ export const PTTCallView: React.FC<Props> = ({
|
|||||||
roomName={roomName}
|
roomName={roomName}
|
||||||
avatarUrl={avatarUrl}
|
avatarUrl={avatarUrl}
|
||||||
onPress={onLeave}
|
onPress={onLeave}
|
||||||
|
isEmbedded={isEmbedded}
|
||||||
/>
|
/>
|
||||||
</LeftNav>
|
</LeftNav>
|
||||||
<RightNav />
|
<RightNav />
|
||||||
@@ -196,7 +206,7 @@ export const PTTCallView: React.FC<Props> = ({
|
|||||||
feedbackModalState={feedbackModalState}
|
feedbackModalState={feedbackModalState}
|
||||||
feedbackModalProps={feedbackModalProps}
|
feedbackModalProps={feedbackModalProps}
|
||||||
/>
|
/>
|
||||||
<HangupButton onPress={onLeave} />
|
{!isEmbedded && <HangupButton onPress={onLeave} />}
|
||||||
<InviteButton onPress={() => inviteModalState.open()} />
|
<InviteButton onPress={() => inviteModalState.open()} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -217,6 +227,7 @@ export const PTTCallView: React.FC<Props> = ({
|
|||||||
<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,9 +237,13 @@ 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}>
|
<p className={styles.actionTip}>
|
||||||
{getPromptText(
|
{getPromptText(
|
||||||
|
networkWaiting,
|
||||||
showTalkOverError,
|
showTalkOverError,
|
||||||
pttButtonHeld,
|
pttButtonHeld,
|
||||||
activeSpeakerIsLocalUser,
|
activeSpeakerIsLocalUser,
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ 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] = useMemo(() => {
|
||||||
const params = new URLSearchParams(search);
|
const params = new URLSearchParams(search);
|
||||||
return [params.getAll("via")];
|
return [params.getAll("via"), params.has("embed")];
|
||||||
}, [search]);
|
}, [search]);
|
||||||
const roomId = (maybeRoomId || hash || "").toLowerCase();
|
const roomId = (maybeRoomId || hash || "").toLowerCase();
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ export function RoomPage() {
|
|||||||
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -258,59 +257,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();
|
||||||
|
|
||||||
@@ -87,10 +95,13 @@ export const SettingsModal = (props) => {
|
|||||||
<FieldRow>
|
<FieldRow>
|
||||||
<InputField
|
<InputField
|
||||||
id="spatialAudio"
|
id="spatialAudio"
|
||||||
label="Spatial audio (experimental)"
|
label="Spatial audio"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={spatialAudio}
|
checked={spatialAudio}
|
||||||
onChange={(e) => setSpatialAudio(e.target.checked)}
|
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={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setSpatialAudio(event.target.checked)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
@@ -132,7 +143,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,12 +211,13 @@ 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,
|
audioInput,
|
||||||
audioInputs,
|
audioInputs,
|
||||||
@@ -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) {
|
||||||
|
try {
|
||||||
ref.current.currentTime = 0;
|
ref.current.currentTime = 0;
|
||||||
await ref.current.play();
|
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];
|
||||||
|
};
|
||||||
18
yarn.lock
18
yarn.lock
@@ -3024,6 +3024,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39"
|
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39"
|
||||||
integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==
|
integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==
|
||||||
|
|
||||||
|
"@types/sdp-transform@^2.4.5":
|
||||||
|
version "2.4.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/sdp-transform/-/sdp-transform-2.4.5.tgz#3167961e0a1a5265545e278627aa37c606003f53"
|
||||||
|
integrity sha512-GVO0gnmbyO3Oxm2HdPsYUNcyihZE3GyCY8ysMYHuQGfLhGZq89Nm4lSzULWTzZoyHtg+VO/IdrnxZHPnPSGnAg==
|
||||||
|
|
||||||
"@types/source-list-map@*":
|
"@types/source-list-map@*":
|
||||||
version "0.1.2"
|
version "0.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9"
|
resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9"
|
||||||
@@ -8597,11 +8602,12 @@ matrix-events-sdk@^0.0.1-beta.7:
|
|||||||
resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1-beta.7.tgz#5ffe45eba1f67cc8d7c2377736c728b322524934"
|
resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1-beta.7.tgz#5ffe45eba1f67cc8d7c2377736c728b322524934"
|
||||||
integrity sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA==
|
integrity sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA==
|
||||||
|
|
||||||
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#aa0d3bd1f5a006d151f826e6b8c5f286abb6e960":
|
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#9a15094374f52053ca9f833269d2b1c6c7f964d2":
|
||||||
version "17.2.0"
|
version "18.1.0"
|
||||||
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/aa0d3bd1f5a006d151f826e6b8c5f286abb6e960"
|
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/9a15094374f52053ca9f833269d2b1c6c7f964d2"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/runtime" "^7.12.5"
|
"@babel/runtime" "^7.12.5"
|
||||||
|
"@types/sdp-transform" "^2.4.5"
|
||||||
another-json "^0.2.0"
|
another-json "^0.2.0"
|
||||||
browser-request "^0.3.3"
|
browser-request "^0.3.3"
|
||||||
bs58 "^4.0.1"
|
bs58 "^4.0.1"
|
||||||
@@ -8611,6 +8617,7 @@ matrix-events-sdk@^0.0.1-beta.7:
|
|||||||
p-retry "^4.5.0"
|
p-retry "^4.5.0"
|
||||||
qs "^6.9.6"
|
qs "^6.9.6"
|
||||||
request "^2.88.2"
|
request "^2.88.2"
|
||||||
|
sdp-transform "^2.14.1"
|
||||||
unhomoglyph "^1.0.6"
|
unhomoglyph "^1.0.6"
|
||||||
|
|
||||||
md5.js@^1.3.4:
|
md5.js@^1.3.4:
|
||||||
@@ -11046,6 +11053,11 @@ schema-utils@^3.0.0, schema-utils@^3.1.0, schema-utils@^3.1.1:
|
|||||||
ajv "^6.12.5"
|
ajv "^6.12.5"
|
||||||
ajv-keywords "^3.5.2"
|
ajv-keywords "^3.5.2"
|
||||||
|
|
||||||
|
sdp-transform@^2.14.1:
|
||||||
|
version "2.14.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/sdp-transform/-/sdp-transform-2.14.1.tgz#2bb443583d478dee217df4caa284c46b870d5827"
|
||||||
|
integrity sha512-RjZyX3nVwJyCuTo5tGPx+PZWkDMCg7oOLpSlhjDdZfwUoNqG1mM8nyj31IGHyaPWXhjbP7cdK3qZ2bmkJ1GzRw==
|
||||||
|
|
||||||
"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.6.0:
|
"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.6.0:
|
||||||
version "5.7.1"
|
version "5.7.1"
|
||||||
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
|
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
|
||||||
|
|||||||
Reference in New Issue
Block a user