Compare commits

..

107 Commits

Author SHA1 Message Date
David Baker
a7748a8492 Merge pull request #389 from vector-im/dbkr/js-sdk-bump-5e76697
Bump js-sdk for https://github.com/matrix-org/matrix-js-sdk/pull/2445
2022-06-08 19:35:58 +01:00
David Baker
edbcf95ead Bump js-sdk for https://github.com/matrix-org/matrix-js-sdk/pull/2445 2022-06-08 19:29:49 +01:00
David Baker
fdcedb5592 Merge pull request #385 from vector-im/dbkr/bump_js_sdk_34ef7bc
Bump js-sdk version for a couple of PTT network reliability fixes
2022-06-08 14:45:56 +01:00
David Baker
17098cf2ab Also yarn.lock 2022-06-08 14:35:27 +01:00
David Baker
7ef3dcc56c Bump js-sdk version for a couple of PTT network reliability fixes 2022-06-08 14:34:29 +01:00
David Baker
8a38276f5d Merge pull request #346 from Kalissaac/main
Add linux/arm64 Docker image
2022-06-08 10:22:40 +01:00
Robin
21ec08ffbd Merge pull request #378 from robintown/lint-shortcut
Add a shortcut lint script
2022-06-07 09:26:28 -04:00
Robin
1a7211198b Merge pull request #377 from robintown/spatial-audio-copy
Tweak spatial audio copy
2022-06-07 09:25:56 -04:00
Matthew Hodgson
4f9efb3563 last minute s/radio call/walkie-talkie call/ig 2022-06-07 13:31:19 +01:00
Robin Townsend
2667e78b43 sound → seem 2022-06-06 11:26:48 -04:00
Robin Townsend
878b48aa7a Add a shortcut lint script 2022-06-06 11:21:51 -04:00
Robin Townsend
b314e047c1 Tweak spatial audio copy 2022-06-06 11:19:40 -04:00
Robin
69cfa1db6d Merge pull request #372 from robintown/organize-colors
Organize colors
2022-06-06 09:03:53 -04:00
Robin Townsend
977016fbb2 Merge branch 'main' into organize-colors 2022-06-06 09:03:40 -04:00
Robin
fb3d9e2a16 Merge pull request #374 from robintown/fix-warning
Fix warning
2022-06-03 08:24:40 -04:00
Robin Townsend
8da492d00d Fix warning 2022-06-02 16:30:35 -04:00
Robin
9676014120 Merge pull request #373 from robintown/camera
'Webcam' → 'Camera'
2022-06-02 14:06:58 -04:00
Robin Townsend
7d87b8d1e5 'Webcam' → 'Camera' 2022-06-02 13:53:31 -04:00
David Baker
ecb139721b Merge pull request #370 from vector-im/dbkr/avoid-browser-index-import
Fix app when built in production mode
2022-06-02 11:01:49 +01:00
Robin Townsend
aa45261b0d Organize colors 2022-06-01 11:48:17 -04:00
David Baker
017ec13981 Disable typescript warnings 2022-06-01 16:05:58 +01:00
David Baker
880a2ca127 Merge pull request #359 from vector-im/dbkr/lower_sdk_timeout
Lower timeout on js-sdk API call to 5s
2022-06-01 16:04:14 +01:00
David Baker
5282ab5f12 Merge remote-tracking branch 'origin/main' into dbkr/avoid-browser-index-import 2022-06-01 16:03:18 +01:00
David Baker
582e6637dc Merge remote-tracking branch 'origin/main' into dbkr/lower_sdk_timeout 2022-06-01 16:02:48 +01:00
David Baker
65804cd962 Merge pull request #358 from vector-im/dbkr/matrix-utils-ts
Convert matrix-utils to typescript
2022-06-01 16:02:20 +01:00
David Baker
0411e1cac8 Fix app when built in production mode
The recent typescripting appears to have caused the typescript
compiler to get confused about dependency references and start
refwrencing things like CRYPTO_ENABLED in the js-sdk before it's
defined them.

This avoids using things from the (javascript) browser-index import
and instead pulls everything in from the typescript files, then
fixes the resulting type failures, (in some cases with hacks).
2022-06-01 15:55:02 +01:00
Robin
bab5c9aa42 Merge pull request #367 from robintown/vu-animation
Add a VU meter-style animation to radio mode
2022-06-01 10:42:07 -04:00
Robin Townsend
d680a36cab Bump the animation size up a little bit more 2022-06-01 10:41:49 -04:00
Robin Townsend
25bde3560b Use color variables 2022-06-01 10:41:12 -04:00
Robin Townsend
ddac2ba5ef Merge branch 'main' into vu-animation 2022-06-01 10:31:04 -04:00
Robin
cd55098921 Merge pull request #365 from robintown/spatial-audio
Spatial audio
2022-06-01 09:17:04 -04:00
Robin
f1bdad0d7f Merge pull request #366 from robintown/chrome-android-sink
Fix crash when setting audio output on Chrome for Android
2022-06-01 09:14:41 -04:00
Robin
9fac2c95e5 Merge pull request #368 from robintown/radio-button-cursor
Make PTTButton feel more clickable
2022-06-01 09:13:04 -04:00
David Baker
486d0abd30 Merge pull request #363 from vector-im/dbkr/ptt_connection_lost
Show when connection is lost on PTT mode
2022-06-01 10:24:53 +01:00
David Baker
d9bd48b9a6 Split out client sync listeber into separate useEffect 2022-06-01 10:21:44 +01:00
David Baker
64e30c89e3 Comment typo
Co-authored-by: Robin <robin@robin.town>
2022-06-01 10:13:20 +01:00
David Baker
1860eaae7a Merge pull request #360 from vector-im/dbkr/consistent_sort
Sort call feeds consistently when choosing active speaker
2022-06-01 10:12:56 +01:00
David Baker
771424cbf0 Expand comment 2022-06-01 10:11:02 +01:00
David Baker
925a909ec1 Merge pull request #361 from vector-im/dbkr/usegroupcall_ts
Convert useGroupCall to TS
2022-06-01 10:07:12 +01:00
David Baker
f07ee54e05 Finish sentence
Co-authored-by: Robin <robin@robin.town>
2022-06-01 10:04:49 +01:00
David Baker
7ee2f630db Add more typers to useInteractiveLogin
otherwise apparently Typescript can't trace the MatrixClient type
through.
2022-06-01 09:59:59 +01:00
David Baker
626fdb9f79 Merge remote-tracking branch 'origin/main' into dbkr/matrix-utils-ts 2022-06-01 09:37:59 +01:00
David Baker
2cf40ff0b8 Fix room creation
The room alias is not part of the spec. Synapse returns it anyway,
but it's not part of the js-sdk types. We don't really need the
server to tell us what the alias is, so just generate it locally
instead.
2022-06-01 09:29:47 +01:00
David Baker
9edc1acc90 Add type to indexeddb variable 2022-06-01 09:07:00 +01:00
Robin Townsend
641e6c53b6 Make the animation smaller 2022-05-31 23:41:05 -04:00
Robin Townsend
14fbddf780 Make PTTButton feel more clickable 2022-05-31 18:08:42 -04:00
Robin Townsend
2a69b72bed Add a VU meter-style animation to radio mode 2022-05-31 18:01:34 -04:00
Robin Townsend
e21094b525 Fix crash when setting audio output on Chrome for Android 2022-05-31 16:21:35 -04:00
Robin Townsend
da3d038547 Make it work on Chrome 2022-05-31 16:11:39 -04:00
Robin Townsend
c6b90803f8 Add spatial audio capabilities 2022-05-31 13:36:15 -04:00
Kalissaac
93baa19ba1 Add arm64 Docker image
Signed-off-by: Kian Sutarwala <kalissaac@protonmail.com>
2022-05-31 10:14:42 -07:00
Robin
9444f43c72 Merge pull request #357 from robintown/ts-auth
TypeScriptify the auth directory
2022-05-31 10:35:39 -04:00
Robin Townsend
26251e1e60 Don't abuse useMemo for creating a MatrixClient 2022-05-31 10:33:10 -04:00
Robin Townsend
5b3183cbd3 Make eslint config stricter
now that we can
2022-05-31 10:32:54 -04:00
David Baker
e9b963080c Show when connection is lost on PTT mode 2022-05-30 16:28:16 +01:00
David Baker
1164e6f1e7 Add return type too 2022-05-30 15:53:44 +01:00
David Baker
21c7bb979e Convert useGroupCall to TS 2022-05-30 15:30:57 +01:00
David Baker
1ff9073a1a Sort call feeds consistently when choosing active speaker 2022-05-30 12:14:25 +01:00
David Baker
7ed2f9bd9a Lower timeout on js-sdk API call to 5s 2022-05-30 11:46:27 +01:00
David Baker
2cdbeb6f12 Fix imports 2022-05-30 11:41:59 +01:00
David Baker
7bd95621f1 More types 2022-05-30 11:28:16 +01:00
David Baker
a05501a909 Convert matrix-utils to typescript 2022-05-30 10:09:13 +01:00
Robin Townsend
e6960a1e15 TypeScriptify RegisterPage 2022-05-27 16:55:50 -04:00
Robin Townsend
c057713004 TypeScriptify useInteractiveRegistration 2022-05-27 16:55:50 -04:00
Robin Townsend
35e2135e3c TypeScriptify useInteractiveLogin 2022-05-27 14:52:32 -04:00
Robin Townsend
af74228f8e TypeScriptify useRecaptcha 2022-05-27 10:37:27 -04:00
Robin Townsend
9a44790450 TypeScriptify LoginPage 2022-05-27 10:37:00 -04:00
Robin
5c4bab2a8a Merge pull request #356 from robintown/call-type-dropdown
Add a dropdown to choose between video calls and radio calls
2022-05-27 08:54:38 -04:00
Robin Townsend
94380b64bd Set color-scheme to dark to make the focus ring on the dropdown button
legible
2022-05-26 14:12:25 -04:00
Robin Townsend
cbfd03f9c6 Add a dropdown to choose between video calls and radio calls 2022-05-26 13:52:06 -04:00
Robin
edf58f1d7d Merge pull request #354 from robintown/smooth-dnd
Smoother drag-and-drop
2022-05-25 08:37:14 -04:00
Robin Townsend
17fed7cd9c Prettyify 2022-05-24 16:55:53 -04:00
Robin Townsend
266861bdad Fix order of tiles in 1:1 layout 2022-05-24 16:54:33 -04:00
Robin Townsend
426e1a433b Make drag-and-drop smoother 2022-05-24 16:37:24 -04:00
Robin
3b8dfcec51 Merge pull request #349 from robintown/rate-limit
Handle rate limits when upgrading from a guest account
2022-05-24 07:45:24 -04:00
Robin
6f892edd5e Merge pull request #348 from robintown/limit-width
Limit the width of the remote participant's video in 1:1 layout
2022-05-24 07:45:09 -04:00
Robin
126bfec339 Merge pull request #347 from robintown/prevent-unmute
Prevent video elements from being mistakenly muted/unmuted
2022-05-24 07:44:43 -04:00
Robin Townsend
59938cd46b Handle rate limits when upgrading from a guest account 2022-05-23 10:48:02 -04:00
Robin Townsend
a445bcd0b9 Limit the width of the remote participant's video in 1:1 layout 2022-05-23 09:59:55 -04:00
Robin Townsend
2acb6825e9 Prevent video elements from being mistakenly muted/unmuted 2022-05-23 09:20:34 -04:00
Robin
7d44a1e979 Merge pull request #345 from robintown/unregistered-join-existing
Fix joining an existing room from UnregisteredView
2022-05-20 07:52:47 -04:00
Robin Townsend
aa1fabf857 Fix joining an existing room from UnregisteredView 2022-05-19 15:59:02 -04:00
David Baker
c714a0608c Merge pull request #337 from vector-im/dbkr/bump_js_sdk_for_olm
Bump js-sdk dependency to encrypt to-device messages
2022-05-19 20:52:11 +01:00
David Baker
92d15e110a Update to include https://github.com/matrix-org/matrix-js-sdk/pull/2383 2022-05-19 19:10:31 +01:00
Robin
1367ff9914 Merge pull request #340 from robintown/fix-invite-modal
Fix soft crash when opening invite modal in lobby
2022-05-19 10:46:41 -04:00
Robin
7a2d64c0ef Merge pull request #339 from robintown/room-avatars
Display room avatars
2022-05-19 10:46:24 -04:00
David Baker
60b5f7cab2 Merge pull request #334 from vector-im/dbkr/codeowners
Add CODEOWNERS file
2022-05-19 10:40:54 +01:00
David Baker
d81c52e9bb Merge pull request #329 from vector-im/dbkr/rageshake_ptt
Enable rageshake on PTT mode
2022-05-19 10:40:43 +01:00
Robin Townsend
c54f1bd7a3 Fix soft crash when opening invite modal in lobby 2022-05-18 19:04:59 -04:00
Robin Townsend
24f721e414 Display room avatars 2022-05-18 19:00:59 -04:00
David Baker
3e19843bf7 Bump js-sdk dependency to encrypt to-device messages 2022-05-18 14:53:43 +01:00
Robin
183eea9f24 Merge pull request #336 from robintown/fix-links
Fix links
2022-05-18 08:45:33 -04:00
Robin
548ea7220b Merge pull request #335 from robintown/drag-local-video
Make local video in 1:1 calls draggable
2022-05-18 08:45:22 -04:00
Robin Townsend
8cd45b64a1 Fix links
The href attribute was never actually being set.
2022-05-17 18:30:59 -04:00
Robin
c33d97a2ed Merge pull request #332 from robintown/double-call-name-prompt
Don't leave UnauthenticatedView if there was a room creation error
2022-05-17 17:43:17 -04:00
Robin Townsend
7926a1f9b9 Make local video in 1:1 calls draggable 2022-05-17 17:35:35 -04:00
David Baker
c7da1177ab Add CODEOWNERS file 2022-05-17 18:44:22 +01:00
Robin Townsend
1e5539f165 Don't leave UnauthenticatedView if there was a room creation error 2022-05-17 12:38:01 -04:00
David Baker
d019add257 Merge remote-tracking branch 'origin/main' into dbkr/rageshake_ptt 2022-05-17 15:41:57 +01:00
David Baker
cc8ce7a05c Move feedback button to overflow menu
To be consistent with normal view and avoid nested dialogs.

Also disable space for the PTT key when the feedback dialog is visible,
since otherwise you can't type a space. Involves some rearrangement of
modal state.

Remove accidentally comitted vite port config.
2022-05-17 15:36:13 +01:00
David Baker
6913fddcd3 Merge pull request #303 from vector-im/to-device-olm2
Add support for to-device messages via OLM
2022-05-17 13:33:30 +01:00
David Baker
b3285974f9 Enable rageshake on PTT mode
By putting another 'Submit Feedback' button in the developer section
of the setting modal (we can work out a better place for it).
2022-05-16 16:58:39 +01:00
Robert Long
7a9ff98550 Add OLM_OPTIONS global TODO 2022-04-27 13:51:08 -07:00
Robert Long
3d54047f87 Fix Olm import 2022-04-27 13:38:16 -07:00
Robert Long
e2aee0be81 Fix olm import 2022-04-26 16:28:21 -07:00
Robert Long
44486aa62d Fix building olm library in production 2022-04-26 16:11:32 -07:00
Robert Long
a0e4de73cc Add support for to-device messages via OLM 2022-04-26 15:20:06 -07:00
76 changed files with 1703 additions and 1036 deletions

21
.env
View File

@@ -14,12 +14,15 @@
# VITE_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
# VITE_CUSTOM_THEME=true
# VITE_PRIMARY_COLOR=#0dbd8b
# VITE_BG_COLOR_1=#ffffff
# VITE_BG_COLOR_2=#f0f1f4
# VITE_BG_COLOR_3=#dbdfe4
# VITE_BG_COLOR_4=#d1d3d7
# VITE_INPUT_BORDER_COLOR=#e7e7e7
# VITE_INPUT_BORDER_COLOR_FOCUSED=#238cf5
# VITE_TEXT_COLOR_1=#17191c
# VITE_TEXT_COLOR_2=#61708b
# VITE_THEME_ACCENT=#0dbd8b
# VITE_THEME_ACCENT_20=#0dbd8b33
# VITE_THEME_ALERT=#ff5b55
# VITE_THEME_ALERT_20=#ff5b5533
# VITE_THEME_LINKS=#0086e6
# VITE_THEME_PRIMARY_CONTENT=#ffffff
# VITE_THEME_SECONDARY_CONTENT=#a9b2bc
# VITE_THEME_TERTIARY_CONTENT=#8e99a4
# VITE_THEME_QUATERNARY_CONTENT=#6f7882
# VITE_THEME_QUINARY_CONTENT=#394049
# VITE_THEME_SYSTEM=#21262c
# VITE_THEME_BACKGROUND=#15191e

View File

@@ -16,9 +16,6 @@ module.exports = {
"sourceType": "module",
},
rules: {
// We break this rule in a few places: dial it back to a warning
// (and run with max warnings) to tolerate the existing code
"react-hooks/exhaustive-deps": ["warn"],
"jsx-a11y/media-has-caption": ["off"],
},
overrides: [

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @vector-im/element-call-reviewers

View File

@@ -32,10 +32,14 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@dc7b9719a96d48369863986a06765841d7ea23f6
- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -1,4 +1,4 @@
FROM node:16-buster as builder
FROM --platform=$BUILDPLATFORM node:16-buster as builder
WORKDIR /src

View File

@@ -8,11 +8,13 @@
"build-storybook": "build-storybook",
"prettier:check": "prettier -c src",
"prettier:format": "prettier -w src",
"lint:js": "eslint --max-warnings 2 src",
"lint": "yarn lint:types && yarn lint:js",
"lint:js": "eslint --max-warnings 0 src",
"lint:types": "tsc"
},
"dependencies": {
"@juggle/resize-observer": "^3.3.1",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
"@react-aria/button": "^3.3.4",
"@react-aria/dialog": "^3.1.4",
"@react-aria/focus": "^3.5.0",
@@ -30,12 +32,12 @@
"@react-stately/tree": "^3.2.0",
"@sentry/react": "^6.13.3",
"@sentry/tracing": "^6.13.3",
"@types/grecaptcha": "^3.0.4",
"@use-gesture/react": "^10.2.11",
"classnames": "^2.3.1",
"color-hash": "^2.0.1",
"events": "^3.3.0",
"lodash-move": "^1.1.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#acef1d7dd0b915368730efabee94deb42b2e4058",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#5e766978b8cf80d943f796df1067722a6a5918a7",
"mermaid": "^8.13.8",
"normalize.css": "^8.0.1",
"pako": "^2.0.4",

View File

@@ -15,3 +15,10 @@ limitations under the License.
*/
import "matrix-js-sdk/src/@types/global";
declare global {
interface Window {
// TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
OLM_OPTIONS: Record<string, string>;
}
}

View File

@@ -1,6 +1,6 @@
.avatar {
position: relative;
color: #ffffff;
color: var(--primary-content);
display: flex;
align-items: center;
justify-content: center;
@@ -17,7 +17,7 @@
}
.avatar svg * {
fill: #ffffff;
fill: var(--primary-content);
}
.avatar span {

View File

@@ -1,6 +1,9 @@
import React, { useMemo } from "react";
import React, { useMemo, CSSProperties } from "react";
import classNames from "classnames";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { getAvatarUrl } from "./matrix-utils";
import { useClient } from "./ClientContext";
import styles from "./Avatar.module.css";
const backgroundColors = [
@@ -14,6 +17,22 @@ const backgroundColors = [
"#74D12C",
];
export enum Size {
XS = "xs",
SM = "sm",
MD = "md",
LG = "lg",
XL = "xl",
}
export const sizes = new Map([
[Size.XS, 22],
[Size.SM, 32],
[Size.MD, 36],
[Size.LG, 42],
[Size.XL, 90],
]);
function hashStringToArrIndex(str: string, arrLength: number) {
let sum = 0;
@@ -24,24 +43,51 @@ function hashStringToArrIndex(str: string, arrLength: number) {
return sum % arrLength;
}
const resolveAvatarSrc = (client: MatrixClient, src: string, size: number) =>
src?.startsWith("mxc://") ? client && getAvatarUrl(client, src, size) : src;
interface Props extends React.HTMLAttributes<HTMLDivElement> {
bgKey?: string;
src: string;
fallback: string;
size?: number;
size?: Size | number;
className: string;
style: React.CSSProperties;
style?: CSSProperties;
}
export const Avatar: React.FC<Props> = ({
bgKey,
src,
fallback,
size,
size = Size.MD,
className,
style,
style = {},
...rest
}) => {
const { client } = useClient();
const [sizeClass, sizePx, sizeStyle] = useMemo(
() =>
Object.values(Size).includes(size as Size)
? [styles[size as string], sizes.get(size as Size), {}]
: [
null,
size as number,
{
width: size,
height: size,
borderRadius: size,
fontSize: Math.round((size as number) / 2),
},
],
[size]
);
const resolvedSrc = useMemo(
() => resolveAvatarSrc(client, src, sizePx),
[client, src, sizePx]
);
const backgroundColor = useMemo(() => {
const index = hashStringToArrIndex(
bgKey || fallback || src || "",
@@ -53,12 +99,12 @@ export const Avatar: React.FC<Props> = ({
/* eslint-disable jsx-a11y/alt-text */
return (
<div
className={classNames(styles.avatar, styles[size || "md"], className)}
style={{ backgroundColor, ...style }}
className={classNames(styles.avatar, sizeClass, className)}
style={{ backgroundColor, ...sizeStyle, ...style }}
{...rest}
>
{src ? (
<img src={src} />
{resolvedSrc ? (
<img src={resolvedSrc} />
) : typeof fallback === "string" ? (
<span>{fallback}</span>
) : (

View File

@@ -1,5 +1,5 @@
/*
Copyright 2021 New Vector Ltd
Copyright 2021-2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -15,6 +15,7 @@ limitations under the License.
*/
import React, {
FC,
useCallback,
useEffect,
useState,
@@ -23,17 +24,59 @@ import React, {
useContext,
} from "react";
import { useHistory } from "react-router-dom";
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { ErrorView } from "./FullScreenView";
import { initClient, defaultHomeserver } from "./matrix-utils";
const ClientContext = createContext();
declare global {
interface Window {
matrixclient: MatrixClient;
}
}
export function ClientProvider({ children }) {
export interface Session {
user_id: string;
device_id: string;
access_token: string;
passwordlessUser: boolean;
tempPassword?: string;
}
const loadSession = (): Session => {
const data = localStorage.getItem("matrix-auth-store");
if (data) return JSON.parse(data);
return null;
};
const saveSession = (session: Session) =>
localStorage.setItem("matrix-auth-store", JSON.stringify(session));
const clearSession = () => localStorage.removeItem("matrix-auth-store");
interface ClientState {
loading: boolean;
isAuthenticated: boolean;
isPasswordlessUser: boolean;
client: MatrixClient;
userName: string;
changePassword: (password: string) => Promise<void>;
logout: () => void;
setClient: (client: MatrixClient, session: Session) => void;
}
const ClientContext = createContext<ClientState>(null);
type ClientProviderState = Omit<
ClientState,
"changePassword" | "logout" | "setClient"
> & { error?: Error };
export const ClientProvider: FC = ({ children }) => {
const history = useHistory();
const [
{ loading, isAuthenticated, isPasswordlessUser, client, userName, error },
setState,
] = useState({
] = useState<ClientProviderState>({
loading: true,
isAuthenticated: false,
isPasswordlessUser: false,
@@ -43,18 +86,16 @@ export function ClientProvider({ children }) {
});
useEffect(() => {
async function restore() {
const restore = async (): Promise<
Pick<ClientProviderState, "client" | "isPasswordlessUser">
> => {
try {
const authStore = localStorage.getItem("matrix-auth-store");
const session = loadSession();
if (authStore) {
const {
user_id,
device_id,
access_token,
passwordlessUser,
tempPassword,
} = JSON.parse(authStore);
if (session) {
/* eslint-disable camelcase */
const { user_id, device_id, access_token, passwordlessUser } =
session;
const client = await initClient({
baseUrl: defaultHomeserver,
@@ -62,37 +103,26 @@ export function ClientProvider({ children }) {
userId: user_id,
deviceId: device_id,
});
/* eslint-enable camelcase */
localStorage.setItem(
"matrix-auth-store",
JSON.stringify({
user_id,
device_id,
access_token,
passwordlessUser,
tempPassword,
})
);
return { client, passwordlessUser };
return { client, isPasswordlessUser: passwordlessUser };
}
return { client: undefined };
return { client: undefined, isPasswordlessUser: false };
} catch (err) {
console.error(err);
localStorage.removeItem("matrix-auth-store");
clearSession();
throw err;
}
}
};
restore()
.then(({ client, passwordlessUser }) => {
.then(({ client, isPasswordlessUser }) => {
setState({
client,
loading: false,
isAuthenticated: !!client,
isPasswordlessUser: !!passwordlessUser,
isAuthenticated: Boolean(client),
isPasswordlessUser,
userName: client?.getUserIdLocalpart(),
});
})
@@ -108,31 +138,23 @@ export function ClientProvider({ children }) {
}, []);
const changePassword = useCallback(
async (password) => {
const { tempPassword, passwordlessUser, ...existingSession } = JSON.parse(
localStorage.getItem("matrix-auth-store")
);
async (password: string) => {
const { tempPassword, ...session } = loadSession();
await client.setPassword(
{
type: "m.login.password",
identifier: {
type: "m.id.user",
user: existingSession.user_id,
user: session.user_id,
},
user: existingSession.user_id,
user: session.user_id,
password: tempPassword,
},
password
);
localStorage.setItem(
"matrix-auth-store",
JSON.stringify({
...existingSession,
passwordlessUser: false,
})
);
saveSession({ ...session, passwordlessUser: false });
setState({
client,
@@ -146,23 +168,23 @@ export function ClientProvider({ children }) {
);
const setClient = useCallback(
(newClient, session) => {
(newClient: MatrixClient, session: Session) => {
if (client && client !== newClient) {
client.stopClient();
}
if (newClient) {
localStorage.setItem("matrix-auth-store", JSON.stringify(session));
saveSession(session);
setState({
client: newClient,
loading: false,
isAuthenticated: true,
isPasswordlessUser: !!session.passwordlessUser,
isPasswordlessUser: session.passwordlessUser,
userName: newClient.getUserIdLocalpart(),
});
} else {
localStorage.removeItem("matrix-auth-store");
clearSession();
setState({
client: undefined,
@@ -177,29 +199,23 @@ export function ClientProvider({ children }) {
);
const logout = useCallback(() => {
localStorage.removeItem("matrix-auth-store");
window.location = "/";
clearSession();
history.push("/");
}, [history]);
useEffect(() => {
if (client) {
const loadTime = Date.now();
const onToDeviceEvent = (event) => {
if (event.getType() !== "org.matrix.call_duplicate_session") {
return;
}
const onToDeviceEvent = (event: MatrixEvent) => {
if (event.getType() !== "org.matrix.call_duplicate_session") return;
const content = event.getContent();
if (content.session_id === client.getSessionId()) {
return;
}
if (content.session_id === client.getSessionId()) return;
if (content.timestamp > loadTime) {
if (client) {
client.stopClient();
}
client?.stopClient();
setState((prev) => ({
...prev,
@@ -210,7 +226,7 @@ export function ClientProvider({ children }) {
}
};
client.on("toDeviceEvent", onToDeviceEvent);
client.on(ClientEvent.ToDeviceEvent, onToDeviceEvent);
client.sendToDevice("org.matrix.call_duplicate_session", {
[client.getUserId()]: {
@@ -219,12 +235,12 @@ export function ClientProvider({ children }) {
});
return () => {
client.removeListener("toDeviceEvent", onToDeviceEvent);
client?.removeListener(ClientEvent.ToDeviceEvent, onToDeviceEvent);
};
}
}, [client]);
const context = useMemo(
const context = useMemo<ClientState>(
() => ({
loading,
isAuthenticated,
@@ -258,8 +274,6 @@ export function ClientProvider({ children }) {
return (
<ClientContext.Provider value={context}>{children}</ClientContext.Provider>
);
}
};
export function useClient() {
return useContext(ClientContext);
}
export const useClient = () => useContext(ClientContext);

View File

@@ -1,8 +1,7 @@
import React from "react";
import styles from "./Facepile.module.css";
import classNames from "classnames";
import { Avatar } from "./Avatar";
import { getAvatarUrl } from "./matrix-utils";
import { Avatar, sizes } from "./Avatar";
const overlapMap = {
xs: 2,
@@ -10,12 +9,6 @@ const overlapMap = {
md: 8,
};
const sizeMap = {
xs: 24,
sm: 32,
md: 36,
};
export function Facepile({
className,
client,
@@ -24,7 +17,7 @@ export function Facepile({
size,
...rest
}) {
const _size = sizeMap[size];
const _size = sizes.get(size);
const _overlap = overlapMap[size];
return (
@@ -40,7 +33,7 @@ export function Facepile({
<Avatar
key={member.userId}
size={size}
src={avatarUrl && getAvatarUrl(client, avatarUrl, _size)}
src={avatarUrl}
fallback={member.name.slice(0, 1).toUpperCase()}
className={styles.avatar}
style={{ left: i * (_size - _overlap) }}

View File

@@ -18,7 +18,7 @@
.facepile .avatar {
position: absolute;
top: 0;
border: 1px solid var(--bgColor2);
border: 1px solid var(--system);
}
.facepile.md .avatar {

View File

@@ -57,12 +57,13 @@ export function HeaderLogo({ className }) {
);
}
export function RoomHeaderInfo({ roomName }) {
export function RoomHeaderInfo({ roomName, avatarUrl }) {
return (
<>
<div className={styles.roomAvatar}>
<Avatar
size="md"
src={avatarUrl}
bgKey={roomName}
fallback={roomName.slice(0, 1).toUpperCase()}
/>
@@ -73,13 +74,13 @@ export function RoomHeaderInfo({ roomName }) {
);
}
export function RoomSetupHeaderInfo({ roomName, ...rest }) {
export function RoomSetupHeaderInfo({ roomName, avatarUrl, ...rest }) {
const ref = useRef();
const { buttonProps } = useButton(rest, ref);
return (
<button className={styles.backButton} ref={ref} {...buttonProps}>
<ArrowLeftIcon width={16} height={16} />
<RoomHeaderInfo roomName={roomName} />
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
</button>
);
}

View File

@@ -70,7 +70,7 @@
background: transparent;
border: none;
display: flex;
color: var(--textColor1);
color: var(--primary-content);
cursor: pointer;
align-items: center;
}

5
src/IndexedDBWorker.js Normal file
View File

@@ -0,0 +1,5 @@
import { IndexedDBStoreWorker } from "matrix-js-sdk/src/indexeddb-worker";
const remoteWorker = new IndexedDBStoreWorker(self.postMessage);
self.onmessage = remoteWorker.onMessage;

View File

@@ -5,8 +5,8 @@
overflow-y: auto;
list-style: none;
background-color: transparent;
border: 1px solid var(--inputBorderColor);
background-color: var(--bgColor1);
border: 1px solid var(--quinary-content);
background-color: var(--background);
border-radius: 8px;
}
@@ -15,7 +15,7 @@
align-items: center;
justify-content: space-between;
background-color: transparent;
color: var(--textColor1);
color: var(--primary-content);
padding: 8px 16px;
outline: none;
cursor: pointer;
@@ -28,6 +28,6 @@
}
.option.disabled {
color: var(--textColor2);
color: var(--quaternary-content);
background-color: var(--bgColor3);
}

View File

@@ -11,7 +11,7 @@
display: flex;
align-items: center;
padding: 0 12px;
color: var(--textColor1);
color: var(--primary-content);
font-size: 14px;
}
@@ -25,7 +25,7 @@
.menuItem.focused,
.menuItem:hover {
background-color: var(--bgColor4);
background-color: var(--quinary-content);
}
.menuItem.focused:first-child,
@@ -39,3 +39,12 @@
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.checkIcon {
position: absolute;
right: 16px;
}
.checkIcon * {
stroke: var(--primary-content);
}

View File

@@ -1,10 +1,10 @@
.tooltip {
background-color: var(--bgColor2);
background-color: var(--system);
flex-direction: row;
justify-content: center;
align-items: center;
padding: 8px 10px;
color: var(--textColor1);
color: var(--primary-content);
border-radius: 8px;
max-width: 135px;
width: max-content;

View File

@@ -4,7 +4,7 @@
}
.userButton svg * {
fill: var(--textColor1);
fill: var(--primary-content);
}
.avatar {

View File

@@ -65,7 +65,7 @@
}
.authLinks a {
color: #0dbd8b;
color: var(--accent);
text-decoration: none;
font-weight: normal;
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2021 New Vector Ltd
Copyright 2021-2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,9 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useRef, useState, useMemo } from "react";
import React, {
FC,
FormEvent,
useCallback,
useRef,
useState,
useMemo,
} from "react";
import { useHistory, useLocation, Link } from "react-router-dom";
import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
import { useClient } from "../ClientContext";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
import { defaultHomeserver, defaultHomeserverHost } from "../matrix-utils";
@@ -24,27 +33,30 @@ import styles from "./LoginPage.module.css";
import { useInteractiveLogin } from "./useInteractiveLogin";
import { usePageTitle } from "../usePageTitle";
export function LoginPage() {
export const LoginPage: FC = () => {
usePageTitle("Login");
const [_, login] = useInteractiveLogin();
const [homeserver, setHomeServer] = useState(defaultHomeserver);
const usernameRef = useRef();
const passwordRef = useRef();
const { setClient } = useClient();
const login = useInteractiveLogin();
const homeserver = defaultHomeserver; // TODO: Make this configurable
const usernameRef = useRef<HTMLInputElement>();
const passwordRef = useRef<HTMLInputElement>();
const history = useHistory();
const location = useLocation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const [error, setError] = useState<Error>();
// TODO: Handle hitting login page with authenticated client
const onSubmitLoginForm = useCallback(
(e) => {
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
login(homeserver, usernameRef.current.value, passwordRef.current.value)
.then(() => {
.then(([client, session]) => {
setClient(client, session);
if (location.state && location.state.from) {
history.push(location.state.from);
} else {
@@ -56,13 +68,13 @@ export function LoginPage() {
setLoading(false);
});
},
[login, location, history, homeserver]
[login, location, history, homeserver, setClient]
);
const homeserverHost = useMemo(() => {
try {
return new URL(homeserver).host;
} catch (_error) {
} catch (error) {
return defaultHomeserverHost;
}
}, [homeserver]);
@@ -121,4 +133,4 @@ export function LoginPage() {
</div>
</>
);
}
};

View File

@@ -1,5 +1,5 @@
/*
Copyright 2021 New Vector Ltd
Copyright 2021-2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,8 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, {
FC,
FormEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useHistory, useLocation } from "react-router-dom";
import { captureException } from "@sentry/react";
import { sleep } from "matrix-js-sdk/src/utils";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
import { useClient } from "../ClientContext";
@@ -28,67 +38,68 @@ import { useRecaptcha } from "./useRecaptcha";
import { Caption, Link } from "../typography/Typography";
import { usePageTitle } from "../usePageTitle";
export function RegisterPage() {
export const RegisterPage: FC = () => {
usePageTitle("Register");
const { loading, isAuthenticated, isPasswordlessUser, client } = useClient();
const confirmPasswordRef = useRef();
const { loading, isAuthenticated, isPasswordlessUser, client, setClient } =
useClient();
const confirmPasswordRef = useRef<HTMLInputElement>();
const history = useHistory();
const location = useLocation();
const [registering, setRegistering] = useState(false);
const [error, setError] = useState();
const [error, setError] = useState<Error>();
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [{ privacyPolicyUrl, recaptchaKey }, register] =
const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const onSubmitRegisterForm = useCallback(
(e) => {
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.target);
const userName = data.get("userName");
const password = data.get("password");
const passwordConfirmation = data.get("passwordConfirmation");
const data = new FormData(e.target as HTMLFormElement);
const userName = data.get("userName") as string;
const password = data.get("password") as string;
const passwordConfirmation = data.get("passwordConfirmation") as string;
if (password !== passwordConfirmation) {
return;
}
if (password !== passwordConfirmation) return;
async function submit() {
const submit = async () => {
setRegistering(true);
let roomIds;
if (client && isPasswordlessUser) {
const groupCalls = client.groupCallEventHandler.groupCalls.values();
roomIds = Array.from(groupCalls).map(
(groupCall) => groupCall.room.roomId
);
}
const recaptchaResponse = await execute();
const newClient = await register(
const [newClient, session] = await register(
userName,
password,
userName,
recaptchaResponse
);
if (roomIds) {
for (const roomId of roomIds) {
if (client && isPasswordlessUser) {
// Migrate the user's rooms
for (const groupCall of client.groupCallEventHandler.groupCalls.values()) {
const roomId = groupCall.room.roomId;
try {
await newClient.joinRoom(roomId);
} catch (error) {
console.warn(`Couldn't join room ${roomId}`, error);
if (error.errcode === "M_LIMIT_EXCEEDED") {
await sleep(error.data.retry_after_ms);
await newClient.joinRoom(roomId);
} else {
captureException(error);
console.error(`Couldn't join room ${roomId}`, error);
}
}
}
}
}
setClient(newClient, session);
};
submit()
.then(() => {
if (location.state && location.state.from) {
if (location.state?.from) {
history.push(location.state.from);
} else {
history.push("/");
@@ -100,18 +111,23 @@ export function RegisterPage() {
reset();
});
},
[register, location, history, isPasswordlessUser, reset, execute, client]
[
register,
location,
history,
isPasswordlessUser,
reset,
execute,
client,
setClient,
]
);
useEffect(() => {
if (!confirmPasswordRef.current) {
return;
}
if (password && passwordConfirmation && password !== passwordConfirmation) {
confirmPasswordRef.current.setCustomValidity("Passwords must match");
confirmPasswordRef.current?.setCustomValidity("Passwords must match");
} else {
confirmPasswordRef.current.setCustomValidity("");
confirmPasswordRef.current?.setCustomValidity("");
}
}, [password, passwordConfirmation]);
@@ -119,7 +135,7 @@ export function RegisterPage() {
if (!loading && isAuthenticated && !isPasswordlessUser && !registering) {
history.push("/");
}
}, [history, isAuthenticated, isPasswordlessUser, registering]);
}, [loading, history, isAuthenticated, isPasswordlessUser, registering]);
if (loading) {
return <LoadingView />;
@@ -207,4 +223,4 @@ export function RegisterPage() {
</div>
</>
);
}
};

View File

@@ -1,64 +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 matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index";
import { useState, useCallback } from "react";
import { useClient } from "../ClientContext";
import { initClient, defaultHomeserver } from "../matrix-utils";
export function useInteractiveLogin() {
const { setClient } = useClient();
const [state, setState] = useState({ loading: false });
const auth = useCallback(
async (homeserver, username, password) => {
const authClient = matrix.createClient(homeserver);
const interactiveAuth = new InteractiveAuth({
matrixClient: authClient,
busyChanged(loading) {
setState((prev) => ({ ...prev, loading }));
},
async doRequest(_auth, _background) {
return authClient.login("m.login.password", {
identifier: {
type: "m.id.user",
user: username,
},
password,
});
},
});
const { user_id, access_token, device_id } =
await interactiveAuth.attemptAuth();
const client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
setClient(client, { user_id, access_token, device_id });
return client;
},
[setClient]
);
return [state, auth];
}

View File

@@ -0,0 +1,69 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useCallback } from "react";
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
import { initClient, defaultHomeserver } from "../matrix-utils";
import { Session } from "../ClientContext";
export const useInteractiveLogin = () =>
useCallback<
(
homeserver: string,
username: string,
password: string
) => Promise<[MatrixClient, Session]>
>(async (homeserver: string, username: string, password: string) => {
const authClient = createClient(homeserver);
const interactiveAuth = new InteractiveAuth({
matrixClient: authClient,
doRequest: () =>
authClient.login("m.login.password", {
identifier: {
type: "m.id.user",
user: username,
},
password,
}),
stateUpdated: null,
requestEmailToken: null,
});
// XXX: This claims to return an IAuthData which contains none of these
// things - the js-sdk types may be wrong?
/* eslint-disable camelcase,@typescript-eslint/no-explicit-any */
const { user_id, access_token, device_id } =
(await interactiveAuth.attemptAuth()) as any;
const session = {
user_id,
access_token,
device_id,
passwordlessUser: false,
};
const client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
/* eslint-enable camelcase */
return [client, session];
}, []);

View File

@@ -14,55 +14,60 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import matrix, { InteractiveAuth } from "matrix-js-sdk/src/browser-index";
import { useState, useEffect, useCallback, useRef } from "react";
import { useClient } from "../ClientContext";
import { InteractiveAuth } from "matrix-js-sdk/src/interactive-auth";
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
import { initClient, defaultHomeserver } from "../matrix-utils";
import { Session } from "../ClientContext";
export function useInteractiveRegistration() {
const { setClient } = useClient();
const [state, setState] = useState({ privacyPolicyUrl: "#", loading: false });
export const useInteractiveRegistration = (): [
string,
string,
(
username: string,
password: string,
displayName: string,
recaptchaResponse: string,
passwordlessUser?: boolean
) => Promise<[MatrixClient, Session]>
] => {
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string>();
const [recaptchaKey, setRecaptchaKey] = useState<string>();
const authClientRef = useRef();
const authClient = useRef<MatrixClient>();
if (!authClient.current) {
authClient.current = createClient(defaultHomeserver);
}
useEffect(() => {
authClientRef.current = matrix.createClient(defaultHomeserver);
authClientRef.current.registerRequest({}).catch((error) => {
const privacyPolicyUrl =
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url;
const recaptchaKey = error.data?.params["m.login.recaptcha"]?.public_key;
if (privacyPolicyUrl || recaptchaKey) {
setState((prev) => ({ ...prev, privacyPolicyUrl, recaptchaKey }));
}
authClient.current.registerRequest({}).catch((error) => {
setPrivacyPolicyUrl(
error.data?.params["m.login.terms"]?.policies?.privacy_policy?.en?.url
);
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
});
}, []);
const register = useCallback(
async (
username,
password,
displayName,
recaptchaResponse,
passwordlessUser
) => {
username: string,
password: string,
displayName: string,
recaptchaResponse: string,
passwordlessUser?: boolean
): Promise<[MatrixClient, Session]> => {
const interactiveAuth = new InteractiveAuth({
matrixClient: authClientRef.current,
busyChanged(loading) {
setState((prev) => ({ ...prev, loading }));
},
async doRequest(auth, _background) {
return authClientRef.current.registerRequest({
matrixClient: authClient.current,
doRequest: (auth) =>
authClient.current.registerRequest({
username,
password,
auth: auth || undefined,
});
},
stateUpdated(nextStage, status) {
}),
stateUpdated: (nextStage, status) => {
if (status.error) {
throw new Error(error);
throw new Error(status.error);
}
if (nextStage === "m.login.terms") {
@@ -76,10 +81,14 @@ export function useInteractiveRegistration() {
});
}
},
requestEmailToken: null,
});
// XXX: This claims to return an IAuthData which contains none of these
// things - the js-sdk types may be wrong?
/* eslint-disable camelcase,@typescript-eslint/no-explicit-any */
const { user_id, access_token, device_id } =
await interactiveAuth.attemptAuth();
(await interactiveAuth.attemptAuth()) as any;
const client = await initClient({
baseUrl: defaultHomeserver,
@@ -90,23 +99,26 @@ export function useInteractiveRegistration() {
await client.setDisplayName(displayName);
const session = { user_id, device_id, access_token, passwordlessUser };
const session: Session = {
user_id,
device_id,
access_token,
passwordlessUser,
};
/* eslint-enable camelcase */
if (passwordlessUser) {
session.tempPassword = password;
}
setClient(client, session);
const user = client.getUser(client.getUserId());
user.setRawDisplayName(displayName);
user.setDisplayName(displayName);
return client;
return [client, session];
},
[setClient]
[]
);
return [state, register];
}
return [privacyPolicyUrl, recaptchaKey, register];
};

View File

@@ -14,52 +14,49 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useEffect, useCallback, useRef, useState } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
declare global {
interface Window {
mxOnRecaptchaLoaded: () => void;
}
}
const RECAPTCHA_SCRIPT_URL =
"https://www.recaptcha.net/recaptcha/api.js?onload=mxOnRecaptchaLoaded&render=explicit";
export function useRecaptcha(sitekey) {
interface RecaptchaPromiseRef {
resolve: (response: string) => void;
reject: (error: Error) => void;
}
export const useRecaptcha = (sitekey: string) => {
const [recaptchaId] = useState(() => randomString(16));
const promiseRef = useRef();
const promiseRef = useRef<RecaptchaPromiseRef>();
useEffect(() => {
if (!sitekey) {
return;
}
if (!sitekey) return;
const onRecaptchaLoaded = () => {
if (!document.getElementById(recaptchaId)) {
return;
}
if (!document.getElementById(recaptchaId)) return;
window.grecaptcha.render(recaptchaId, {
sitekey,
size: "invisible",
callback: (response) => {
if (promiseRef.current) {
promiseRef.current.resolve(response);
}
},
"error-callback": (error) => {
if (promiseRef.current) {
promiseRef.current.reject(error);
}
},
callback: (response: string) => promiseRef.current?.resolve(response),
// eslint-disable-next-line @typescript-eslint/naming-convention
"error-callback": () => promiseRef.current?.reject(new Error()),
});
};
if (
typeof window.grecaptcha !== "undefined" &&
typeof window.grecaptcha.render === "function"
) {
if (typeof window.grecaptcha?.render === "function") {
onRecaptchaLoaded();
} else {
window.mxOnRecaptchaLoaded = onRecaptchaLoaded;
if (!document.querySelector(`script[src="${RECAPTCHA_SCRIPT_URL}"]`)) {
const scriptTag = document.createElement("script");
const scriptTag = document.createElement("script") as HTMLScriptElement;
scriptTag.src = RECAPTCHA_SCRIPT_URL;
scriptTag.async = true;
document.body.appendChild(scriptTag);
@@ -80,7 +77,7 @@ export function useRecaptcha(sitekey) {
return new Promise((resolve, reject) => {
const observer = new MutationObserver((mutationsList) => {
for (const item of mutationsList) {
if (item.target?.style?.visibility !== "visible") {
if ((item.target as HTMLElement)?.style?.visibility !== "visible") {
reject(new Error("Recaptcha dismissed"));
observer.disconnect();
return;
@@ -101,7 +98,7 @@ export function useRecaptcha(sitekey) {
window.grecaptcha.execute();
const iframe = document.querySelector(
const iframe = document.querySelector<HTMLIFrameElement>(
'iframe[src*="recaptcha/api2/bframe"]'
);
@@ -111,13 +108,11 @@ export function useRecaptcha(sitekey) {
});
}
});
}, [recaptchaId, sitekey]);
}, [sitekey]);
const reset = useCallback(() => {
if (window.grecaptcha) {
window.grecaptcha.reset();
}
}, [recaptchaId]);
window.grecaptcha?.reset();
}, []);
return { execute, reset, recaptchaId };
}
};

View File

@@ -25,6 +25,7 @@ import { ReactComponent as HangupIcon } from "../icons/Hangup.svg";
import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg";
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
import { useButton } from "@react-aria/button";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import { TooltipTrigger } from "../Tooltip";
@@ -36,9 +37,10 @@ export const variantToClassName = {
icon: [styles.iconButton],
secondary: [styles.secondary],
copy: [styles.copyButton],
secondaryCopy: [styles.secondaryCopy, styles.copyButton],
iconCopy: [styles.iconCopyButton],
secondaryCopy: [styles.copyButton],
secondaryHangup: [styles.secondaryHangup],
dropdown: [styles.dropdownButton],
};
export const sizeToClassName = {
@@ -86,13 +88,13 @@ export const Button = forwardRef(
{
[styles.on]: on,
[styles.off]: off,
[styles.secondaryCopy]: variant === "secondaryCopy",
}
)}
{...mergeProps(rest, filteredButtonProps)}
ref={buttonRef}
>
{children}
{variant === "dropdown" && <ArrowDownIcon />}
</button>
);
}

View File

@@ -21,7 +21,8 @@ limitations under the License.
.iconCopyButton,
.secondary,
.secondaryHangup,
.copyButton {
.copyButton,
.dropdownButton {
position: relative;
display: flex;
justify-content: center;
@@ -45,8 +46,8 @@ limitations under the License.
}
.button {
color: #fff;
background-color: var(--primaryColor);
color: var(--primary-content);
background-color: var(--accent);
}
.button:focus,
@@ -65,46 +66,46 @@ limitations under the License.
width: 50px;
height: 50px;
border-radius: 50px;
background-color: var(--bgColor2);
background-color: var(--system);
}
.toolbarButton:hover,
.toolbarButtonSecondary:hover {
background-color: var(--bgColor4);
background-color: var(--quinary-content);
}
.toolbarButton.on,
.toolbarButton.off {
background-color: #ffffff;
background-color: var(--primary-content);
}
.toolbarButtonSecondary.on {
background-color: #0dbd8b;
background-color: var(--accent);
}
.iconButton:not(.stroke) svg * {
fill: #ffffff;
fill: var(--primary-content);
}
.iconButton:not(.stroke):hover svg * {
fill: #0dbd8b;
fill: var(--accent);
}
.iconButton.on:not(.stroke) svg * {
fill: #0dbd8b;
fill: var(--accent);
}
.iconButton.on.stroke svg * {
stroke: #0dbd8b;
stroke: var(--accent);
}
.hangupButton,
.hangupButton:hover {
background-color: #ff5b55;
background-color: var(--alert);
}
.toolbarButton.on svg * {
fill: #0dbd8b;
fill: var(--accent);
}
.toolbarButton.off svg * {
@@ -112,25 +113,25 @@ limitations under the License.
}
.toolbarButtonSecondary.on svg * {
fill: #ffffff;
fill: var(--primary-content);
}
.secondary,
.copyButton {
color: #0dbd8b;
border: 2px solid #0dbd8b;
color: var(--accent);
border: 2px solid var(--accent);
background-color: transparent;
}
.secondaryHangup {
color: #ff5b55;
border: 2px solid #ff5b55;
color: var(--alert);
border: 2px solid var(--alert);
background-color: transparent;
}
.copyButton.secondaryCopy {
color: var(--textColor1);
border-color: var(--textColor1);
color: var(--primary-content);
border-color: var(--primary-content);
}
.copyButton {
@@ -153,12 +154,12 @@ limitations under the License.
}
.copyButton:not(.on) svg * {
fill: #0dbd8b;
fill: var(--accent);
}
.copyButton.on {
border-color: transparent;
background-color: #0dbd8b;
background-color: var(--accent);
color: white;
}
@@ -167,21 +168,40 @@ limitations under the License.
}
.copyButton.secondaryCopy:not(.on) svg * {
fill: var(--textColor1);
fill: var(--primary-content);
}
.iconCopyButton svg * {
fill: var(--textColor3);
fill: var(--tertiary-content);
}
.iconCopyButton:hover svg * {
fill: #0dbd8b;
fill: var(--accent);
}
.iconCopyButton.on svg *,
.iconCopyButton.on:hover svg * {
fill: transparent;
stroke: #0dbd8b;
stroke: var(--accent);
}
.dropdownButton {
color: var(--primary-content);
padding: 2px 8px;
border-radius: 8px;
}
.dropdownButton:hover,
.dropdownButton.on {
background-color: var(--quinary-content);
}
.dropdownButton svg {
margin-left: 8px;
}
.dropdownButton svg * {
fill: var(--primary-content);
}
.lg {

View File

@@ -10,7 +10,7 @@
.callTile {
height: 95px;
padding: 12px;
background-color: var(--bgColor2);
background-color: var(--system);
border-radius: 8px;
overflow: hidden;
box-sizing: border-box;
@@ -36,7 +36,7 @@
flex-direction: column;
flex: 1;
padding: 0 16px;
color: var(--textColor1);
color: var(--primary-content);
min-width: 0;
}

View File

@@ -0,0 +1,3 @@
.label {
margin-bottom: 0;
}

View File

@@ -0,0 +1,69 @@
/*
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 React, { FC } from "react";
import { Item } from "@react-stately/collections";
import { Headline } from "../typography/Typography";
import { Button } from "../button";
import { PopoverMenuTrigger } from "../popover/PopoverMenu";
import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
import styles from "./CallTypeDropdown.module.css";
import commonStyles from "./common.module.css";
import menuStyles from "../Menu.module.css";
import { Menu } from "../Menu";
export enum CallType {
Video = "video",
Radio = "radio",
}
interface Props {
callType: CallType;
setCallType: (value: CallType) => void;
}
export const CallTypeDropdown: FC<Props> = ({ callType, setCallType }) => {
return (
<PopoverMenuTrigger placement="bottom">
<Button variant="dropdown" className={commonStyles.headline}>
<Headline className={styles.label}>
{callType === CallType.Video ? "Video call" : "Walkie-talkie call"}
</Headline>
</Button>
{(props) => (
<Menu {...props} label="Call type menu" onAction={setCallType}>
<Item key={CallType.Video} textValue="Video call">
<VideoIcon />
<span>Video call</span>
{callType === CallType.Video && (
<CheckIcon className={menuStyles.checkIcon} />
)}
</Item>
<Item key={CallType.Radio} textValue="Walkie-talkie call">
<MicIcon />
<span>Walkie-talkie call</span>
{callType === CallType.Radio && (
<CheckIcon className={menuStyles.checkIcon} />
)}
</Item>
</Menu>
)}
</PopoverMenuTrigger>
);
};

View File

@@ -15,7 +15,7 @@ limitations under the License.
*/
import React, { useState, useCallback } from "react";
import { createRoom, roomAliasFromRoomName } from "../matrix-utils";
import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
import { useGroupCallRooms } from "./useGroupCallRooms";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import commonStyles from "./common.module.css";
@@ -27,21 +27,21 @@ import { UserMenuContainer } from "../UserMenuContainer";
import { useModalTriggerState } from "../Modal";
import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { useHistory } from "react-router-dom";
import { Headline, Title } from "../typography/Typography";
import { Title } from "../typography/Typography";
import { Form } from "../form/Form";
import { useShouldShowPtt } from "../useShouldShowPtt";
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
export function RegisteredView({ client }) {
const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const history = useHistory();
const shouldShowPtt = useShouldShowPtt();
const onSubmit = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const roomName = data.get("callName");
const ptt = data.get("ptt") !== null;
const ptt = callType === CallType.Radio;
async function submit() {
setError(undefined);
@@ -56,7 +56,7 @@ export function RegisteredView({ client }) {
submit().catch((error) => {
if (error.errcode === "M_ROOM_IN_USE") {
setExistingRoomId(roomAliasFromRoomName(roomName));
setExistingRoomId(roomAliasLocalpartFromRoomName(roomName));
setLoading(false);
setError(undefined);
modalState.open();
@@ -68,7 +68,7 @@ export function RegisteredView({ client }) {
}
});
},
[client]
[client, callType]
);
const recentRooms = useGroupCallRooms(client);
@@ -79,6 +79,9 @@ export function RegisteredView({ client }) {
history.push(`/${existingRoomId}`);
}, [history, existingRoomId]);
const callNameLabel =
callType === CallType.Video ? "Video call name" : "Walkie-talkie call name";
return (
<>
<Header>
@@ -92,16 +95,14 @@ export function RegisteredView({ client }) {
<div className={commonStyles.container}>
<main className={commonStyles.main}>
<HeaderLogo className={commonStyles.logo} />
<Headline className={commonStyles.headline}>
Enter a call name
</Headline>
<CallTypeDropdown callType={callType} setCallType={setCallType} />
<Form className={styles.form} onSubmit={onSubmit}>
<FieldRow className={styles.fieldRow}>
<InputField
id="callName"
name="callName"
label="Call name"
placeholder="Call name"
label={callNameLabel}
placeholder={callNameLabel}
type="text"
required
autoComplete="off"
@@ -116,16 +117,6 @@ export function RegisteredView({ client }) {
{loading ? "Loading..." : "Go"}
</Button>
</FieldRow>
{shouldShowPtt && (
<FieldRow className={styles.fieldRow}>
<InputField
id="ptt"
name="ptt"
label="Push to Talk"
type="checkbox"
/>
</FieldRow>
)}
{error && (
<FieldRow className={styles.fieldRow}>
<ErrorMessage>{error.message}</ErrorMessage>

View File

@@ -15,81 +15,96 @@ limitations under the License.
*/
import React, { useCallback, useState } from "react";
import { useClient } from "../ClientContext";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { UserMenuContainer } from "../UserMenuContainer";
import { useHistory } from "react-router-dom";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { createRoom, roomAliasFromRoomName } from "../matrix-utils";
import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { useModalTriggerState } from "../Modal";
import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { useRecaptcha } from "../auth/useRecaptcha";
import { Body, Caption, Link, Headline } from "../typography/Typography";
import { Form } from "../form/Form";
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
import styles from "./UnauthenticatedView.module.css";
import commonStyles from "./common.module.css";
import { generateRandomName } from "../auth/generateRandomName";
import { useShouldShowPtt } from "../useShouldShowPtt";
export function UnauthenticatedView() {
const shouldShowPtt = useShouldShowPtt();
const { setClient } = useClient();
const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const [{ privacyPolicyUrl, recaptchaKey }, register] =
const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const { modalState, modalProps } = useModalTriggerState();
const [onFinished, setOnFinished] = useState();
const history = useHistory();
const onSubmit = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const roomName = data.get("callName");
const displayName = data.get("displayName");
const ptt = data.get("ptt") !== null;
const ptt = callType === CallType.Radio;
async function submit() {
setError(undefined);
setLoading(true);
const recaptchaResponse = await execute();
const userName = generateRandomName();
const client = await register(
const [client, session] = await register(
userName,
randomString(16),
displayName,
recaptchaResponse,
true
);
const roomIdOrAlias = await createRoom(client, roomName, ptt);
if (roomIdOrAlias) {
history.push(`/room/${roomIdOrAlias}`);
let roomIdOrAlias;
try {
roomIdOrAlias = await createRoom(client, roomName, ptt);
} catch (error) {
if (error.errcode === "M_ROOM_IN_USE") {
setOnFinished(() => () => {
setClient(client, session);
const aliasLocalpart = roomAliasLocalpartFromRoomName(roomName);
const [, serverName] = client.getUserId().split(":");
history.push(`/room/#${aliasLocalpart}:${serverName}`);
});
setLoading(false);
modalState.open();
return;
} else {
throw error;
}
}
// Only consider the registration successful if we managed to create the room, too
setClient(client, session);
history.push(`/room/${roomIdOrAlias}`);
}
submit().catch((error) => {
if (error.errcode === "M_ROOM_IN_USE") {
setExistingRoomId(roomAliasFromRoomName(roomName));
setLoading(false);
setError(undefined);
modalState.open();
} else {
console.error(error);
setLoading(false);
setError(error);
reset();
}
console.error(error);
setLoading(false);
setError(error);
reset();
});
},
[register, reset, execute]
[register, reset, execute, history, callType]
);
const { modalState, modalProps } = useModalTriggerState();
const [existingRoomId, setExistingRoomId] = useState();
const history = useHistory();
const onJoinExistingRoom = useCallback(() => {
history.push(`/${existingRoomId}`);
}, [history, existingRoomId]);
const callNameLabel =
callType === CallType.Video ? "Video call name" : "Walkie-talkie call name";
return (
<>
@@ -104,16 +119,14 @@ export function UnauthenticatedView() {
<div className={commonStyles.container}>
<main className={commonStyles.main}>
<HeaderLogo className={commonStyles.logo} />
<Headline className={commonStyles.headline}>
Enter a call name
</Headline>
<CallTypeDropdown callType={callType} setCallType={setCallType} />
<Form className={styles.form} onSubmit={onSubmit}>
<FieldRow>
<InputField
id="callName"
name="callName"
label="Call name"
placeholder="Call name"
label={callNameLabel}
placeholder={callNameLabel}
type="text"
required
autoComplete="off"
@@ -130,16 +143,6 @@ export function UnauthenticatedView() {
autoComplete="off"
/>
</FieldRow>
{shouldShowPtt && (
<FieldRow>
<InputField
id="ptt"
name="ptt"
label="Push to Talk"
type="checkbox"
/>
</FieldRow>
)}
<Caption>
By clicking "Go", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
@@ -170,7 +173,7 @@ export function UnauthenticatedView() {
</footer>
</div>
{modalState.isOpen && (
<JoinExistingCallModal onJoin={onJoinExistingRoom} {...modalProps} />
<JoinExistingCallModal onJoin={onFinished} {...modalProps} />
)}
</>
);

View File

@@ -79,7 +79,7 @@ export function useGroupCallRooms(client) {
return {
roomId: room.getCanonicalAlias() || room.roomId,
roomName: room.name,
avatarUrl: null,
avatarUrl: room.getMxcAvatarUrl(),
room,
groupCall,
participants: [...groupCall.participants],

View File

@@ -1,5 +1,5 @@
/*
Copyright 2021 New Vector Ltd
Copyright 2021-2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -25,19 +25,19 @@ limitations under the License.
:root {
--inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f,
U+25c2-2664, U+2666-2763, U+2765-2b05, U+2b07-2b1b, U+2b1d-10FFFF;
--primaryColor: #0dbd8b;
--bgColor1: #15191e;
--bgColor2: #21262c;
--bgColor3: #444;
--bgColor4: #394049;
--bgColor5: #8d97a5;
--textColor1: #fff;
--textColor2: #6f7882;
--textColor3: #8e99a4;
--textColor4: #a9b2bc;
--inputBorderColor: #394049;
--inputBorderColorFocused: #0086e6;
--linkColor: #0086e6;
--accent: #0dbd8b;
--accent-20: #0dbd8b33;
--alert: #ff5b55;
--alert-20: #ff5b5533;
--links: #0086e6;
--primary-content: #ffffff;
--secondary-content: #a9b2bc;
--tertiary-content: #8e99a4;
--quaternary-content: #6f7882;
--quinary-content: #394049;
--system: #21262c;
--background: #15191e;
--bgColor3: #444; /* This isn't found anywhere in the designs or Compound */
}
@font-face {
@@ -121,8 +121,9 @@ limitations under the License.
}
body {
background-color: var(--bgColor1);
color: var(--textColor1);
background-color: var(--background);
color: var(--primary-content);
color-scheme: dark;
margin: 0;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
@@ -181,7 +182,7 @@ p {
}
a {
color: var(--primaryColor);
color: var(--accent);
text-decoration: none;
}
@@ -193,8 +194,8 @@ a:active {
hr {
width: calc(100% - 24px);
border: none;
border-top: 1px solid var(--bgColor4);
color: var(--textColor2);
border-top: 1px solid var(--quinary-content);
color: var(--quaternary-content);
overflow: visible;
text-align: center;
height: 5px;

View File

@@ -26,7 +26,7 @@
position: absolute;
bottom: 11px;
right: -4px;
background-color: var(--bgColor4);
background-color: var(--quinary-content);
width: 20px;
height: 20px;
border-radius: 10px;
@@ -37,5 +37,5 @@
}
.removeButton {
color: #0dbd8b;
color: var(--accent);
}

View File

@@ -39,7 +39,18 @@ export function Field({ children, className, ...rest }) {
export const InputField = forwardRef(
(
{ id, label, className, type, checked, prefix, suffix, disabled, ...rest },
{
id,
label,
className,
type,
checked,
prefix,
suffix,
description,
disabled,
...rest
},
ref
) => {
return (
@@ -82,6 +93,7 @@ export const InputField = forwardRef(
{label}
</label>
{suffix && <span>{suffix}</span>}
{description && <p className={styles.description}>{description}</p>}
</Field>
);
}

View File

@@ -26,7 +26,7 @@
.inputField {
border-radius: 4px;
transition: border-color 0.25s;
border: 1px solid var(--inputBorderColor);
border: 1px solid var(--quinary-content);
}
.inputField input,
@@ -36,8 +36,8 @@
border: none;
border-radius: 4px;
padding: 12px 9px 10px 9px;
color: var(--textColor1);
background-color: var(--bgColor1);
color: var(--primary-content);
background-color: var(--background);
flex: 1;
min-width: 0;
}
@@ -45,7 +45,7 @@
.inputField.disabled input,
.inputField.disabled textarea,
.inputField.disabled span {
color: var(--textColor2);
color: var(--quaternary-content);
}
.inputField span {
@@ -65,13 +65,13 @@
.inputField input:placeholder-shown:focus::placeholder,
.inputField textarea:placeholder-shown:focus::placeholder {
transition: color 0.25s ease-in 0.1s;
color: var(--textColor2);
color: var(--quaternary-content);
}
.inputField label {
transition: font-size 0.25s ease-out 0.1s, color 0.25s ease-out 0.1s,
top 0.25s ease-out 0.1s, background-color 0.25s ease-out 0.1s;
color: var(--textColor3);
color: var(--tertiary-content);
background-color: transparent;
font-size: 15px;
position: absolute;
@@ -87,7 +87,7 @@
}
.inputField:focus-within {
border-color: var(--inputBorderColorFocused);
border-color: var(--links);
}
.inputField input:focus,
@@ -101,7 +101,7 @@
.inputField textarea:focus + label,
.inputField textarea:not(:placeholder-shown) + label,
.inputField.prefix textarea + label {
background-color: var(--bgColor2);
background-color: var(--system);
transition: font-size 0.25s ease-out 0s, color 0.25s ease-out 0s,
top 0.25s ease-out 0s, background-color 0.25s ease-out 0s;
font-size: 10px;
@@ -112,19 +112,21 @@
.inputField input:focus + label,
.inputField textarea:focus + label {
color: var(--inputBorderColorFocused);
color: var(--links);
}
.checkboxField {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
}
.checkboxField label {
display: flex;
align-items: center;
flex-grow: 1;
font-size: 13px;
font-size: 15px;
line-height: 24px;
}
.checkboxField input {
@@ -154,12 +156,12 @@
}
.checkbox svg * {
stroke: #fff;
stroke: var(--primary-content);
}
.checkboxField input[type="checkbox"]:checked + label > .checkbox {
background: var(--primaryColor);
border-color: var(--primaryColor);
background: var(--accent);
border-color: var(--accent);
}
.checkboxField input[type="checkbox"]:checked + label > .checkbox svg {
@@ -167,12 +169,18 @@
}
.checkboxField:focus-within .checkbox {
border: 1.5px solid var(--inputBorderColorFocused) !important;
border: 1.5px solid var(--links) !important;
}
.errorMessage {
margin: 0;
font-size: 13px;
color: #ff5b55;
color: var(--alert);
font-weight: 600;
}
.description {
color: var(--secondary-content);
margin-left: 26px;
width: 100%; /* Ensure that it breaks onto the next row */
}

View File

@@ -17,11 +17,11 @@
align-items: center;
justify-content: space-between;
padding: 0 12px;
background-color: var(--bgColor1);
background-color: var(--background);
border-radius: 8px;
border: 1px solid var(--inputBorderColor);
border: 1px solid var(--quinary-content);
font-size: 15px;
color: var(--textColor1);
color: var(--primary-content);
height: 40px;
max-width: 100%;
width: 100%;

View File

@@ -11,7 +11,7 @@
height: 24px;
border: none;
border-radius: 21px;
background-color: #6f7882;
background-color: var(--quaternary-content);
cursor: pointer;
margin-right: 8px;
}
@@ -22,7 +22,7 @@
width: 20px;
height: 20px;
border-radius: 21px;
background-color: #15191e;
background-color: var(--background);
left: 2px;
top: 2px;
}
@@ -30,11 +30,11 @@
.label {
padding: 10px 8px;
line-height: 24px;
color: #6f7882;
color: var(--quaternary-content);
}
.toggle.on .button {
background-color: #0dbd8b;
background-color: var(--accent);
}
.toggle.on .ball {
@@ -42,5 +42,5 @@
}
.toggle.on .label {
color: #ffffff;
color: var(--primary-content);
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2021 New Vector Ltd
Copyright 2021-2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,13 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// We need to import this somewhere, once, so that the correct 'request'
// function gets set. It needs to be not in the same file as we use
// createClient, or the typescript transpiler gets confused about
// dependency references.
import "matrix-js-sdk/src/browser-index";
import React from "react";
import ReactDOM from "react-dom";
import { createBrowserHistory } from "history";
import "./index.css";
import App from "./App";
import * as Sentry from "@sentry/react";
import { Integrations } from "@sentry/tracing";
import "./index.css";
import App from "./App";
import { ErrorView } from "./FullScreenView";
import { init as initRageshake } from "./settings/rageshake";
import { InspectorContextProvider } from "./room/GroupCallInspector";
@@ -31,30 +38,50 @@ console.info(`matrix-video-chat ${import.meta.env.VITE_APP_VERSION || "dev"}`);
if (import.meta.env.VITE_CUSTOM_THEME) {
const style = document.documentElement.style;
style.setProperty("--primaryColor", import.meta.env.VITE_PRIMARY_COLOR);
style.setProperty("--bgColor1", import.meta.env.VITE_BG_COLOR_1);
style.setProperty("--bgColor2", import.meta.env.VITE_BG_COLOR_2);
style.setProperty("--bgColor3", import.meta.env.VITE_BG_COLOR_3);
style.setProperty("--bgColor4", import.meta.env.VITE_BG_COLOR_4);
style.setProperty("--bgColor5", import.meta.env.VITE_BG_COLOR_5);
style.setProperty("--textColor1", import.meta.env.VITE_TEXT_COLOR_1);
style.setProperty("--textColor2", import.meta.env.VITE_TEXT_COLOR_2);
style.setProperty("--textColor4", import.meta.env.VITE_TEXT_COLOR_4);
style.setProperty("--accent", import.meta.env.VITE_THEME_ACCENT as string);
style.setProperty(
"--inputBorderColor",
import.meta.env.VITE_INPUT_BORDER_COLOR
"--accent-20",
import.meta.env.VITE_THEME_ACCENT_20 as string
);
style.setProperty("--alert", import.meta.env.VITE_THEME_ALERT as string);
style.setProperty(
"--alert-20",
import.meta.env.VITE_THEME_ALERT_20 as string
);
style.setProperty("--links", import.meta.env.VITE_THEME_LINKS as string);
style.setProperty(
"--primary-content",
import.meta.env.VITE_THEME_PRIMARY_CONTENT as string
);
style.setProperty(
"--inputBorderColorFocused",
import.meta.env.VITE_INPUT_BORDER_COLOR_FOCUSED
"--secondary-content",
import.meta.env.VITE_THEME_SECONDARY_CONTENT as string
);
style.setProperty(
"--tertiary-content",
import.meta.env.VITE_THEME_TERTIARY_CONTENT as string
);
style.setProperty(
"--quaternary-content",
import.meta.env.VITE_THEME_QUATERNARY_CONTENT as string
);
style.setProperty(
"--quinary-content",
import.meta.env.VITE_THEME_QUINARY_CONTENT as string
);
style.setProperty("--system", import.meta.env.VITE_THEME_SYSTEM as string);
style.setProperty(
"--background",
import.meta.env.VITE_THEME_BACKGROUND as string
);
}
const history = createBrowserHistory();
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? "production",
dsn: import.meta.env.VITE_SENTRY_DSN as string,
environment:
(import.meta.env.VITE_SENTRY_ENVIRONMENT as string) ?? "production",
integrations: [
new Integrations.BrowserTracing({
routingInstrumentation: Sentry.reactRouterV5Instrumentation(history),

View File

@@ -1,140 +0,0 @@
import matrix from "matrix-js-sdk/src/browser-index";
import {
GroupCallIntent,
GroupCallType,
} from "matrix-js-sdk/src/browser-index";
export const defaultHomeserver =
import.meta.env.VITE_DEFAULT_HOMESERVER ||
`${window.location.protocol}//${window.location.host}`;
export const defaultHomeserverHost = new URL(defaultHomeserver).host;
function waitForSync(client) {
return new Promise((resolve, reject) => {
const onSync = (state, _old, data) => {
if (state === "PREPARED") {
resolve();
client.removeListener("sync", onSync);
} else if (state === "ERROR") {
reject(data?.error);
client.removeListener("sync", onSync);
}
};
client.on("sync", onSync);
});
}
export async function initClient(clientOptions) {
const client = matrix.createClient({
...clientOptions,
useAuthorizationHeader: true,
});
await client.startClient({
// dirty hack to reduce chance of gappy syncs
// should be fixed by spotting gaps and backpaginating
initialSyncLimit: 50,
});
await waitForSync(client);
return client;
}
export function roomAliasFromRoomName(roomName) {
return roomName
.trim()
.replace(/\s/g, "-")
.replace(/[^\w-]/g, "")
.toLowerCase();
}
export function roomNameFromRoomId(roomId) {
return roomId
.match(/([^:]+):.*$/)[1]
.substring(1)
.split("-")
.map((part) =>
part.length > 0 ? part.charAt(0).toUpperCase() + part.slice(1) : part
)
.join(" ")
.toLowerCase();
}
export function isLocalRoomId(roomId) {
if (!roomId) {
return false;
}
const parts = roomId.match(/[^:]+:(.*)$/);
if (parts.length < 2) {
return false;
}
return parts[1] === defaultHomeserverHost;
}
export async function createRoom(client, name, isPtt = false) {
const { room_id, room_alias } = await client.createRoom({
visibility: "private",
preset: "public_chat",
name,
room_alias_name: roomAliasFromRoomName(name),
power_level_content_override: {
invite: 100,
kick: 100,
ban: 100,
redact: 50,
state_default: 0,
events_default: 0,
users_default: 0,
events: {
"m.room.power_levels": 100,
"m.room.history_visibility": 100,
"m.room.tombstone": 100,
"m.room.encryption": 100,
"m.room.name": 50,
"m.room.message": 0,
"m.room.encrypted": 50,
"m.sticker": 50,
"org.matrix.msc3401.call.member": 0,
},
users: {
[client.getUserId()]: 100,
},
},
});
console.log({ isPtt });
await client.createGroupCall(
room_id,
isPtt ? GroupCallType.Voice : GroupCallType.Video,
isPtt,
GroupCallIntent.Prompt
);
return room_alias || room_id;
}
export function getRoomUrl(roomId) {
if (roomId.startsWith("#")) {
const [localPart, host] = roomId.replace("#", "").split(":");
if (host !== defaultHomeserverHost) {
return `${window.location.protocol}//${window.location.host}/room/${roomId}`;
} else {
return `${window.location.protocol}//${window.location.host}/${localPart}`;
}
} else {
return `${window.location.protocol}//${window.location.host}/room/${roomId}`;
}
}
export function getAvatarUrl(client, mxcUrl, avatarSize = 96) {
const width = Math.floor(avatarSize * window.devicePixelRatio);
const height = Math.floor(avatarSize * window.devicePixelRatio);
return mxcUrl && client.mxcUrlToHttp(mxcUrl, width, height, "crop");
}

224
src/matrix-utils.ts Normal file
View File

@@ -0,0 +1,224 @@
import Olm from "@matrix-org/olm";
import olmWasmPath from "@matrix-org/olm/olm.wasm?url";
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 { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store";
import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
import { ICreateClientOpts } from "matrix-js-sdk/src/matrix";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { Visibility, Preset } from "matrix-js-sdk/src/@types/partials";
import {
GroupCallIntent,
GroupCallType,
} from "matrix-js-sdk/src/webrtc/groupCall";
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
import IndexedDBWorker from "./IndexedDBWorker?worker";
export const defaultHomeserver =
(import.meta.env.VITE_DEFAULT_HOMESERVER as string) ??
`${window.location.protocol}//${window.location.host}`;
export const defaultHomeserverHost = new URL(defaultHomeserver).host;
function waitForSync(client: MatrixClient) {
return new Promise<void>((resolve, reject) => {
const onSync = (
state: SyncState,
_old: SyncState,
data: ISyncStateData
) => {
if (state === "PREPARED") {
resolve();
client.removeListener(ClientEvent.Sync, onSync);
} else if (state === "ERROR") {
reject(data?.error);
client.removeListener(ClientEvent.Sync, onSync);
}
};
client.on(ClientEvent.Sync, onSync);
});
}
export async function initClient(
clientOptions: ICreateClientOpts
): Promise<MatrixClient> {
// TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
window.OLM_OPTIONS = {};
await Olm.init({ locateFile: () => olmWasmPath });
let indexedDB: IDBFactory;
try {
indexedDB = window.indexedDB;
} catch (e) {}
const storeOpts = {} as ICreateClientOpts;
if (indexedDB && localStorage && !import.meta.env.DEV) {
storeOpts.store = new IndexedDBStore({
indexedDB: window.indexedDB,
localStorage: window.localStorage,
dbName: "element-call-sync",
workerFactory: () => new IndexedDBWorker(),
});
}
if (localStorage) {
storeOpts.sessionStore = new WebStorageSessionStore(localStorage);
}
if (indexedDB) {
storeOpts.cryptoStore = new IndexedDBCryptoStore(
indexedDB,
"matrix-js-sdk:crypto"
);
}
const client = createClient({
...storeOpts,
...clientOptions,
useAuthorizationHeader: true,
// 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.
localTimeoutMs: 5000,
});
try {
await client.store.startup();
} catch (error) {
console.error(
"Error starting matrix client store. Falling back to memory store.",
error
);
client.store = new MemoryStore({ localStorage });
await client.store.startup();
}
if (client.initCrypto) {
await client.initCrypto();
}
await client.startClient({
// dirty hack to reduce chance of gappy syncs
// should be fixed by spotting gaps and backpaginating
initialSyncLimit: 50,
});
await waitForSync(client);
return client;
}
export function roomAliasLocalpartFromRoomName(roomName: string): string {
return roomName
.trim()
.replace(/\s/g, "-")
.replace(/[^\w-]/g, "")
.toLowerCase();
}
export function fullAliasFromRoomName(
roomName: string,
client: MatrixClient
): string {
return `#${roomAliasLocalpartFromRoomName(roomName)}:${client.getDomain()}`;
}
export function roomNameFromRoomId(roomId: string): string {
return roomId
.match(/([^:]+):.*$/)[1]
.substring(1)
.split("-")
.map((part) =>
part.length > 0 ? part.charAt(0).toUpperCase() + part.slice(1) : part
)
.join(" ")
.toLowerCase();
}
export function isLocalRoomId(roomId: string): boolean {
if (!roomId) {
return false;
}
const parts = roomId.match(/[^:]+:(.*)$/);
if (parts.length < 2) {
return false;
}
return parts[1] === defaultHomeserverHost;
}
export async function createRoom(
client: MatrixClient,
name: string,
isPtt = false
): Promise<string> {
const createRoomResult = await client.createRoom({
visibility: Visibility.Private,
preset: Preset.PublicChat,
name,
room_alias_name: roomAliasLocalpartFromRoomName(name),
power_level_content_override: {
invite: 100,
kick: 100,
ban: 100,
redact: 50,
state_default: 0,
events_default: 0,
users_default: 0,
events: {
"m.room.power_levels": 100,
"m.room.history_visibility": 100,
"m.room.tombstone": 100,
"m.room.encryption": 100,
"m.room.name": 50,
"m.room.message": 0,
"m.room.encrypted": 50,
"m.sticker": 50,
"org.matrix.msc3401.call.member": 0,
},
users: {
[client.getUserId()]: 100,
},
},
});
console.log(`Creating ${isPtt ? "PTT" : "video"} group call room`);
await client.createGroupCall(
createRoomResult.room_id,
isPtt ? GroupCallType.Voice : GroupCallType.Video,
isPtt,
GroupCallIntent.Prompt
);
return fullAliasFromRoomName(name, client);
}
export function getRoomUrl(roomId: string): string {
if (roomId.startsWith("#")) {
const [localPart, host] = roomId.replace("#", "").split(":");
if (host !== defaultHomeserverHost) {
return `${window.location.protocol}//${window.location.host}/room/${roomId}`;
} else {
return `${window.location.protocol}//${window.location.host}/${localPart}`;
}
} else {
return `${window.location.protocol}//${window.location.host}/room/${roomId}`;
}
}
export function getAvatarUrl(
client: MatrixClient,
mxcUrl: string,
avatarSize = 96
): string {
const width = Math.floor(avatarSize * window.devicePixelRatio);
const height = Math.floor(avatarSize * window.devicePixelRatio);
return mxcUrl && client.mxcUrlToHttp(mxcUrl, width, height, "crop");
}

View File

@@ -2,7 +2,7 @@
display: flex;
flex-direction: column;
width: 194px;
background: var(--bgColor2);
background: var(--system);
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
border-radius: 8px;
}

View File

@@ -15,7 +15,6 @@ limitations under the License.
*/
import { useState, useCallback, useEffect } from "react";
import { getAvatarUrl } from "../matrix-utils";
export function useProfile(client) {
const [{ loading, displayName, avatarUrl, error, success }, setState] =
@@ -26,7 +25,7 @@ export function useProfile(client) {
success: false,
loading: false,
displayName: user?.rawDisplayName,
avatarUrl: user && client && getAvatarUrl(client, user.avatarUrl),
avatarUrl: user?.avatarUrl,
error: null,
};
});
@@ -37,7 +36,7 @@ export function useProfile(client) {
success: false,
loading: false,
displayName,
avatarUrl: getAvatarUrl(client, avatarUrl),
avatarUrl,
error: null,
});
};
@@ -84,11 +83,7 @@ export function useProfile(client) {
setState((prev) => ({
...prev,
displayName,
avatarUrl: removeAvatar
? null
: mxcAvatarUrl
? getAvatarUrl(client, mxcAvatarUrl)
: prev.avatarUrl,
avatarUrl: removeAvatar ? null : mxcAvatarUrl ?? prev.avatarUrl,
loading: false,
success: true,
}));

View File

@@ -33,7 +33,7 @@ export function AudioPreview({
}) {
return (
<>
<h1>{`${roomName} - Radio Call`}</h1>
<h1>{`${roomName} - Walkie-talkie call`}</h1>
<div className={styles.preview}>
{state === GroupCallState.LocalCallFeedUninitialized && (
<Body fontWeight="semiBold" className={styles.microphonePermissions}>

View File

@@ -20,7 +20,7 @@ import { PopoverMenuTrigger } from "../popover/PopoverMenu";
import { ReactComponent as SpotlightIcon } from "../icons/Spotlight.svg";
import { ReactComponent as FreedomIcon } from "../icons/Freedom.svg";
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
import styles from "./GridLayoutMenu.module.css";
import menuStyles from "../Menu.module.css";
import { Menu } from "../Menu";
import { Item } from "@react-stately/collections";
import { Tooltip, TooltipTrigger } from "../Tooltip";
@@ -39,13 +39,15 @@ export function GridLayoutMenu({ layout, setLayout }) {
<Item key="freedom" textValue="Freedom">
<FreedomIcon />
<span>Freedom</span>
{layout === "freedom" && <CheckIcon className={styles.checkIcon} />}
{layout === "freedom" && (
<CheckIcon className={menuStyles.checkIcon} />
)}
</Item>
<Item key="spotlight" textValue="Spotlight">
<SpotlightIcon />
<span>Spotlight</span>
{layout === "spotlight" && (
<CheckIcon className={styles.checkIcon} />
<CheckIcon className={menuStyles.checkIcon} />
)}
</Item>
</Menu>

View File

@@ -1,8 +0,0 @@
.checkIcon {
position: absolute;
right: 16px;
}
.checkIcon * {
stroke: var(--textColor1);
}

View File

@@ -1,5 +1,5 @@
.inspector {
background-color: var(--bgColor2);
background-color: var(--system);
}
.scrollContainer {
@@ -20,6 +20,6 @@
.sequenceDiagramViewer :global(.messageText) {
font-size: 12px;
fill: var(--textColor1) !important;
stroke: var(--textColor1) !important;
fill: var(--primary-content) !important;
stroke: var(--primary-content) !important;
}

View File

@@ -23,6 +23,7 @@ import { LobbyView } from "./LobbyView";
import { InCallView } from "./InCallView";
import { PTTCallView } from "./PTTCallView";
import { CallEndedView } from "./CallEndedView";
import { useRoomAvatar } from "./useRoomAvatar";
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
import { useLocationNavigation } from "../useLocationNavigation";
@@ -32,19 +33,6 @@ export function GroupCallView({
roomId,
groupCall,
}) {
const [showInspector, setShowInspector] = useState(
() => !!localStorage.getItem("matrix-group-call-inspector")
);
const onChangeShowInspector = useCallback((show) => {
setShowInspector(show);
if (show) {
localStorage.setItem("matrix-group-call-inspector", "true");
} else {
localStorage.removeItem("matrix-group-call-inspector");
}
}, []);
const {
state,
error,
@@ -67,6 +55,8 @@ export function GroupCallView({
participants,
} = useGroupCall(groupCall);
const avatarUrl = useRoomAvatar(groupCall.room);
useEffect(() => {
window.groupCall = groupCall;
}, [groupCall]);
@@ -96,12 +86,11 @@ export function GroupCallView({
client={client}
roomId={roomId}
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
groupCall={groupCall}
participants={participants}
userMediaFeeds={userMediaFeeds}
onLeave={onLeave}
setShowInspector={onChangeShowInspector}
showInspector={showInspector}
/>
);
} else {
@@ -110,6 +99,7 @@ export function GroupCallView({
groupCall={groupCall}
client={client}
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
@@ -121,8 +111,6 @@ export function GroupCallView({
isScreensharing={isScreensharing}
localScreenshareFeed={localScreenshareFeed}
screenshareFeeds={screenshareFeeds}
setShowInspector={onChangeShowInspector}
showInspector={showInspector}
roomId={roomId}
/>
);
@@ -142,6 +130,7 @@ export function GroupCallView({
groupCall={groupCall}
hasLocalParticipant={hasLocalParticipant}
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
state={state}
onInitLocalCallFeed={initLocalCallFeed}
localCallFeed={localCallFeed}
@@ -150,8 +139,6 @@ export function GroupCallView({
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
setShowInspector={onChangeShowInspector}
showInspector={showInspector}
roomId={roomId}
/>
);

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useMemo } from "react";
import React, { useCallback, useMemo, useRef } from "react";
import styles from "./InCallView.module.css";
import {
HangupButton,
@@ -25,7 +25,6 @@ import {
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { VideoGrid, useVideoGridLayout } from "../video-grid/VideoGrid";
import { VideoTileContainer } from "../video-grid/VideoTileContainer";
import { getAvatarUrl } from "../matrix-utils";
import { GroupCallInspector } from "./GroupCallInspector";
import { OverflowMenu } from "./OverflowMenu";
import { GridLayoutMenu } from "./GridLayoutMenu";
@@ -35,6 +34,8 @@ import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { usePreventScroll } from "@react-aria/overlays";
import { useMediaHandler } from "../settings/useMediaHandler";
import { useShowInspector } from "../settings/useSetting";
import { useModalTriggerState } from "../Modal";
const canScreenshare = "getDisplayMedia" in navigator.mediaDevices;
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
@@ -46,6 +47,7 @@ export function InCallView({
client,
groupCall,
roomName,
avatarUrl,
microphoneMuted,
localVideoMuted,
toggleLocalVideoMuted,
@@ -56,14 +58,19 @@ export function InCallView({
toggleScreensharing,
isScreensharing,
screenshareFeeds,
setShowInspector,
showInspector,
roomId,
}) {
usePreventScroll();
const [layout, setLayout] = useVideoGridLayout(screenshareFeeds.length > 0);
const { audioOutput } = useMediaHandler();
const [showInspector] = useShowInspector();
const audioContext = useRef();
if (!audioContext.current) audioContext.current = new AudioContext();
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
const items = useMemo(() => {
const participants = [];
@@ -100,23 +107,6 @@ export function InCallView({
return participants;
}, [userMediaFeeds, activeSpeaker, screenshareFeeds, layout]);
const onFocusTile = useCallback(
(tiles, focusedTile) => {
if (layout === "freedom") {
return tiles.map((tile) => {
if (tile === focusedTile) {
return { ...tile, focused: !tile.focused };
}
return tile;
});
} else {
return tiles;
}
},
[layout, setLayout]
);
const renderAvatar = useCallback(
(roomMember, width, height) => {
const avatarUrl = roomMember.user?.avatarUrl;
@@ -125,13 +115,8 @@ export function InCallView({
return (
<Avatar
key={roomMember.userId}
style={{
width: size,
height: size,
borderRadius: size,
fontSize: Math.round(size / 2),
}}
src={avatarUrl && getAvatarUrl(client, avatarUrl, 96)}
size={size}
src={avatarUrl}
fallback={roomMember.name.slice(0, 1).toUpperCase()}
className={styles.avatar}
/>
@@ -149,7 +134,7 @@ export function InCallView({
<div className={styles.inRoom}>
<Header>
<LeftNav>
<RoomHeaderInfo roomName={roomName} />
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
</LeftNav>
<RightNav>
<GridLayoutMenu layout={layout} setLayout={setLayout} />
@@ -161,12 +146,7 @@ export function InCallView({
<p>Waiting for other participants...</p>
</div>
) : (
<VideoGrid
items={items}
layout={layout}
onFocusTile={onFocusTile}
disableAnimations={isSafari}
>
<VideoGrid items={items} layout={layout} disableAnimations={isSafari}>
{({ item, ...rest }) => (
<VideoTileContainer
key={item.id}
@@ -174,6 +154,7 @@ export function InCallView({
getAvatar={renderAvatar}
showName={items.length > 2 || item.focused}
audioOutputDevice={audioOutput}
audioContext={audioContext.current}
disableSpeakingIndicator={items.length < 3}
{...rest}
/>
@@ -192,10 +173,11 @@ export function InCallView({
<OverflowMenu
inCall
roomId={roomId}
setShowInspector={setShowInspector}
showInspector={showInspector}
client={client}
groupCall={groupCall}
showInvite={true}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
<HangupButton onPress={onLeave} />
</div>

View File

@@ -32,6 +32,7 @@ export function LobbyView({
client,
groupCall,
roomName,
avatarUrl,
state,
onInitLocalCallFeed,
onEnter,
@@ -40,8 +41,6 @@ export function LobbyView({
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
setShowInspector,
showInspector,
roomId,
}) {
const { stream } = useCallFeed(localCallFeed);
@@ -72,7 +71,7 @@ export function LobbyView({
<div className={styles.room}>
<Header>
<LeftNav>
<RoomHeaderInfo roomName={roomName} />
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
</LeftNav>
<RightNav>
<UserMenuContainer />
@@ -95,12 +94,11 @@ export function LobbyView({
<VideoPreview
state={state}
client={client}
roomId={roomId}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
setShowInspector={setShowInspector}
showInspector={showInspector}
stream={stream}
audioOutput={audioOutput}
/>

View File

@@ -31,17 +31,16 @@ import { FeedbackModal } from "./FeedbackModal";
export function OverflowMenu({
roomId,
setShowInspector,
showInspector,
inCall,
groupCall,
showInvite,
feedbackModalState,
feedbackModalProps,
}) {
const { modalState: inviteModalState, modalProps: inviteModalProps } =
useModalTriggerState();
const { modalState: settingsModalState, modalProps: settingsModalProps } =
useModalTriggerState();
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
// TODO: On closing modal, focus should be restored to the trigger button
// https://github.com/adobe/react-spectrum/issues/2444
@@ -70,10 +69,12 @@ export function OverflowMenu({
</TooltipTrigger>
{(props) => (
<Menu {...props} label="More menu" onAction={onAction}>
<Item key="invite" textValue="Invite people">
<AddUserIcon />
<span>Invite people</span>
</Item>
{showInvite && (
<Item key="invite" textValue="Invite people">
<AddUserIcon />
<span>Invite people</span>
</Item>
)}
<Item key="settings" textValue="Settings">
<SettingsIcon />
<span>Settings</span>
@@ -85,13 +86,7 @@ export function OverflowMenu({
</Menu>
)}
</PopoverMenuTrigger>
{settingsModalState.isOpen && (
<SettingsModal
{...settingsModalProps}
setShowInspector={setShowInspector}
showInspector={showInspector}
/>
)}
{settingsModalState.isOpen && <SettingsModal {...settingsModalProps} />}
{inviteModalState.isOpen && (
<InviteModal roomId={roomId} {...inviteModalProps} />
)}

View File

@@ -4,22 +4,20 @@
max-height: 232px;
max-width: 232px;
border-radius: 116px;
color: ##fff;
border: 6px solid #0dbd8b;
color: var(--primary-content);
border: 6px solid var(--accent);
background-color: #21262c;
position: relative;
padding: 0;
cursor: pointer;
}
.talking {
background-color: #0dbd8b;
box-shadow: 0px 0px 0px 17px rgba(13, 189, 139, 0.2),
0px 0px 0px 34px rgba(13, 189, 139, 0.2);
background-color: var(--accent);
cursor: unset;
}
.error {
background-color: #ff5b55;
border-color: #ff5b55;
box-shadow: 0px 0px 0px 17px rgba(255, 91, 85, 0.2),
0px 0px 0px 34px rgba(255, 91, 85, 0.2);
background-color: var(--alert);
border-color: var(--alert);
}

View File

@@ -16,6 +16,7 @@ limitations under the License.
import React, { useCallback, useEffect, useState, createRef } from "react";
import classNames from "classnames";
import { useSpring, animated } from "@react-spring/web";
import styles from "./PTTButton.module.css";
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
@@ -27,6 +28,7 @@ interface Props {
activeSpeakerDisplayName: string;
activeSpeakerAvatarUrl: string;
activeSpeakerIsLocalUser: boolean;
activeSpeakerVolume: number;
size: number;
startTalking: () => void;
stopTalking: () => void;
@@ -44,6 +46,7 @@ export const PTTButton: React.FC<Props> = ({
activeSpeakerDisplayName,
activeSpeakerAvatarUrl,
activeSpeakerIsLocalUser,
activeSpeakerVolume,
size,
startTalking,
stopTalking,
@@ -130,12 +133,32 @@ export const PTTButton: React.FC<Props> = ({
);
};
}, [onWindowMouseUp, onWindowTouchEnd, onButtonTouchStart, buttonRef]);
const { shadow } = useSpring({
shadow: (Math.max(activeSpeakerVolume, -70) + 70) * 0.6,
config: {
clamp: true,
tension: 300,
},
});
const shadowColor = showTalkOverError
? "var(--alert-20)"
: "var(--accent-20)";
return (
<button
<animated.button
className={classNames(styles.pttButton, {
[styles.talking]: activeSpeakerUserId,
[styles.error]: showTalkOverError,
})}
style={{
boxShadow: shadow.to(
(s) =>
`0px 0px 0px ${s}px ${shadowColor}, 0px 0px 0px ${
2 * s
}px ${shadowColor}`
),
}}
onMouseDown={onButtonMouseDown}
ref={buttonRef}
>
@@ -148,17 +171,12 @@ export const PTTButton: React.FC<Props> = ({
) : (
<Avatar
key={activeSpeakerUserId}
style={{
width: size - 12,
height: size - 12,
borderRadius: size - 12,
fontSize: Math.round((size - 12) / 2),
}}
size={size - 12}
src={activeSpeakerAvatarUrl}
fallback={activeSpeakerDisplayName.slice(0, 1).toUpperCase()}
className={styles.avatar}
/>
)}
</button>
</animated.button>
);
};

View File

@@ -31,7 +31,7 @@
}
.participants > p {
color: #a9b2bc;
color: var(--secondary-content);
margin-bottom: 8px;
}

View File

@@ -21,9 +21,8 @@ import { GroupCall, MatrixClient, RoomMember } from "matrix-js-sdk";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { useModalTriggerState } from "../Modal";
import { SettingsModal } from "../settings/SettingsModal";
import { InviteModal } from "./InviteModal";
import { HangupButton, InviteButton, SettingsButton } from "../button";
import { HangupButton, InviteButton } from "../button";
import { Header, LeftNav, RightNav, RoomSetupHeaderInfo } from "../Header";
import styles from "./PTTCallView.module.css";
import { Facepile } from "../Facepile";
@@ -33,10 +32,11 @@ import { useMediaHandler } from "../settings/useMediaHandler";
import { usePTT } from "./usePTT";
import { Timer } from "./Timer";
import { Toggle } from "../input/Toggle";
import { getAvatarUrl } from "../matrix-utils";
import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
import { usePTTSounds } from "../sound/usePttSounds";
import { PTTClips } from "../sound/PTTClips";
import { GroupCallInspector } from "./GroupCallInspector";
import { OverflowMenu } from "./OverflowMenu";
function getPromptText(
showTalkOverError: boolean,
@@ -44,8 +44,11 @@ function getPromptText(
activeSpeakerIsLocalUser: boolean,
talkOverEnabled: boolean,
activeSpeakerUserId: string,
activeSpeakerDisplayName: string
activeSpeakerDisplayName: string,
connected: boolean
): string {
if (!connected) return "Connection Lost";
const isTouchScreen = Boolean(window.ontouchstart !== undefined);
if (showTalkOverError) {
@@ -79,33 +82,30 @@ interface Props {
client: MatrixClient;
roomId: string;
roomName: string;
avatarUrl: string;
groupCall: GroupCall;
participants: RoomMember[];
userMediaFeeds: CallFeed[];
onLeave: () => void;
setShowInspector: (boolean) => void;
showInspector: boolean;
}
export const PTTCallView: React.FC<Props> = ({
client,
roomId,
roomName,
avatarUrl,
groupCall,
participants,
userMediaFeeds,
onLeave,
setShowInspector,
showInspector,
}) => {
const { modalState: inviteModalState, modalProps: inviteModalProps } =
useModalTriggerState();
const { modalState: settingsModalState, modalProps: settingsModalProps } =
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
const [containerRef, bounds] = useMeasure({ polyfill: ResizeObserver });
const facepileSize = bounds.width < 800 ? "sm" : "md";
const pttButtonSize = 232;
const pttBorderWidth = 6;
const { audioOutput } = useMediaHandler();
@@ -123,10 +123,18 @@ export const PTTCallView: React.FC<Props> = ({
talkOverEnabled,
setTalkOverEnabled,
activeSpeakerUserId,
activeSpeakerVolume,
startTalking,
stopTalking,
transmitBlocked,
} = usePTT(client, groupCall, userMediaFeeds, playClip);
connected,
} = usePTT(
client,
groupCall,
userMediaFeeds,
playClip,
!feedbackModalState.isOpen
);
const showTalkOverError = pttButtonHeld && transmitBlocked;
@@ -135,13 +143,7 @@ export const PTTCallView: React.FC<Props> = ({
const activeSpeakerUser = activeSpeakerUserId
? client.getUser(activeSpeakerUserId)
: null;
const activeSpeakerAvatarUrl = activeSpeakerUser
? getAvatarUrl(
client,
activeSpeakerUser.avatarUrl,
pttButtonSize - pttBorderWidth * 2
)
: null;
const activeSpeakerAvatarUrl = activeSpeakerUser?.avatarUrl;
const activeSpeakerDisplayName = activeSpeakerUser
? activeSpeakerUser.displayName
: "";
@@ -154,9 +156,20 @@ export const PTTCallView: React.FC<Props> = ({
endTalkingRef={endTalkingRef}
blockedRef={blockedRef}
/>
<GroupCallInspector
client={client}
groupCall={groupCall}
// Never shown in PTT mode, but must be present to collect call state
// https://github.com/vector-im/element-call/issues/328
show={false}
/>
<Header className={styles.header}>
<LeftNav>
<RoomSetupHeaderInfo roomName={roomName} onPress={onLeave} />
<RoomSetupHeaderInfo
roomName={roomName}
avatarUrl={avatarUrl}
onPress={onLeave}
/>
</LeftNav>
<RightNav />
</Header>
@@ -174,7 +187,15 @@ export const PTTCallView: React.FC<Props> = ({
/>
</div>
<div className={styles.footer}>
<SettingsButton onPress={() => settingsModalState.open()} />
<OverflowMenu
inCall
roomId={roomId}
client={client}
groupCall={groupCall}
showInvite={false}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
<HangupButton onPress={onLeave} />
<InviteButton onPress={() => inviteModalState.open()} />
</div>
@@ -201,6 +222,7 @@ export const PTTCallView: React.FC<Props> = ({
activeSpeakerDisplayName={activeSpeakerDisplayName}
activeSpeakerAvatarUrl={activeSpeakerAvatarUrl}
activeSpeakerIsLocalUser={activeSpeakerIsLocalUser}
activeSpeakerVolume={activeSpeakerVolume}
size={pttButtonSize}
startTalking={startTalking}
stopTalking={stopTalking}
@@ -212,7 +234,8 @@ export const PTTCallView: React.FC<Props> = ({
activeSpeakerIsLocalUser,
talkOverEnabled,
activeSpeakerUserId,
activeSpeakerDisplayName
activeSpeakerDisplayName,
connected
)}
</p>
{userMediaFeeds.map((callFeed) => (
@@ -233,13 +256,6 @@ export const PTTCallView: React.FC<Props> = ({
</div>
</div>
{settingsModalState.isOpen && (
<SettingsModal
{...settingsModalProps}
setShowInspector={setShowInspector}
showInspector={showInspector}
/>
)}
{inviteModalState.isOpen && (
<InviteModal roomId={roomId} {...inviteModalProps} />
)}

View File

@@ -16,6 +16,7 @@ limitations under the License.
import React, { useCallback, useState } from "react";
import styles from "./RoomAuthView.module.css";
import { useClient } from "../ClientContext";
import { Button } from "../button";
import { Body, Caption, Link, Headline } from "../typography/Typography";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
@@ -29,11 +30,13 @@ import { UserMenuContainer } from "../UserMenuContainer";
import { generateRandomName } from "../auth/generateRandomName";
export function RoomAuthView() {
const { setClient } = useClient();
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const [{ privacyPolicyUrl, recaptchaKey }, register] =
const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const onSubmit = useCallback(
(e) => {
e.preventDefault();
@@ -45,13 +48,14 @@ export function RoomAuthView() {
setLoading(true);
const recaptchaResponse = await execute();
const userName = generateRandomName();
await register(
const [client, session] = await register(
userName,
randomString(16),
displayName,
recaptchaResponse,
true
);
setClient(client, session);
}
submit().catch((error) => {

View File

@@ -25,6 +25,7 @@ import { ResizeObserver } from "@juggle/resize-observer";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import styles from "./VideoPreview.module.css";
import { Body } from "../typography/Typography";
import { useModalTriggerState } from "../Modal";
export function VideoPreview({
client,
@@ -34,8 +35,6 @@ export function VideoPreview({
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
setShowInspector,
showInspector,
audioOutput,
stream,
}) {
@@ -44,17 +43,20 @@ export function VideoPreview({
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
const avatarSize = (previewBounds.height - 66) / 2;
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
return (
<div className={styles.preview} ref={previewRef}>
<video ref={videoRef} muted playsInline disablePictureInPicture />
{state === GroupCallState.LocalCallFeedUninitialized && (
<Body fontWeight="semiBold" className={styles.webcamPermissions}>
Webcam/microphone permissions needed to join the call.
<Body fontWeight="semiBold" className={styles.cameraPermissions}>
Camera/microphone permissions needed to join the call.
</Body>
)}
{state === GroupCallState.InitializingLocalCallFeed && (
<Body fontWeight="semiBold" className={styles.webcamPermissions}>
Accept webcam/microphone permissions to join the call.
<Body fontWeight="semiBold" className={styles.cameraPermissions}>
Accept camera/microphone permissions to join the call.
</Body>
)}
{state === GroupCallState.LocalCallFeedInitialized && (
@@ -62,12 +64,7 @@ export function VideoPreview({
{localVideoMuted && (
<div className={styles.avatarContainer}>
<Avatar
style={{
width: avatarSize,
height: avatarSize,
borderRadius: avatarSize,
fontSize: Math.round(avatarSize / 2),
}}
size={avatarSize}
src={avatarUrl}
fallback={displayName.slice(0, 1).toUpperCase()}
/>
@@ -84,9 +81,9 @@ export function VideoPreview({
/>
<OverflowMenu
roomId={roomId}
setShowInspector={setShowInspector}
showInspector={showInspector}
client={client}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
</div>
</>

View File

@@ -29,7 +29,7 @@
background-color: var(--bgColor3);
}
.webcamPermissions {
.cameraPermissions {
position: absolute;
top: 50%;
left: 50%;

View File

@@ -18,10 +18,57 @@ import { useCallback, useEffect, useState } from "react";
import {
GroupCallEvent,
GroupCallState,
GroupCall,
} from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { usePageUnload } from "./usePageUnload";
export function useGroupCall(groupCall) {
export interface UseGroupCallType {
state: GroupCallState;
calls: MatrixCall[];
localCallFeed: CallFeed;
activeSpeaker: string;
userMediaFeeds: CallFeed[];
microphoneMuted: boolean;
localVideoMuted: boolean;
error: Error;
initLocalCallFeed: () => void;
enter: () => void;
leave: () => void;
toggleLocalVideoMuted: () => void;
toggleMicrophoneMuted: () => void;
toggleScreensharing: () => void;
requestingScreenshare: boolean;
isScreensharing: boolean;
screenshareFeeds: CallFeed[];
localScreenshareFeed: CallFeed;
localDesktopCapturerSourceId: string;
participants: RoomMember[];
hasLocalParticipant: boolean;
}
interface State {
state: GroupCallState;
calls: MatrixCall[];
localCallFeed: CallFeed;
activeSpeaker: string;
userMediaFeeds: CallFeed[];
error: Error;
microphoneMuted: boolean;
localVideoMuted: boolean;
screenshareFeeds: CallFeed[];
localScreenshareFeed: CallFeed;
localDesktopCapturerSourceId: string;
isScreensharing: boolean;
requestingScreenshare: boolean;
participants: RoomMember[];
hasLocalParticipant: boolean;
}
export function useGroupCall(groupCall: GroupCall): UseGroupCallType {
const [
{
state,
@@ -41,20 +88,25 @@ export function useGroupCall(groupCall) {
requestingScreenshare,
},
setState,
] = useState({
] = useState<State>({
state: GroupCallState.LocalCallFeedUninitialized,
calls: [],
localCallFeed: null,
activeSpeaker: null,
userMediaFeeds: [],
error: null,
microphoneMuted: false,
localVideoMuted: false,
screenshareFeeds: [],
isScreensharing: false,
screenshareFeeds: [],
localScreenshareFeed: null,
localDesktopCapturerSourceId: null,
requestingScreenshare: false,
participants: [],
hasLocalParticipant: false,
});
const updateState = (state) =>
const updateState = (state: Partial<State>) =>
setState((prevState) => ({ ...prevState, ...state }));
useEffect(() => {
@@ -75,25 +127,28 @@ export function useGroupCall(groupCall) {
});
}
function onUserMediaFeedsChanged(userMediaFeeds) {
function onUserMediaFeedsChanged(userMediaFeeds: CallFeed[]): void {
updateState({
userMediaFeeds: [...userMediaFeeds],
});
}
function onScreenshareFeedsChanged(screenshareFeeds) {
function onScreenshareFeedsChanged(screenshareFeeds: CallFeed[]): void {
updateState({
screenshareFeeds: [...screenshareFeeds],
});
}
function onActiveSpeakerChanged(activeSpeaker) {
function onActiveSpeakerChanged(activeSpeaker: string): void {
updateState({
activeSpeaker: activeSpeaker,
});
}
function onLocalMuteStateChanged(microphoneMuted, localVideoMuted) {
function onLocalMuteStateChanged(
microphoneMuted: boolean,
localVideoMuted: boolean
): void {
updateState({
microphoneMuted,
localVideoMuted,
@@ -101,10 +156,10 @@ export function useGroupCall(groupCall) {
}
function onLocalScreenshareStateChanged(
isScreensharing,
localScreenshareFeed,
localDesktopCapturerSourceId
) {
isScreensharing: boolean,
localScreenshareFeed: CallFeed,
localDesktopCapturerSourceId: string
): void {
updateState({
isScreensharing,
localScreenshareFeed,
@@ -112,13 +167,13 @@ export function useGroupCall(groupCall) {
});
}
function onCallsChanged(calls) {
function onCallsChanged(calls: MatrixCall[]): void {
updateState({
calls: [...calls],
});
}
function onParticipantsChanged(participants) {
function onParticipantsChanged(participants: RoomMember[]): void {
updateState({
participants: [...participants],
hasLocalParticipant: groupCall.hasLocalParticipant(),

View File

@@ -15,10 +15,11 @@ limitations under the License.
*/
import { useCallback, useEffect, useState } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed";
import { logger } from "matrix-js-sdk/src/logger";
import { SyncState } from "matrix-js-sdk/src/sync";
import { PlayClipFunction, PTTClipID } from "../sound/usePttSounds";
@@ -30,6 +31,21 @@ function getActiveSpeakerFeed(
): CallFeed | null {
const activeSpeakerFeeds = feeds.filter((f) => !f.isAudioMuted());
// make sure the feeds are in a deterministic order so every client picks
// the same one as the active speaker. The custom sort function sorts
// by user ID, so needs a collator of some kind to compare. We make a
// specific one to help ensure every client sorts the same way
// although of course user IDs shouldn't contain accented characters etc.
// anyway).
const collator = new Intl.Collator("en", {
sensitivity: "variant",
usage: "sort",
ignorePunctuation: false,
});
activeSpeakerFeeds.sort((a: CallFeed, b: CallFeed): number =>
collator.compare(a.userId, b.userId)
);
let activeSpeakerFeed = null;
let highestPowerLevel = null;
for (const feed of activeSpeakerFeeds) {
@@ -49,16 +65,23 @@ export interface PTTState {
talkOverEnabled: boolean;
setTalkOverEnabled: (boolean) => void;
activeSpeakerUserId: string;
activeSpeakerVolume: number;
startTalking: () => void;
stopTalking: () => void;
transmitBlocked: boolean;
// connected is actually an indication of whether we're connected to the HS
// (ie. the client's syncing state) rather than media connection, since
// it's peer to peer so we can't really say which peer is 'disconnected' if
// there's only one other person in the call and they've lost Internet.
connected: boolean;
}
export const usePTT = (
client: MatrixClient,
groupCall: GroupCall,
userMediaFeeds: CallFeed[],
playClip: PlayClipFunction
playClip: PlayClipFunction,
enablePTTButton: boolean
): PTTState => {
// 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.
@@ -86,6 +109,7 @@ export const usePTT = (
isAdmin,
talkOverEnabled,
activeSpeakerUserId,
activeSpeakerVolume,
transmitBlocked,
},
setState,
@@ -99,6 +123,7 @@ export const usePTT = (
talkOverEnabled: false,
pttButtonHeld: false,
activeSpeakerUserId: activeSpeakerFeed ? activeSpeakerFeed.userId : null,
activeSpeakerVolume: -Infinity,
transmitBlocked: false,
};
});
@@ -130,15 +155,11 @@ export const usePTT = (
playClip(PTTClipID.BLOCKED);
}
setState((prevState) => {
return {
...prevState,
activeSpeakerUserId: activeSpeakerFeed
? activeSpeakerFeed.userId
: null,
transmitBlocked: blocked,
};
});
setState((prevState) => ({
...prevState,
activeSpeakerUserId: activeSpeakerFeed ? activeSpeakerFeed.userId : null,
transmitBlocked: blocked,
}));
}, [
playClip,
groupCall,
@@ -151,7 +172,7 @@ export const usePTT = (
useEffect(() => {
for (const callFeed of userMediaFeeds) {
callFeed.addListener(CallFeedEvent.MuteStateChanged, onMuteStateChanged);
callFeed.on(CallFeedEvent.MuteStateChanged, onMuteStateChanged);
}
const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall);
@@ -163,14 +184,30 @@ export const usePTT = (
return () => {
for (const callFeed of userMediaFeeds) {
callFeed.removeListener(
CallFeedEvent.MuteStateChanged,
onMuteStateChanged
);
callFeed.off(CallFeedEvent.MuteStateChanged, onMuteStateChanged);
}
};
}, [userMediaFeeds, onMuteStateChanged, groupCall]);
const onVolumeChanged = useCallback((volume: number) => {
setState((prevState) => ({
...prevState,
activeSpeakerVolume: volume,
}));
}, []);
useEffect(() => {
const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall);
activeSpeakerFeed?.on(CallFeedEvent.VolumeChanged, onVolumeChanged);
return () => {
activeSpeakerFeed?.off(CallFeedEvent.VolumeChanged, onVolumeChanged);
setState((prevState) => ({
...prevState,
activeSpeakerVolume: -Infinity,
}));
};
}, [activeSpeakerUserId, onVolumeChanged, userMediaFeeds, groupCall]);
const startTalking = useCallback(async () => {
if (pttButtonHeld) return;
@@ -210,9 +247,22 @@ export const usePTT = (
setMicMuteWrapper(true);
}, [setMicMuteWrapper]);
// separate state for connected: we set it separately from other things
// in the client sync callback
const [connected, setConnected] = useState(true);
const onClientSync = useCallback(
(syncState: SyncState) => {
setConnected(syncState !== SyncState.Error);
},
[setConnected]
);
useEffect(() => {
function onKeyDown(event: KeyboardEvent): void {
if (event.code === "Space") {
if (!enablePTTButton) return;
event.preventDefault();
if (pttButtonHeld) return;
@@ -255,9 +305,20 @@ export const usePTT = (
isAdmin,
talkOverEnabled,
pttButtonHeld,
enablePTTButton,
setMicMuteWrapper,
client,
onClientSync,
]);
useEffect(() => {
client.on(ClientEvent.Sync, onClientSync);
return () => {
client.removeListener(ClientEvent.Sync, onClientSync);
};
}, [client, onClientSync]);
const setTalkOverEnabled = useCallback((talkOverEnabled) => {
setState((prevState) => ({
...prevState,
@@ -271,8 +332,10 @@ export const usePTT = (
talkOverEnabled,
setTalkOverEnabled,
activeSpeakerUserId,
activeSpeakerVolume,
startTalking,
stopTalking,
transmitBlocked,
connected,
};
};

24
src/room/useRoomAvatar.ts Normal file
View File

@@ -0,0 +1,24 @@
import { useState, useEffect } from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { EventType } from "matrix-js-sdk/src/@types/event";
export const useRoomAvatar = (room: Room) => {
const [avatarUrl, setAvatarUrl] = useState(room.getMxcAvatarUrl());
useEffect(() => {
const update = (ev: MatrixEvent) => {
if (ev.getType() === EventType.RoomAvatar) {
setAvatarUrl(room.getMxcAvatarUrl());
}
};
room.currentState.on(RoomStateEvent.Events, update);
return () => {
room.currentState.off(RoomStateEvent.Events, update);
};
}, [room]);
return avatarUrl;
};

View File

@@ -24,12 +24,13 @@ import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
import { SelectInput } from "../input/SelectInput";
import { Item } from "@react-stately/collections";
import { useMediaHandler } from "./useMediaHandler";
import { useSpatialAudio, useShowInspector } from "./useSetting";
import { FieldRow, InputField } from "../input/Input";
import { Button } from "../button";
import { useDownloadDebugLog } from "./submit-rageshake";
import { Body } from "../typography/Typography";
export function SettingsModal({ setShowInspector, showInspector, ...rest }) {
export const SettingsModal = (props) => {
const {
audioInput,
audioInputs,
@@ -41,6 +42,8 @@ export function SettingsModal({ setShowInspector, showInspector, ...rest }) {
audioOutputs,
setAudioOutput,
} = useMediaHandler();
const [spatialAudio, setSpatialAudio] = useSpatialAudio();
const [showInspector, setShowInspector] = useShowInspector();
const downloadDebugLog = useDownloadDebugLog();
@@ -50,7 +53,7 @@ export function SettingsModal({ setShowInspector, showInspector, ...rest }) {
isDismissable
mobileFullScreen
className={styles.settingsModal}
{...rest}
{...props}
>
<TabContainer className={styles.tabContainer}>
<TabItem
@@ -81,6 +84,16 @@ export function SettingsModal({ setShowInspector, showInspector, ...rest }) {
))}
</SelectInput>
)}
<FieldRow>
<InputField
id="spatialAudio"
label="Spatial audio"
type="checkbox"
checked={spatialAudio}
description="This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)"
onChange={(e) => setSpatialAudio(e.target.checked)}
/>
</FieldRow>
</TabItem>
<TabItem
title={
@@ -91,7 +104,7 @@ export function SettingsModal({ setShowInspector, showInspector, ...rest }) {
}
>
<SelectInput
label="Webcam"
label="Camera"
selectedKey={videoInput}
onSelectionChange={setVideoInput}
>
@@ -130,4 +143,4 @@ export function SettingsModal({ setShowInspector, showInspector, ...rest }) {
</TabContainer>
</Modal>
);
}
};

View File

@@ -0,0 +1,56 @@
/*
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 { EventEmitter } from "events";
import { useMemo, useState, useEffect, useCallback } from "react";
// Bus to notify other useSetting consumers when a setting is changed
const settingsBus = new EventEmitter();
// Like useState, but reads from and persists the value to localStorage
const useSetting = <T>(
name: string,
defaultValue: T
): [T, (value: T) => void] => {
const key = useMemo(() => `matrix-setting-${name}`, [name]);
const [value, setValue] = useState<T>(() => {
const item = localStorage.getItem(key);
return item == null ? defaultValue : JSON.parse(item);
});
useEffect(() => {
settingsBus.on(name, setValue);
return () => {
settingsBus.off(name, setValue);
};
}, [name, setValue]);
return [
value,
useCallback(
(newValue: T) => {
setValue(newValue);
localStorage.setItem(key, JSON.stringify(newValue));
settingsBus.emit(name, newValue);
},
[name, key, setValue]
),
];
};
export const useSpatialAudio = () => useSetting("spatial-audio", false);
export const useShowInspector = () => useSetting("show-inspector", false);

View File

@@ -25,12 +25,12 @@
}
.tab > * {
color: var(--textColor4);
color: var(--secondary-content);
margin: 0 8px 0 0;
}
.tab svg * {
fill: var(--textColor4);
fill: var(--secondary-content);
}
.tab > :last-child {
@@ -38,15 +38,15 @@
}
.tab.selected {
background-color: #0dbd8b;
background-color: var(--accent);
}
.tab.selected * {
color: #ffffff;
color: var(--primary-content);
}
.tab.selected svg * {
fill: #ffffff;
fill: var(--primary-content);
}
.tab.disabled {

View File

@@ -209,6 +209,7 @@ export const Link = forwardRef(
if (href) {
externalLinkProps = {
href,
target: "_blank",
rel: "noreferrer noopener",
};

View File

@@ -21,11 +21,11 @@
}
.link {
color: var(--linkColor);
color: var(--links);
}
.primary {
color: var(--primaryColor);
color: var(--accent);
}
.overflowEllipsis {

View File

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

View File

@@ -19,7 +19,6 @@ import { useDrag, useGesture } from "@use-gesture/react";
import { useSprings } from "@react-spring/web";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import moveArrItem from "lodash-move";
import styles from "./VideoGrid.module.css";
export function useVideoGridLayout(hasScreenshareFeeds) {
@@ -52,6 +51,8 @@ export function useVideoGridLayout(hasScreenshareFeeds) {
return [layoutRef.current, setLayout];
}
const GAP = 8;
function useIsMounted() {
const isMountedRef = useRef(false);
@@ -79,16 +80,25 @@ function isInside([x, y], targetTile) {
return true;
}
const getPipGap = (gridAspectRatio) => (gridAspectRatio < 1 ? 12 : 24);
function getTilePositions(
tileCount,
presenterTileCount,
gridWidth,
gridHeight,
pipXRatio,
pipYRatio,
layout
) {
if (layout === "freedom") {
if (tileCount === 2 && presenterTileCount === 0) {
return getOneOnOneLayoutTilePositions(gridWidth, gridHeight);
return getOneOnOneLayoutTilePositions(
gridWidth,
gridHeight,
pipXRatio,
pipYRatio
);
}
return getFreedomLayoutTilePositions(
@@ -102,34 +112,44 @@ function getTilePositions(
}
}
function getOneOnOneLayoutTilePositions(gridWidth, gridHeight) {
const gap = 8;
function getOneOnOneLayoutTilePositions(
gridWidth,
gridHeight,
pipXRatio,
pipYRatio
) {
const [remotePosition] = getFreedomLayoutTilePositions(
1,
0,
gridWidth,
gridHeight
);
const gridAspectRatio = gridWidth / gridHeight;
const pipWidth = gridAspectRatio < 1 ? 114 : 230;
const pipHeight = gridAspectRatio < 1 ? 163 : 155;
const pipGap = gridAspectRatio < 1 ? 12 : 24;
const pipGap = getPipGap(gridAspectRatio);
const pipMinX = remotePosition.x + pipGap;
const pipMinY = remotePosition.y + pipGap;
const pipMaxX = remotePosition.x + remotePosition.width - pipWidth - pipGap;
const pipMaxY = remotePosition.y + remotePosition.height - pipHeight - pipGap;
return [
{
x: gridWidth - pipWidth - gap - pipGap,
y: gridHeight - pipHeight - gap - pipGap,
// Apply the PiP position as a proportion of the available space
x: pipMinX + pipXRatio * (pipMaxX - pipMinX),
y: pipMinY + pipYRatio * (pipMaxY - pipMinY),
width: pipWidth,
height: pipHeight,
zIndex: 1,
},
{
x: gap,
y: gap,
width: gridWidth - gap * 2,
height: gridHeight - gap * 2,
zIndex: 0,
},
remotePosition,
];
}
function getSpotlightLayoutTilePositions(tileCount, gridWidth, gridHeight) {
const gap = 8;
const tilePositions = [];
const gridAspectRatio = gridWidth / gridHeight;
@@ -137,25 +157,25 @@ function getSpotlightLayoutTilePositions(tileCount, gridWidth, gridHeight) {
if (gridAspectRatio < 1) {
// Vertical layout (mobile)
const spotlightTileHeight =
tileCount > 1 ? (gridHeight - gap * 3) * (4 / 5) : gridHeight - gap * 2;
tileCount > 1 ? (gridHeight - GAP * 3) * (4 / 5) : gridHeight - GAP * 2;
const spectatorTileSize =
tileCount > 1 ? gridHeight - gap * 3 - spotlightTileHeight : 0;
tileCount > 1 ? gridHeight - GAP * 3 - spotlightTileHeight : 0;
for (let i = 0; i < tileCount; i++) {
if (i === 0) {
// Spotlight tile
tilePositions.push({
x: gap,
y: gap,
width: gridWidth - gap * 2,
x: GAP,
y: GAP,
width: gridWidth - GAP * 2,
height: spotlightTileHeight,
zIndex: 0,
});
} else {
// Spectator tile
tilePositions.push({
x: (gap + spectatorTileSize) * (i - 1) + gap,
y: spotlightTileHeight + gap * 2,
x: (GAP + spectatorTileSize) * (i - 1) + GAP,
y: spotlightTileHeight + GAP * 2,
width: spectatorTileSize,
height: spectatorTileSize,
zIndex: 0,
@@ -165,24 +185,24 @@ function getSpotlightLayoutTilePositions(tileCount, gridWidth, gridHeight) {
} else {
// Horizontal layout (desktop)
const spotlightTileWidth =
tileCount > 1 ? ((gridWidth - gap * 3) * 4) / 5 : gridWidth - gap * 2;
tileCount > 1 ? ((gridWidth - GAP * 3) * 4) / 5 : gridWidth - GAP * 2;
const spectatorTileWidth =
tileCount > 1 ? gridWidth - gap * 3 - spotlightTileWidth : 0;
tileCount > 1 ? gridWidth - GAP * 3 - spotlightTileWidth : 0;
const spectatorTileHeight = spectatorTileWidth * (9 / 16);
for (let i = 0; i < tileCount; i++) {
if (i === 0) {
tilePositions.push({
x: gap,
y: gap,
x: GAP,
y: GAP,
width: spotlightTileWidth,
height: gridHeight - gap * 2,
height: gridHeight - GAP * 2,
zIndex: 0,
});
} else {
tilePositions.push({
x: gap * 2 + spotlightTileWidth,
y: (gap + spectatorTileHeight) * (i - 1) + gap,
x: GAP * 2 + spotlightTileWidth,
y: (GAP + spectatorTileHeight) * (i - 1) + GAP,
width: spectatorTileWidth,
height: spectatorTileHeight,
zIndex: 0,
@@ -208,8 +228,6 @@ function getFreedomLayoutTilePositions(
console.warn("Over 12 tiles is not currently supported");
}
const gap = 8;
const { layoutDirection, itemGridRatio } = getGridLayout(
tileCount,
presenterTileCount,
@@ -242,8 +260,7 @@ function getFreedomLayoutTilePositions(
itemRowCount,
itemTileAspectRatio,
itemGridWidth,
itemGridHeight,
gap
itemGridHeight
);
const itemGridBounds = getSubGridBoundingBox(itemGridPositions);
@@ -256,10 +273,10 @@ function getFreedomLayoutTilePositions(
} else if (layoutDirection === "vertical") {
presenterGridWidth = gridWidth;
presenterGridHeight =
gridHeight - (itemGridBounds.height + (itemTileCount ? gap * 2 : 0));
gridHeight - (itemGridBounds.height + (itemTileCount ? GAP * 2 : 0));
} else {
presenterGridWidth =
gridWidth - (itemGridBounds.width + (itemTileCount ? gap * 2 : 0));
gridWidth - (itemGridBounds.width + (itemTileCount ? GAP * 2 : 0));
presenterGridHeight = gridHeight;
}
@@ -279,8 +296,7 @@ function getFreedomLayoutTilePositions(
presenterRowCount,
presenterTileAspectRatio,
presenterGridWidth,
presenterGridHeight,
gap
presenterGridHeight
);
const tilePositions = [...presenterGridPositions, ...itemGridPositions];
@@ -517,8 +533,7 @@ function getSubGridPositions(
rowCount,
tileAspectRatio,
gridWidth,
gridHeight,
gap
gridHeight
) {
if (tileCount === 0) {
return [];
@@ -527,9 +542,9 @@ function getSubGridPositions(
const newTilePositions = [];
const boxWidth = Math.round(
(gridWidth - gap * (columnCount + 1)) / columnCount
(gridWidth - GAP * (columnCount + 1)) / columnCount
);
const boxHeight = Math.round((gridHeight - gap * (rowCount + 1)) / rowCount);
const boxHeight = Math.round((gridHeight - GAP * (rowCount + 1)) / rowCount);
let tileWidth;
let tileHeight;
@@ -551,7 +566,7 @@ function getSubGridPositions(
for (let i = 0; i < tileCount; i++) {
const verticalIndex = Math.floor(i / columnCount);
const top = verticalIndex * gap + verticalIndex * tileHeight;
const top = verticalIndex * GAP + verticalIndex * tileHeight;
let rowItemCount;
@@ -566,15 +581,15 @@ function getSubGridPositions(
let centeringPadding = 0;
if (rowItemCount < columnCount) {
const subgridWidth = tileWidth * columnCount + (gap * columnCount - 1);
const subgridWidth = tileWidth * columnCount + (GAP * columnCount - 1);
centeringPadding = Math.round(
(subgridWidth - (tileWidth * rowItemCount + (gap * rowItemCount - 1))) /
(subgridWidth - (tileWidth * rowItemCount + (GAP * rowItemCount - 1))) /
2
);
}
const left =
centeringPadding + gap * horizontalIndex + tileWidth * horizontalIndex;
centeringPadding + GAP * horizontalIndex + tileWidth * horizontalIndex;
newTilePositions.push({
width: tileWidth,
@@ -588,34 +603,43 @@ function getSubGridPositions(
return newTilePositions;
}
function sortTiles(layout, tiles) {
const is1on1Freedom = layout === "freedom" && tiles.length === 2;
function reorderTiles(tiles, layout) {
if (layout === "freedom" && tiles.length === 2) {
// 1:1 layout
tiles.forEach((tile) => (tile.order = tile.item.isLocal ? 0 : 1));
} else {
const focusedTiles = [];
const presenterTiles = [];
const otherTiles = [];
tiles.sort((a, b) => {
if (is1on1Freedom && a.item.isLocal !== b.item.isLocal) {
return (b.item.isLocal ? 1 : 0) - (a.item.isLocal ? 1 : 0);
} else if (a.focused !== b.focused) {
return (b.focused ? 1 : 0) - (a.focused ? 1 : 0);
} else if (a.presenter !== b.presenter) {
return (b.presenter ? 1 : 0) - (a.presenter ? 1 : 0);
}
const orderedTiles = new Array(tiles.length);
tiles.forEach((tile) => (orderedTiles[tile.order] = tile));
return 0;
});
orderedTiles.forEach((tile) =>
(tile.focused
? focusedTiles
: tile.presenter
? presenterTiles
: otherTiles
).push(tile)
);
[...focusedTiles, ...presenterTiles, ...otherTiles].forEach(
(tile, i) => (tile.order = i)
);
}
}
export function VideoGrid({
items,
layout,
onFocusTile,
disableAnimations,
children,
}) {
const [{ tiles, tilePositions, scrollPosition }, setTileState] = useState({
export function VideoGrid({ items, layout, disableAnimations, children }) {
// Place the PiP in the bottom right corner by default
const [pipXRatio, setPipXRatio] = useState(1);
const [pipYRatio, setPipYRatio] = useState(1);
const [{ tiles, tilePositions }, setTileState] = useState({
tiles: [],
tilePositions: [],
scrollPosition: 0,
});
const [scrollPosition, setScrollPosition] = useState(0);
const draggingTileRef = useRef(null);
const lastTappedRef = useRef({});
const lastLayoutRef = useRef(layout);
@@ -626,7 +650,7 @@ export function VideoGrid({
useEffect(() => {
setTileState(({ tiles, ...rest }) => {
const newTiles = [];
const removedTileKeys = [];
const removedTileKeys = new Set();
for (const tile of tiles) {
let item = items.find((item) => item.id === tile.key);
@@ -636,7 +660,7 @@ export function VideoGrid({
if (!item) {
remove = true;
item = tile.item;
removedTileKeys.push(tile.key);
removedTileKeys.add(tile.key);
}
let focused;
@@ -651,6 +675,7 @@ export function VideoGrid({
newTiles.push({
key: item.id,
order: tile.order,
item,
remove,
focused,
@@ -671,6 +696,7 @@ export function VideoGrid({
const newTile = {
key: item.id,
order: existingTile?.order ?? newTiles.length,
item,
remove: false,
focused: layout === "spotlight" && item.focused,
@@ -686,22 +712,19 @@ export function VideoGrid({
}
}
sortTiles(layout, newTiles);
reorderTiles(newTiles, layout);
if (removedTileKeys.length > 0) {
if (removedTileKeys.size > 0) {
setTimeout(() => {
if (!isMounted.current) {
return;
}
setTileState(({ tiles, ...rest }) => {
const newTiles = tiles.filter(
(tile) => !removedTileKeys.includes(tile.key)
);
// TODO: When we remove tiles, we reuse the order of the tiles vs calling sort on the
// items array. This can cause the local feed to display large in the room.
// To fix this we need to move to using a reducer and sorting the input items
const newTiles = tiles
.filter((tile) => !removedTileKeys.has(tile.key))
.map((tile) => ({ ...tile })); // clone before reordering
reorderTiles(newTiles, layout);
const presenterTileCount = newTiles.reduce(
(count, tile) => count + (tile.focused ? 1 : 0),
@@ -716,6 +739,8 @@ export function VideoGrid({
presenterTileCount,
gridBounds.width,
gridBounds.height,
pipXRatio,
pipYRatio,
layout
),
};
@@ -738,16 +763,18 @@ export function VideoGrid({
presenterTileCount,
gridBounds.width,
gridBounds.height,
pipXRatio,
pipYRatio,
layout
),
};
});
}, [items, gridBounds, layout, isMounted]);
}, [items, gridBounds, layout, isMounted, pipXRatio, pipYRatio]);
const animate = useCallback(
(tiles) => (tileIndex) => {
const tile = tiles[tileIndex];
const tilePosition = tilePositions[tileIndex];
const tilePosition = tilePositions[tile.order];
const draggingTile = draggingTileRef.current;
const dragging = draggingTile && tile.key === draggingTile.key;
const remove = tile.remove;
@@ -806,6 +833,9 @@ export function VideoGrid({
reset: false,
immediate: (key) =>
disableAnimations || key === "zIndex" || key === "shadow",
// If we just stopped dragging a tile, give it time for its animation
// to settle before pushing its z-index back down
delay: (key) => (key === "zIndex" ? 500 : 0),
};
}
},
@@ -830,43 +860,25 @@ export function VideoGrid({
lastTappedRef.current[tileKey] = 0;
const tile = tiles.find((tile) => tile.key === tileKey);
if (!tile) {
return;
}
if (!tile || layout !== "freedom") return;
const item = tile.item;
setTileState((state) => {
setTileState(({ tiles, ...state }) => {
let presenterTileCount = 0;
const newTiles = tiles.map((tile) => {
let newTile = { ...tile }; // clone before reordering
let newTiles;
if (onFocusTile) {
newTiles = onFocusTile(state.tiles, tile);
for (const tile of newTiles) {
if (tile.focused) {
presenterTileCount++;
}
if (tile.item === item) {
newTile.focused = !tile.focused;
}
if (newTile.focused) {
presenterTileCount++;
}
} else {
newTiles = state.tiles.map((tile) => {
let newTile = tile;
if (tile.item === item) {
newTile = { ...tile, focused: !tile.focused };
}
return newTile;
});
if (newTile.focused) {
presenterTileCount++;
}
return newTile;
});
}
sortTiles(layout, newTiles);
reorderTiles(newTiles, layout);
return {
...state,
@@ -876,16 +888,18 @@ export function VideoGrid({
presenterTileCount,
gridBounds.width,
gridBounds.height,
pipXRatio,
pipYRatio,
layout
),
};
});
},
[tiles, gridBounds, onFocusTile, layout]
[tiles, gridBounds, layout]
);
const bindTile = useDrag(
({ args: [key], active, xy, movement, tap, event }) => {
({ args: [key], active, xy, movement, tap, last, event }) => {
event.preventDefault();
if (tap) {
@@ -893,51 +907,81 @@ export function VideoGrid({
return;
}
if (layout !== "freedom") {
return;
}
if (layout === "freedom" && tiles.length === 2) {
return;
}
if (layout !== "freedom") return;
const dragTileIndex = tiles.findIndex((tile) => tile.key === key);
const dragTile = tiles[dragTileIndex];
const dragTilePosition = tilePositions[dragTileIndex];
let newTiles = tiles;
const dragTilePosition = tilePositions[dragTile.order];
const cursorPosition = [xy[0] - gridBounds.left, xy[1] - gridBounds.top];
for (
let hoverTileIndex = 0;
hoverTileIndex < tiles.length;
hoverTileIndex++
) {
const hoverTile = tiles[hoverTileIndex];
const hoverTilePosition = tilePositions[hoverTileIndex];
let newTiles = tiles;
if (hoverTile.key === key) {
continue;
if (tiles.length === 2) {
// We're in 1:1 mode, so only the local tile should be draggable
if (!dragTile.item.isLocal) return;
// Position should only update on the very last event, to avoid
// compounding the offset on every drag event
if (last) {
const remotePosition = tilePositions[1];
const pipGap = getPipGap(gridBounds.width / gridBounds.height);
const pipMinX = remotePosition.x + pipGap;
const pipMinY = remotePosition.y + pipGap;
const pipMaxX =
remotePosition.x +
remotePosition.width -
dragTilePosition.width -
pipGap;
const pipMaxY =
remotePosition.y +
remotePosition.height -
dragTilePosition.height -
pipGap;
const newPipXRatio =
(dragTilePosition.x + movement[0] - pipMinX) / (pipMaxX - pipMinX);
const newPipYRatio =
(dragTilePosition.y + movement[1] - pipMinY) / (pipMaxY - pipMinY);
setPipXRatio(Math.max(0, Math.min(1, newPipXRatio)));
setPipYRatio(Math.max(0, Math.min(1, newPipYRatio)));
}
} else {
const hoverTile = tiles.find(
(tile) =>
tile.key !== key &&
isInside(cursorPosition, tilePositions[tile.order])
);
if (isInside(cursorPosition, hoverTilePosition)) {
newTiles = moveArrItem(tiles, dragTileIndex, hoverTileIndex);
if (hoverTile) {
// Shift the tiles into their new order
newTiles = newTiles.map((tile) => {
if (tile === hoverTile) {
return { ...tile, focused: dragTile.focused };
} else if (tile === dragTile) {
return { ...tile, focused: hoverTile.focused };
let order = tile.order;
if (order < dragTile.order) {
if (order >= hoverTile.order) order++;
} else if (order > dragTile.order) {
if (order <= hoverTile.order) order--;
} else {
return tile;
order = hoverTile.order;
}
let focused;
if (tile === hoverTile) {
focused = dragTile.focused;
} else if (tile === dragTile) {
focused = hoverTile.focused;
} else {
focused = tile.focused;
}
return { ...tile, order, focused };
});
sortTiles(layout, newTiles);
reorderTiles(newTiles, layout);
setTileState((state) => ({ ...state, tiles: newTiles }));
break;
}
}
@@ -981,17 +1025,13 @@ export function VideoGrid({
if (tilePositions.length > 1) {
const lastTile = tilePositions[tilePositions.length - 1];
min = isMobile
? gridBounds.width - lastTile.x - lastTile.width - 8
: gridBounds.height - lastTile.y - lastTile.height - 8;
? gridBounds.width - lastTile.x - lastTile.width - GAP
: gridBounds.height - lastTile.y - lastTile.height - GAP;
}
setTileState((state) => ({
...state,
scrollPosition: Math.min(
Math.max(movement + state.scrollPosition, min),
0
),
}));
setScrollPosition((scrollPosition) =>
Math.min(Math.max(movement + scrollPosition, min), 0)
);
},
[layout, gridBounds, tilePositions]
);
@@ -1008,7 +1048,7 @@ export function VideoGrid({
<div className={styles.videoGrid} ref={gridRef} {...bindGrid()}>
{springs.map(({ shadow, ...style }, i) => {
const tile = tiles[i];
const tilePosition = tilePositions[i];
const tilePosition = tilePositions[tile.order];
return children({
...bindTile(tile.key),

View File

@@ -14,57 +14,63 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { forwardRef } from "react";
import { animated } from "@react-spring/web";
import classNames from "classnames";
import styles from "./VideoTile.module.css";
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
import { ReactComponent as VideoMutedIcon } from "../icons/VideoMuted.svg";
export function VideoTile({
className,
isLocal,
speaking,
audioMuted,
noVideo,
videoMuted,
screenshare,
avatar,
name,
showName,
mediaRef,
...rest
}) {
return (
<animated.div
className={classNames(styles.videoTile, className, {
[styles.isLocal]: isLocal,
[styles.speaking]: speaking,
[styles.muted]: audioMuted,
[styles.screenshare]: screenshare,
})}
{...rest}
>
{(videoMuted || noVideo) && (
<>
<div className={styles.videoMutedOverlay} />
{avatar}
</>
)}
{screenshare ? (
<div className={styles.presenterLabel}>
<span>{`${name} is presenting`}</span>
</div>
) : (
(showName || audioMuted || (videoMuted && !noVideo)) && (
<div className={styles.memberName}>
{audioMuted && !(videoMuted && !noVideo) && <MicMutedIcon />}
{videoMuted && !noVideo && <VideoMutedIcon />}
{showName && <span title={name}>{name}</span>}
export const VideoTile = forwardRef(
(
{
className,
isLocal,
speaking,
audioMuted,
noVideo,
videoMuted,
screenshare,
avatar,
name,
showName,
mediaRef,
...rest
},
ref
) => {
return (
<animated.div
className={classNames(styles.videoTile, className, {
[styles.isLocal]: isLocal,
[styles.speaking]: speaking,
[styles.muted]: audioMuted,
[styles.screenshare]: screenshare,
})}
ref={ref}
{...rest}
>
{(videoMuted || noVideo) && (
<>
<div className={styles.videoMutedOverlay} />
{avatar}
</>
)}
{screenshare ? (
<div className={styles.presenterLabel}>
<span>{`${name} is presenting`}</span>
</div>
)
)}
<video ref={mediaRef} playsInline disablePictureInPicture />
</animated.div>
);
}
) : (
(showName || audioMuted || (videoMuted && !noVideo)) && (
<div className={styles.memberName}>
{audioMuted && !(videoMuted && !noVideo) && <MicMutedIcon />}
{videoMuted && !noVideo && <VideoMutedIcon />}
{showName && <span title={name}>{name}</span>}
</div>
)
)}
<video ref={mediaRef} playsInline disablePictureInPicture />
</animated.div>
);
}
);

View File

@@ -5,6 +5,10 @@
overflow: hidden;
cursor: pointer;
touch-action: none;
/* HACK: This has no visual effect due to the short duration, but allows the
JS to detect movement via the transform property for audio spatialization */
transition: transform 0.000000001s;
}
.videoTile * {
@@ -33,7 +37,7 @@
bottom: -1px;
content: "";
border-radius: 20px;
box-shadow: inset 0 0 0 4px #0dbd8b !important;
box-shadow: inset 0 0 0 4px var(--accent) !important;
}
.videoTile.screenshare > video {

View File

@@ -17,7 +17,7 @@ limitations under the License.
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
import React from "react";
import { useCallFeed } from "./useCallFeed";
import { useMediaStream } from "./useMediaStream";
import { useSpatialMediaStream } from "./useMediaStream";
import { useRoomMemberName } from "./useRoomMemberName";
import { VideoTile } from "./VideoTile";
@@ -28,6 +28,7 @@ export function VideoTileContainer({
getAvatar,
showName,
audioOutputDevice,
audioContext,
disableSpeakingIndicator,
...rest
}) {
@@ -42,7 +43,12 @@ export function VideoTileContainer({
member,
} = useCallFeed(item.callFeed);
const { rawDisplayName } = useRoomMemberName(member);
const mediaRef = useMediaStream(stream, audioOutputDevice, isLocal);
const [tileRef, mediaRef] = useSpatialMediaStream(
stream,
audioOutputDevice,
audioContext,
isLocal
);
// Firefox doesn't respect the disablePictureInPicture attribute
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
@@ -57,6 +63,7 @@ export function VideoTileContainer({
screenshare={purpose === SDPStreamMetadataPurpose.Screenshare}
name={rawDisplayName}
showName={showName}
ref={tileRef}
mediaRef={mediaRef}
avatar={getAvatar && getAvatar(member, width, height)}
{...rest}

View File

@@ -16,6 +16,8 @@ limitations under the License.
import { useRef, useEffect } from "react";
import { useSpatialAudio } from "../settings/useSetting";
export function useMediaStream(stream, audioOutputDevice, mute = false) {
const mediaRef = useRef();
@@ -27,10 +29,21 @@ export function useMediaStream(stream, audioOutputDevice, mute = false) {
);
if (mediaRef.current) {
const mediaEl = mediaRef.current;
if (stream) {
mediaRef.current.muted = mute;
mediaRef.current.srcObject = stream;
mediaRef.current.play();
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;
}
@@ -44,7 +57,8 @@ export function useMediaStream(stream, audioOutputDevice, mute = false) {
mediaRef.current !== undefined
) {
console.log(`useMediaStream setSinkId ${audioOutputDevice}`);
mediaRef.current.setSinkId(audioOutputDevice);
// Chrome for Android doesn't support this
mediaRef.current.setSinkId?.(audioOutputDevice);
}
}, [audioOutputDevice]);
@@ -62,3 +76,69 @@ export function useMediaStream(stream, audioOutputDevice, mute = false) {
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];
};

View File

@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "es2016",
"esModuleInterop": true,
"module": "commonjs",
"module": "es2020",
"moduleResolution": "node",
"noEmit": true,
"noImplicitAny": false,

View File

@@ -2876,6 +2876,11 @@
"@types/minimatch" "*"
"@types/node" "*"
"@types/grecaptcha@^3.0.4":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@types/grecaptcha/-/grecaptcha-3.0.4.tgz#3de601f3b0cd0298faf052dd5bd62aff64c2be2e"
integrity sha512-7l1Y8DTGXkx/r4pwU1nMVAR+yD/QC+MCHKXAyEX/7JZhwcN1IED09aZ9vCjjkcGdhSQiu/eJqcXInpl6eEEEwg==
"@types/hast@^2.0.0":
version "2.3.4"
resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.4.tgz#8aa5ef92c117d20d974a82bdfb6a648b08c0bafc"
@@ -8461,13 +8466,6 @@ locate-path@^6.0.0:
dependencies:
p-locate "^5.0.0"
lodash-move@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/lodash-move/-/lodash-move-1.1.1.tgz#59f76e0f1ac57e6d8683f531bec07c5b6ea4e348"
integrity sha1-WfduDxrFfm2Gg/UxvsB8W26k40g=
dependencies:
lodash "^4.6.1"
lodash.curry@^4.0.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.curry/-/lodash.curry-4.1.1.tgz#248e36072ede906501d75966200a86dab8b23170"
@@ -8493,7 +8491,7 @@ lodash.uniq@4.5.0:
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.6.1:
lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21:
version "4.17.21"
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -8599,9 +8597,9 @@ 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"
integrity sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA==
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#acef1d7dd0b915368730efabee94deb42b2e4058":
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#5e766978b8cf80d943f796df1067722a6a5918a7":
version "17.2.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/acef1d7dd0b915368730efabee94deb42b2e4058"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/5e766978b8cf80d943f796df1067722a6a5918a7"
dependencies:
"@babel/runtime" "^7.12.5"
another-json "^0.2.0"