Compare commits

..

1 Commits

Author SHA1 Message Date
Robert Long
34f8023a4e Always authenticate user by registering temp guest accounts 2021-12-14 18:25:52 -08:00
196 changed files with 4982 additions and 22064 deletions

View File

@@ -1 +0,0 @@
node_modules

28
.env
View File

@@ -4,25 +4,19 @@
# https://vitejs.dev/guide/env-and-mode.html#env-files
####
# Used for determining the homeserver to use for short urls etc.
# VITE_DEFAULT_HOMESERVER=http://localhost:8008
# Used for submitting debug logs to an external rageshake server
# VITE_RAGESHAKE_SUBMIT_URL=http://localhost:9110/api/submit
# The room id for the space to use for listing public group call rooms
# VITE_PUBLIC_SPACE_ROOM_ID=!hjdfshkdskjdsk:myhomeserver.com
# The Sentry DSN to use for error reporting. Leave undefined to disable.
# VITE_SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0
# VITE_CUSTOM_THEME=true
# 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
# 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

View File

@@ -1,38 +0,0 @@
module.exports = {
plugins: [
"matrix-org",
],
extends: [
"plugin:matrix-org/react",
"plugin:matrix-org/a11y",
"prettier",
],
env: {
browser: true,
node: true,
},
parserOptions: {
"ecmaVersion": "latest",
"sourceType": "module",
},
rules: {
"jsx-a11y/media-has-caption": ["off"],
},
overrides: [
{
files: [
"src/**/*.{ts,tsx}",
],
extends: [
"plugin:matrix-org/typescript",
"plugin:matrix-org/react",
"prettier",
],
},
],
settings: {
react: {
version: "detect",
},
},
};

1
.github/CODEOWNERS vendored
View File

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

View File

@@ -1,31 +0,0 @@
name: Build
on:
push:
branches: [main]
env:
VITE_DEFAULT_HOMESERVER: "https://call.ems.host"
VITE_SENTRY_DSN: https://b1e328d49be3402ba96101338989fb35@sentry.matrix.org/41
VITE_SENTRY_ENVIRONMENT: main-branch-cd
VITE_RAGESHAKE_SUBMIT_URL: https://element.io/bugreports/submit
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Yarn cache
uses: actions/setup-node@v3
with:
cache: 'yarn'
- name: Install dependencies
run: "yarn install"
- name: Build
run: "yarn run build"
- name: Upload Artifact
uses: actions/upload-artifact@v2
with:
name: build
path: dist
# We'll only use this in a triggered job, then we're done with it
retention-days: 1

View File

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

View File

@@ -1,79 +0,0 @@
name: Netlify Main
on:
workflow_run:
workflows: ["Build"]
types:
- completed
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
deployments: write
if: github.event.workflow_run.conclusion == 'success'
steps:
- name: Create Deployment
uses: bobheadxi/deployments@v1
id: deployment
with:
step: start
token: ${{ secrets.GITHUB_TOKEN }}
env: main-branch-cd
ref: ${{ github.event.workflow_run.head_sha }}
- name: 'Download artifact'
uses: actions/github-script@v3.1.0
with:
script: |
const artifacts = await github.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{ github.event.workflow_run.id }},
});
const matchArtifact = artifacts.data.artifacts.filter((artifact) => {
return artifact.name == "build"
})[0];
const download = await github.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
const fs = require('fs');
fs.writeFileSync('${{github.workspace}}/build.zip', Buffer.from(download.data));
- name: Extract Artifacts
run: unzip -d dist build.zip && rm build.zip
- name: Add redirects file
# We fetch from github directly as we don't bother checking out the repo
run: curl -s https://raw.githubusercontent.com/vector-im/element-call/main/config/netlify_redirects > dist/_redirects
- name: Deploy to Netlify
id: netlify
uses: nwtgck/actions-netlify@v1.2.3
with:
publish-dir: dist
deploy-message: "Deploy from GitHub Actions"
production-branch: main
production-deploy: true
# These don't work because we're in workflow_run
enable-pull-request-comment: false
enable-commit-comment: false
github-deployment-environment: main
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
timeout-minutes: 1
- name: Update deployment status
uses: bobheadxi/deployments@v1
if: always()
with:
step: finish
override: false
token: ${{ secrets.GITHUB_TOKEN }}
status: ${{ job.status }}
env: ${{ steps.deployment.outputs.env }}
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
env_url: ${{ steps.netlify.outputs.deploy-url }}

View File

@@ -1,45 +0,0 @@
name: Build & publish images to the package registry for tags
on:
release:
types: [published]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
name: Build & publish
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Check it out
uses: actions/checkout@v2
- name: Log in to container registry
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
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 }}

3
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -1,25 +0,0 @@
const svgrPlugin = require("vite-plugin-svgr");
const path = require("path");
module.exports = {
stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"],
framework: "@storybook/react",
core: {
builder: "storybook-builder-vite",
},
async viteFinal(config) {
config.plugins = config.plugins.filter(
(item) =>
!(
Array.isArray(item) &&
item.length > 0 &&
item[0].name === "vite-plugin-mdx"
)
);
config.plugins.push(svgrPlugin());
config.resolve = config.resolve || {};
config.resolve.dedupe = config.resolve.dedupe || [];
config.resolve.dedupe.push("react", "react-dom", "matrix-js-sdk");
return config;
},
};

View File

@@ -1,25 +0,0 @@
import React from "react";
import { addDecorator } from "@storybook/react";
import { MemoryRouter } from "react-router-dom";
import { usePageFocusStyle } from "../src/usePageFocusStyle";
import { OverlayProvider } from "@react-aria/overlays";
import "../src/index.css";
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};
addDecorator((story) => {
usePageFocusStyle();
return (
<MemoryRouter initialEntries={["/"]}>
<OverlayProvider>{story()}</OverlayProvider>
</MemoryRouter>
);
});

View File

@@ -1,6 +1,5 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.insertSpaces": true,
"editor.tabSize": 2
}
}

View File

@@ -1,4 +0,0 @@
Contributing code to Element
============================
Element follows the same pattern as the [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md).

View File

@@ -1,18 +0,0 @@
FROM --platform=$BUILDPLATFORM node:16-buster as builder
WORKDIR /src
COPY . /src/element-call
RUN element-call/scripts/dockerbuild.sh
# App
FROM nginxinc/nginx-unprivileged:alpine
COPY --from=builder /src/element-call/dist /app
COPY config/default.conf /etc/nginx/conf.d/
USER root
RUN rm -rf /usr/share/nginx/html
USER 101

View File

@@ -1,12 +1,10 @@
# Element Call
# Matrix Video Chat
Showcase for full mesh video chat powered by Matrix, implementing [MSC3401](https://github.com/matrix-org/matrix-spec-proposals/blob/matthew/group-voip/proposals/3401-group-voip.md).
Discussion in [#webrtc:matrix.org: ![#webrtc:matrix.org](https://img.shields.io/matrix/webrtc:matrix.org)](https://matrix.to/#/#webrtc:matrix.org)
Testbed for full mesh video chat.
## Getting Started
`element-call` is built against the `robertlong/group-call` branch of [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk/pull/1902). Because of how this package is configured and Vite's requirements, you will need to clone it locally and use `yarn link` to stich things together.
`matrix-video-chat` is built against the `robertlong/group-call` branch of both [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk/pull/1902) and [matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk/pull/6848). Because of how these packages are configured and Vite's requirements, you will need to clone them locally and use `yarn link` to stich things together.
First clone, install, and link `matrix-js-sdk`
@@ -18,36 +16,30 @@ yarn
yarn link
```
Then clone, install, link `matrix-js-sdk` into `matrix-react-sdk`, and link `matrix-react-sdk`
```
git clone https://github.com/matrix-org/matrix-react-sdk.git
cd matrix-react-sdk
git checkout robertlong/group-call
yarn
yarn link matrix-js-sdk
yarn link
```
Next you'll also need [Synapse](https://matrix-org.github.io/synapse/latest/setup/installation.html) installed locally and running on port 8008.
Finally we can set up this project.
```
git clone https://github.com/vector-im/element-call.git
cd element-call
git clone https://github.com/vector-im/matrix-video-chat.git
cd matrix-video-chat
yarn
yarn link matrix-js-sdk
yarn link matrix-react-sdk
yarn dev
```
## Config
Configuration options are documented in the `.env` file.
## License
All files in this project are:
Copyright 2021-2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -1,10 +0,0 @@
server {
listen 8080;
server_name localhost;
location / {
root /app;
try_files $uri /$uri /index.html;
}
}

View File

@@ -1,4 +0,0 @@
# This file is copied to the netlify deploy dir in the upload stage
# Redirect any unknown path to index.html
/* /index.html 200

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>Matrix Video Chat</title>
<script>
window.global = window;
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -3,18 +3,9 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook",
"prettier:check": "prettier -c src",
"prettier:format": "prettier -w src",
"lint": "yarn lint:types && yarn lint:js",
"lint:js": "eslint --max-warnings 0 src",
"lint:types": "tsc"
"serve": "vite preview"
},
"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",
@@ -22,25 +13,18 @@
"@react-aria/overlays": "^3.7.3",
"@react-aria/select": "^3.6.0",
"@react-aria/tabs": "^3.1.0",
"@react-aria/tooltip": "^3.1.3",
"@react-aria/utils": "^3.10.0",
"@react-spring/web": "^9.4.4",
"@react-stately/collections": "^3.3.4",
"@react-stately/overlays": "^3.1.3",
"@react-stately/select": "^3.1.3",
"@react-stately/tooltip": "^3.0.5",
"@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",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#5e766978b8cf80d943f796df1067722a6a5918a7",
"mermaid": "^8.13.8",
"normalize.css": "^8.0.1",
"pako": "^2.0.4",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#robertlong/group-call",
"matrix-react-sdk": "github:matrix-org/matrix-react-sdk#robertlong/group-call",
"postcss-preset-env": "^6.7.0",
"re-resizable": "^6.9.0",
"react": "^17.0.0",
@@ -48,32 +32,11 @@
"react-json-view": "^1.21.3",
"react-router": "6",
"react-router-dom": "^5.2.0",
"react-use-clipboard": "^1.0.7",
"react-use-measure": "^2.1.1",
"unique-names-generator": "^4.6.0"
"react-use-clipboard": "^1.0.7"
},
"devDependencies": {
"@babel/core": "^7.16.5",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
"@storybook/react": "^6.5.0-alpha.5",
"@types/request": "^2.48.8",
"@typescript-eslint/eslint-plugin": "^5.22.0",
"@typescript-eslint/parser": "^5.22.0",
"babel-loader": "^8.2.3",
"eslint": "^8.14.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-matrix-org": "^0.4.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.5.0",
"prettier": "^2.6.2",
"sass": "^1.42.1",
"storybook-builder-vite": "^0.1.12",
"typescript": "^4.6.4",
"vite": "^2.4.2",
"vite-plugin-html-template": "^1.1.0",
"vite-plugin-svgr": "^0.4.0"
}
}

View File

@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>
<%- title %>
</title>
<script>
window.global = window;
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -1,23 +0,0 @@
#!/bin/sh
set -ex
export VITE_DEFAULT_HOMESERVER=https://call.ems.host
export VITE_SENTRY_DSN=https://b1e328d49be3402ba96101338989fb35@sentry.matrix.org/41
export VITE_RAGESHAKE_SUBMIT_URL=https://element.io/bugreports/submit
export VITE_PRODUCT_NAME="Element Call"
git clone https://github.com/matrix-org/matrix-js-sdk.git
cd matrix-js-sdk
git checkout robertlong/group-call
yarn install
yarn run build
yarn link
cd ../element-call
export VITE_APP_VERSION=$(git describe --tags --abbrev=0)
yarn link matrix-js-sdk
yarn install
yarn run build

View File

@@ -1,24 +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-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,2 +0,0 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />

View File

@@ -14,31 +14,56 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import React, { useEffect, useState } from "react";
import {
BrowserRouter as Router,
Switch,
Route,
useLocation,
useHistory,
} from "react-router-dom";
import * as Sentry from "@sentry/react";
import { OverlayProvider } from "@react-aria/overlays";
import { HomePage } from "./home/HomePage";
import { LoginPage } from "./auth/LoginPage";
import { RegisterPage } from "./auth/RegisterPage";
import { RoomPage } from "./room/RoomPage";
import { RoomRedirect } from "./room/RoomRedirect";
import { ClientProvider } from "./ClientContext";
import { usePageFocusStyle } from "./usePageFocusStyle";
import { SequenceDiagramViewerPage } from "./SequenceDiagramViewerPage";
import { Home } from "./Home";
import { LoginPage } from "./LoginPage";
import { RegisterPage } from "./RegisterPage";
import { Room } from "./Room";
import { ClientProvider } from "./ConferenceCallManagerHooks";
import { useFocusVisible } from "@react-aria/interactions";
import styles from "./App.module.css";
import { ErrorView, LoadingView } from "./FullScreenView";
const SentryRoute = Sentry.withSentryRouting(Route);
export default function App({ history }) {
usePageFocusStyle();
const { protocol, host } = window.location;
// Assume homeserver is hosted on same domain (proxied in development by vite)
const homeserverUrl = `${protocol}//${host}`;
export default function App() {
const { isFocusVisible } = useFocusVisible();
useEffect(() => {
const classList = document.body.classList;
const hasClass = classList.contains(styles.hideFocus);
if (isFocusVisible && hasClass) {
classList.remove(styles.hideFocus);
} else if (!isFocusVisible && !hasClass) {
classList.add(styles.hideFocus);
}
return () => {
classList.remove(styles.hideFocus);
};
}, [isFocusVisible]);
return (
<Router history={history}>
<ClientProvider>
<Router>
<ClientProvider homeserverUrl={homeserverUrl}>
<OverlayProvider>
<Switch>
<SentryRoute exact path="/">
<HomePage />
<Home />
</SentryRoute>
<SentryRoute exact path="/login">
<LoginPage />
@@ -47,10 +72,7 @@ export default function App({ history }) {
<RegisterPage />
</SentryRoute>
<SentryRoute path="/room/:roomId?">
<RoomPage />
</SentryRoute>
<SentryRoute path="/inspector">
<SequenceDiagramViewerPage />
<Room />
</SentryRoute>
<SentryRoute path="*">
<RoomRedirect />
@@ -61,3 +83,54 @@ export default function App({ history }) {
</Router>
);
}
function RoomRedirect() {
const { pathname } = useLocation();
const history = useHistory();
const [error, setError] = useState();
useEffect(() => {
async function redirect() {
let roomId = pathname;
if (pathname.startsWith("/")) {
roomId = roomId.substr(1, roomId.length);
}
if (!roomId.startsWith("#") && !roomId.startsWith("!")) {
let loginHomeserverUrl = homeserverUrl.trim();
if (!loginHomeserverUrl.includes("://")) {
loginHomeserverUrl = "https://" + loginHomeserverUrl;
}
try {
const wellKnownUrl = new URL(
"/.well-known/matrix/client",
window.location
);
const response = await fetch(wellKnownUrl);
const config = await response.json();
if (config["m.homeserver"]) {
loginHomeserverUrl = config["m.homeserver"];
}
} catch (error) {}
const { host } = new URL(loginHomeserverUrl);
roomId = `#${roomId}:${host}`;
}
history.replace(`/room/${roomId}`);
}
redirect().catch(setError);
}, [history, pathname]);
if (error) {
return <ErrorView error={error} />;
}
return <LoadingView />;
}

3
src/App.module.css Normal file
View File

@@ -0,0 +1,3 @@
.hideFocus * {
outline: none;
}

58
src/Avatar.jsx Normal file
View File

@@ -0,0 +1,58 @@
import React, { useMemo } from "react";
import classNames from "classnames";
import styles from "./Avatar.module.css";
const backgroundColors = [
"#5C56F5",
"#03B381",
"#368BD6",
"#AC3BA8",
"#E64F7A",
"#FF812D",
"#2DC2C5",
"#74D12C",
];
function hashStringToArrIndex(str, arrLength) {
let sum = 0;
for (let i = 0; i < str.length; i++) {
sum += str.charCodeAt(i);
}
return sum % arrLength;
}
export function Avatar({
bgKey,
src,
fallback,
size,
className,
style,
...rest
}) {
const backgroundColor = useMemo(() => {
const index = hashStringToArrIndex(
bgKey || fallback || src,
backgroundColors.length
);
return backgroundColors[index];
}, [bgKey, src, fallback]);
return (
<div
className={classNames(styles.avatar, styles[size || "md"], className)}
style={{ backgroundColor, ...style }}
{...rest}
>
{src ? (
<img src={src} />
) : typeof fallback === "string" ? (
<span>{fallback}</span>
) : (
fallback
)}
</div>
);
}

View File

@@ -1,13 +1,11 @@
.avatar {
position: relative;
color: var(--primary-content);
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
font-weight: 600;
overflow: hidden;
flex-shrink: 0;
}
.avatar img {
@@ -17,27 +15,16 @@
}
.avatar svg * {
fill: var(--primary-content);
fill: #ffffff;
}
.avatar span {
padding-top: 1px;
}
.xs {
.sm {
width: 22px;
height: 22px;
border-radius: 22px;
font-size: 14px;
}
.sm {
width: 32px;
height: 32px;
border-radius: 32px;
font-size: 15px;
}
.md {
width: 36px;
height: 36px;
@@ -49,12 +36,11 @@
width: 42px;
height: 42px;
border-radius: 42px;
font-size: 24px;
font-size: 36px;
}
.xl {
width: 90px;
height: 90px;
border-radius: 90px;
font-size: 48px;
}

View File

@@ -1,115 +0,0 @@
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 = [
"#5C56F5",
"#03B381",
"#368BD6",
"#AC3BA8",
"#E64F7A",
"#FF812D",
"#2DC2C5",
"#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;
for (let i = 0; i < str.length; i++) {
sum += str.charCodeAt(i);
}
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?: Size | number;
className: string;
style?: CSSProperties;
}
export const Avatar: React.FC<Props> = ({
bgKey,
src,
fallback,
size = Size.MD,
className,
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 || "",
backgroundColors.length
);
return backgroundColors[index];
}, [bgKey, src, fallback]);
/* eslint-disable jsx-a11y/alt-text */
return (
<div
className={classNames(styles.avatar, sizeClass, className)}
style={{ backgroundColor, ...sizeStyle, ...style }}
{...rest}
>
{resolvedSrc ? (
<img src={resolvedSrc} />
) : typeof fallback === "string" ? (
<span>{fallback}</span>
) : (
fallback
)}
</div>
);
};

54
src/CallList.jsx Normal file
View File

@@ -0,0 +1,54 @@
import React, { useMemo } from "react";
import { Link } from "react-router-dom";
import { CopyButton } from "./button";
import { Facepile } from "./Facepile";
import { Avatar } from "./Avatar";
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
import styles from "./CallList.module.css";
import { getRoomUrl } from "./ConferenceCallManagerHooks";
export function CallList({ title, rooms }) {
return (
<>
<h3>{title}</h3>
<div className={styles.callList}>
{rooms.map(({ roomId, roomName, avatarUrl, participants }) => (
<CallTile
key={roomId}
name={roomName}
avatarUrl={avatarUrl}
roomId={roomId}
participants={participants}
/>
))}
</div>
</>
);
}
function CallTile({ name, avatarUrl, roomId, participants }) {
return (
<div className={styles.callTile}>
<Link to={`/room/${roomId}`} className={styles.callTileLink}>
<Avatar
size="md"
bgKey={name}
src={avatarUrl}
fallback={<VideoIcon width={16} height={16} />}
className={styles.avatar}
/>
<div className={styles.callInfo}>
<h5>{name}</h5>
<p>{getRoomUrl(roomId)}</p>
{participants && <Facepile participants={participants} />}
</div>
<div className={styles.copyButtonSpacer} />
</Link>
<CopyButton
className={styles.copyButton}
variant="icon"
value={getRoomUrl(roomId)}
/>
</div>
);
}

View File

@@ -1,16 +1,8 @@
.callTileSpacer,
.callTile {
width: 329px;
}
.callTileSpacer {
height: 0;
}
.callTile {
height: 95px;
min-width: 240px;
height: 94px;
padding: 12px;
background-color: var(--system);
background-color: var(--bgColor2);
border-radius: 8px;
overflow: hidden;
box-sizing: border-box;
@@ -22,8 +14,6 @@
display: flex;
text-decoration: none;
width: 100%;
height: 100%;
align-items: center;
}
.avatar,
@@ -36,16 +26,33 @@
flex-direction: column;
flex: 1;
padding: 0 16px;
color: var(--primary-content);
color: var(--textColor1);
min-width: 0;
}
.callInfo > * {
margin-top: 0;
margin-bottom: 8px;
}
.callInfo > :last-child {
margin-bottom: 0;
}
.facePile {
margin-top: 8px;
.callInfo h5 {
font-size: 15px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.callInfo p {
font-weight: 400;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.copyButtonSpacer,
@@ -61,12 +68,7 @@
}
.callList {
display: flex;
flex-wrap: wrap;
max-width: calc((329px + 24px) * 3);
width: calc(100% - 48px);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 24px;
padding: 0 24px;
justify-content: center;
margin-bottom: 24px;
}

View File

@@ -1,279 +0,0 @@
/*
Copyright 2021-2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {
FC,
useCallback,
useEffect,
useState,
createContext,
useMemo,
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";
declare global {
interface Window {
matrixclient: MatrixClient;
}
}
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<ClientProviderState>({
loading: true,
isAuthenticated: false,
isPasswordlessUser: false,
client: undefined,
userName: null,
error: undefined,
});
useEffect(() => {
const restore = async (): Promise<
Pick<ClientProviderState, "client" | "isPasswordlessUser">
> => {
try {
const session = loadSession();
if (session) {
/* eslint-disable camelcase */
const { user_id, device_id, access_token, passwordlessUser } =
session;
const client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
/* eslint-enable camelcase */
return { client, isPasswordlessUser: passwordlessUser };
}
return { client: undefined, isPasswordlessUser: false };
} catch (err) {
console.error(err);
clearSession();
throw err;
}
};
restore()
.then(({ client, isPasswordlessUser }) => {
setState({
client,
loading: false,
isAuthenticated: Boolean(client),
isPasswordlessUser,
userName: client?.getUserIdLocalpart(),
});
})
.catch(() => {
setState({
client: undefined,
loading: false,
isAuthenticated: false,
isPasswordlessUser: false,
userName: null,
});
});
}, []);
const changePassword = useCallback(
async (password: string) => {
const { tempPassword, ...session } = loadSession();
await client.setPassword(
{
type: "m.login.password",
identifier: {
type: "m.id.user",
user: session.user_id,
},
user: session.user_id,
password: tempPassword,
},
password
);
saveSession({ ...session, passwordlessUser: false });
setState({
client,
loading: false,
isAuthenticated: true,
isPasswordlessUser: false,
userName: client.getUserIdLocalpart(),
});
},
[client]
);
const setClient = useCallback(
(newClient: MatrixClient, session: Session) => {
if (client && client !== newClient) {
client.stopClient();
}
if (newClient) {
saveSession(session);
setState({
client: newClient,
loading: false,
isAuthenticated: true,
isPasswordlessUser: session.passwordlessUser,
userName: newClient.getUserIdLocalpart(),
});
} else {
clearSession();
setState({
client: undefined,
loading: false,
isAuthenticated: false,
isPasswordlessUser: false,
userName: null,
});
}
},
[client]
);
const logout = useCallback(() => {
clearSession();
history.push("/");
}, [history]);
useEffect(() => {
if (client) {
const loadTime = Date.now();
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.timestamp > loadTime) {
client?.stopClient();
setState((prev) => ({
...prev,
error: new Error(
"This application has been opened in another tab."
),
}));
}
};
client.on(ClientEvent.ToDeviceEvent, onToDeviceEvent);
client.sendToDevice("org.matrix.call_duplicate_session", {
[client.getUserId()]: {
"*": { session_id: client.getSessionId(), timestamp: loadTime },
},
});
return () => {
client?.removeListener(ClientEvent.ToDeviceEvent, onToDeviceEvent);
};
}
}, [client]);
const context = useMemo<ClientState>(
() => ({
loading,
isAuthenticated,
isPasswordlessUser,
client,
changePassword,
logout,
userName,
setClient,
}),
[
loading,
isAuthenticated,
isPasswordlessUser,
client,
changePassword,
logout,
userName,
setClient,
]
);
useEffect(() => {
window.matrixclient = client;
}, [client]);
if (error) {
return <ErrorView error={error} />;
}
return (
<ClientContext.Provider value={context}>{children}</ClientContext.Provider>
);
};
export const useClient = () => useContext(ClientContext);

View File

@@ -0,0 +1,437 @@
/*
Copyright 2021 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";
export class ConferenceCallDebugger extends EventEmitter {
constructor(client, groupCall) {
super();
this.client = client;
this.groupCall = groupCall;
this.debugState = {
users: new Map(),
calls: new Map(),
};
this.bufferedEvents = [];
client.on("event", this._onEvent);
groupCall.on("call", this._onCall);
groupCall.on("debugstate", this._onDebugStateChanged);
groupCall.on("entered", this._onEntered);
groupCall.on("left", this._onLeft);
}
_onEntered = () => {
const eventCount = this.bufferedEvents.length;
for (let i = 0; i < eventCount; i++) {
const event = this.bufferedEvents.pop();
this._onEvent(event);
}
};
_onLeft = () => {
this.bufferedEvents = [];
this.debugState = {
users: new Map(),
calls: new Map(),
};
this.emit("debug");
};
_onEvent = (event) => {
if (!this.groupCall.entered) {
this.bufferedEvents.push(event);
return;
}
const roomId = event.getRoomId();
const type = event.getType();
if (
roomId === this.groupCall.room.roomId &&
(type.startsWith("m.call.") ||
type === "me.robertlong.call.info" ||
type === "m.room.member")
) {
const sender = event.getSender();
const { call_id } = event.getContent();
if (call_id) {
if (this.debugState.calls.has(call_id)) {
const callState = this.debugState.calls.get(call_id);
callState.events.push(event);
} else {
this.debugState.calls.set(call_id, {
state: "unknown",
events: [event],
});
}
}
if (this.debugState.users.has(sender)) {
const userState = this.debugState.users.get(sender);
userState.events.push(event);
} else {
this.debugState.users.set(sender, {
state: "unknown",
events: [event],
});
}
this.emit("debug");
}
};
_onDebugStateChanged = (userId, callId, state) => {
if (userId) {
const userState = this.debugState.users.get(userId);
if (userState) {
userState.state = state;
} else {
this.debugState.users.set(userId, {
state,
events: [],
});
}
}
if (callId) {
const callState = this.debugState.calls.get(callId);
if (callState) {
callState.state = state;
} else {
this.debugState.calls.set(callId, {
state,
events: [],
});
}
}
this.emit("debug");
};
_onCall = (call) => {
const peerConnection = call.peerConn;
if (!peerConnection) {
return;
}
const sendWebRTCInfoEvent = async (eventType) => {
const event = {
call_id: call.callId,
eventType,
iceConnectionState: peerConnection.iceConnectionState,
iceGatheringState: peerConnection.iceGatheringState,
signalingState: peerConnection.signalingState,
selectedCandidatePair: null,
localCandidate: null,
remoteCandidate: null,
};
// getStats doesn't support selectors in Firefox so get all stats by passing null.
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/getStats#browser_compatibility
const stats = await peerConnection.getStats(null);
const statsArr = Array.from(stats.values());
// Matrix doesn't support floats so we convert time in seconds to ms
function secToMs(time) {
if (time === undefined) {
return undefined;
}
return Math.round(time * 1000);
}
function processTransportStats(transportStats) {
if (!transportStats) {
return undefined;
}
return {
packetsSent: transportStats.packetsSent,
packetsReceived: transportStats.packetsReceived,
bytesSent: transportStats.bytesSent,
bytesReceived: transportStats.bytesReceived,
iceRole: transportStats.iceRole,
iceState: transportStats.iceState,
dtlsState: transportStats.dtlsState,
dtlsCipher: transportStats.dtlsCipher,
tlsVersion: transportStats.tlsVersion,
};
}
function processCandidateStats(candidateStats) {
if (!candidateStats) {
return undefined;
}
// TODO: Figure out how to normalize ip and address across browsers
// networkType property excluded for privacy reasons:
// https://www.w3.org/TR/webrtc-stats/#sotd
return {
priority:
candidateStats.priority && candidateStats.priority.toString(),
candidateType: candidateStats.candidateType,
protocol: candidateStats.protocol,
address: !!candidateStats.address
? candidateStats.address
: candidateStats.ip,
port: candidateStats.port,
url: candidateStats.url,
relayProtocol: candidateStats.relayProtocol,
};
}
function processCandidatePair(candidatePairStats) {
if (!candidatePairStats) {
return undefined;
}
const localCandidateStats = statsArr.find(
(stat) => stat.id === candidatePairStats.localCandidateId
);
event.localCandidate = processCandidateStats(localCandidateStats);
const remoteCandidateStats = statsArr.find(
(stat) => stat.id === candidatePairStats.remoteCandidateId
);
event.remoteCandidate = processCandidateStats(remoteCandidateStats);
const transportStats = statsArr.find(
(stat) => stat.id === candidatePairStats.transportId
);
event.transport = processTransportStats(transportStats);
return {
state: candidatePairStats.state,
bytesSent: candidatePairStats.bytesSent,
bytesReceived: candidatePairStats.bytesReceived,
requestsSent: candidatePairStats.requestsSent,
requestsReceived: candidatePairStats.requestsReceived,
responsesSent: candidatePairStats.responsesSent,
responsesReceived: candidatePairStats.responsesReceived,
currentRoundTripTime: secToMs(
candidatePairStats.currentRoundTripTime
),
totalRoundTripTime: secToMs(candidatePairStats.totalRoundTripTime),
};
}
// Firefox uses the deprecated "selected" property for the nominated ice candidate.
const selectedCandidatePair = statsArr.find(
(stat) =>
stat.type === "candidate-pair" && (stat.selected || stat.nominated)
);
event.selectedCandidatePair = processCandidatePair(selectedCandidatePair);
function processCodecStats(codecStats) {
if (!codecStats) {
return undefined;
}
// Payload type enums and MIME types listed here:
// https://www.iana.org/assignments/rtp-parameters/rtp-parameters.xhtml
return {
mimeType: codecStats.mimeType,
clockRate: codecStats.clockRate,
payloadType: codecStats.payloadType,
channels: codecStats.channels,
sdpFmtpLine: codecStats.sdpFmtpLine,
};
}
function processRTPStreamStats(rtpStreamStats) {
const codecStats = statsArr.find(
(stat) => stat.id === rtpStreamStats.codecId
);
const codec = processCodecStats(codecStats);
return {
kind: rtpStreamStats.kind,
codec,
};
}
function processInboundRTPStats(inboundRTPStats) {
const rtpStreamStats = processRTPStreamStats(inboundRTPStats);
return {
...rtpStreamStats,
decoderImplementation: inboundRTPStats.decoderImplementation,
bytesReceived: inboundRTPStats.bytesReceived,
packetsReceived: inboundRTPStats.packetsReceived,
packetsLost: inboundRTPStats.packetsLost,
jitter: secToMs(inboundRTPStats.jitter),
frameWidth: inboundRTPStats.frameWidth,
frameHeight: inboundRTPStats.frameHeight,
frameBitDepth: inboundRTPStats.frameBitDepth,
framesPerSecond:
inboundRTPStats.framesPerSecond &&
inboundRTPStats.framesPerSecond.toString(),
framesReceived: inboundRTPStats.framesReceived,
framesDecoded: inboundRTPStats.framesDecoded,
framesDropped: inboundRTPStats.framesDropped,
totalSamplesDecoded: inboundRTPStats.totalSamplesDecoded,
totalDecodeTime: secToMs(inboundRTPStats.totalDecodeTime),
totalProcessingDelay: secToMs(inboundRTPStats.totalProcessingDelay),
};
}
function processOutboundRTPStats(outboundRTPStats) {
const rtpStreamStats = processRTPStreamStats(outboundRTPStats);
return {
...rtpStreamStats,
encoderImplementation: outboundRTPStats.encoderImplementation,
bytesSent: outboundRTPStats.bytesSent,
packetsSent: outboundRTPStats.packetsSent,
frameWidth: outboundRTPStats.frameWidth,
frameHeight: outboundRTPStats.frameHeight,
frameBitDepth: outboundRTPStats.frameBitDepth,
framesPerSecond:
outboundRTPStats.framesPerSecond &&
outboundRTPStats.framesPerSecond.toString(),
framesSent: outboundRTPStats.framesSent,
framesEncoded: outboundRTPStats.framesEncoded,
qualityLimitationReason: outboundRTPStats.qualityLimitationReason,
qualityLimitationResolutionChanges:
outboundRTPStats.qualityLimitationResolutionChanges,
totalEncodeTime: secToMs(outboundRTPStats.totalEncodeTime),
totalPacketSendDelay: secToMs(outboundRTPStats.totalPacketSendDelay),
};
}
function processRemoteInboundRTPStats(remoteInboundRTPStats) {
const rtpStreamStats = processRTPStreamStats(remoteInboundRTPStats);
return {
...rtpStreamStats,
packetsReceived: remoteInboundRTPStats.packetsReceived,
packetsLost: remoteInboundRTPStats.packetsLost,
jitter: secToMs(remoteInboundRTPStats.jitter),
framesDropped: remoteInboundRTPStats.framesDropped,
roundTripTime: secToMs(remoteInboundRTPStats.roundTripTime),
totalRoundTripTime: secToMs(remoteInboundRTPStats.totalRoundTripTime),
fractionLost:
remoteInboundRTPStats.fractionLost !== undefined &&
remoteInboundRTPStats.fractionLost.toString(),
reportsReceived: remoteInboundRTPStats.reportsReceived,
roundTripTimeMeasurements:
remoteInboundRTPStats.roundTripTimeMeasurements,
};
}
function processRemoteOutboundRTPStats(remoteOutboundRTPStats) {
const rtpStreamStats = processRTPStreamStats(remoteOutboundRTPStats);
return {
...rtpStreamStats,
encoderImplementation: remoteOutboundRTPStats.encoderImplementation,
bytesSent: remoteOutboundRTPStats.bytesSent,
packetsSent: remoteOutboundRTPStats.packetsSent,
roundTripTime: secToMs(remoteOutboundRTPStats.roundTripTime),
totalRoundTripTime: secToMs(
remoteOutboundRTPStats.totalRoundTripTime
),
reportsSent: remoteOutboundRTPStats.reportsSent,
roundTripTimeMeasurements:
remoteOutboundRTPStats.roundTripTimeMeasurements,
};
}
event.inboundRTP = statsArr
.filter((stat) => stat.type === "inbound-rtp")
.map(processInboundRTPStats);
event.outboundRTP = statsArr
.filter((stat) => stat.type === "outbound-rtp")
.map(processOutboundRTPStats);
event.remoteInboundRTP = statsArr
.filter((stat) => stat.type === "remote-inbound-rtp")
.map(processRemoteInboundRTPStats);
event.remoteOutboundRTP = statsArr
.filter((stat) => stat.type === "remote-outbound-rtp")
.map(processRemoteOutboundRTPStats);
this.client.sendEvent(
this.groupCall.room.roomId,
"me.robertlong.call.info",
event
);
};
let statsTimeout;
const sendStats = () => {
if (
call.state === "ended" ||
peerConnection.connectionState === "closed"
) {
clearTimeout(statsTimeout);
return;
}
sendWebRTCInfoEvent("stats");
statsTimeout = setTimeout(sendStats, 30 * 1000);
};
setTimeout(sendStats, 30 * 1000);
peerConnection.addEventListener("iceconnectionstatechange", () => {
sendWebRTCInfoEvent("iceconnectionstatechange");
});
peerConnection.addEventListener("icegatheringstatechange", () => {
sendWebRTCInfoEvent("icegatheringstatechange");
});
peerConnection.addEventListener("negotiationneeded", () => {
sendWebRTCInfoEvent("negotiationneeded");
});
peerConnection.addEventListener("track", () => {
sendWebRTCInfoEvent("track");
});
// NOTE: Not available on Firefox
// https://bugzilla.mozilla.org/show_bug.cgi?id=1561441
peerConnection.addEventListener(
"icecandidateerror",
({ errorCode, url, errorText }) => {
this.client.sendEvent(
this.groupCall.room.roomId,
"me.robertlong.call.ice_error",
{
call_id: call.callId,
errorCode,
url,
errorText,
}
);
}
);
peerConnection.addEventListener("signalingstatechange", () => {
sendWebRTCInfoEvent("signalingstatechange");
});
};
}

View File

@@ -0,0 +1,620 @@
/*
Copyright 2021 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 React, {
useCallback,
useEffect,
useState,
createContext,
useMemo,
useContext,
} from "react";
import matrix from "matrix-js-sdk/src/browser-index";
import {
GroupCallIntent,
GroupCallType,
} from "matrix-js-sdk/src/browser-index";
import { useHistory } from "react-router-dom";
import { randomString } from "matrix-js-sdk/src/randomstring";
const ClientContext = createContext();
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);
});
}
async function initClient(clientOptions, guest) {
const client = matrix.createClient(clientOptions);
if (guest) {
client.setGuest(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;
}
async function registerGuest(homeserverUrl) {
const registrationClient = matrix.createClient(homeserverUrl);
const { user_id, device_id, access_token } =
await registrationClient.registerGuest({});
const client = await initClient(
{
baseUrl: homeserverUrl,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
true
);
await client.setDisplayName(`Guest ${client.getUserIdLocalpart()}`);
localStorage.setItem(
"matrix-auth-store",
JSON.stringify({ user_id, device_id, access_token, guest: true })
);
return client;
}
export async function fetchGroupCall(
client,
roomIdOrAlias,
viaServers = undefined,
timeout = 5000
) {
const { roomId } = await client.joinRoom(roomIdOrAlias, { viaServers });
return new Promise((resolve, reject) => {
let timeoutId;
function onGroupCallIncoming(groupCall) {
if (groupCall && groupCall.room.roomId === roomId) {
clearTimeout(timeoutId);
client.removeListener("GroupCall.incoming", onGroupCallIncoming);
resolve(groupCall);
}
}
const groupCall = client.getGroupCallForRoom(roomId);
if (groupCall) {
resolve(groupCall);
}
client.on("GroupCall.incoming", onGroupCallIncoming);
if (timeout) {
timeoutId = setTimeout(() => {
client.removeListener("GroupCall.incoming", onGroupCallIncoming);
reject(new Error("Fetching group call timed out."));
}, timeout);
}
});
}
export function ClientProvider({ homeserverUrl, children }) {
const history = useHistory();
const [{ loading, isPasswordlessUser, isGuest, client, userName }, setState] =
useState({
loading: true,
isPasswordlessUser: false,
isGuest: false,
client: undefined,
userName: null,
displayName: null,
});
useEffect(() => {
async function restore() {
try {
const authStore = localStorage.getItem("matrix-auth-store");
if (authStore) {
const { user_id, device_id, access_token, guest, passwordlessUser } =
JSON.parse(authStore);
const client = await initClient(
{
baseUrl: homeserverUrl,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
guest
);
localStorage.setItem(
"matrix-auth-store",
JSON.stringify({
user_id,
device_id,
access_token,
guest,
passwordlessUser,
})
);
return { client, guest, passwordlessUser };
}
} catch (err) {
localStorage.removeItem("matrix-auth-store");
}
try {
const client = await registerGuest(homeserverUrl);
return { client, guest: true, passwordlessUser: false };
} catch (err) {
localStorage.removeItem("matrix-auth-store");
throw err;
}
}
restore()
.then(({ client, guest, passwordlessUser }) => {
setState({
client,
loading: false,
isPasswordlessUser: !!passwordlessUser,
isGuest: guest,
userName: client?.getUserIdLocalpart(),
});
})
.catch((error) => {
setState({
error,
client: undefined,
loading: false,
isPasswordlessUser: false,
isGuest: false,
userName: null,
});
});
}, []);
const login = useCallback(async (homeserver, username, password) => {
let loginHomeserverUrl = homeserver.trim();
if (!loginHomeserverUrl.includes("://")) {
loginHomeserverUrl = "https://" + loginHomeserverUrl;
}
try {
const wellKnownUrl = new URL(
"/.well-known/matrix/client",
window.location
);
const response = await fetch(wellKnownUrl);
const config = await response.json();
if (config["m.homeserver"]) {
loginHomeserverUrl = config["m.homeserver"];
}
} catch (error) {}
const registrationClient = matrix.createClient(loginHomeserverUrl);
const { user_id, device_id, access_token } =
await registrationClient.loginWithPassword(username, password);
const client = await initClient({
baseUrl: loginHomeserverUrl,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
localStorage.setItem(
"matrix-auth-store",
JSON.stringify({ user_id, device_id, access_token })
);
setState({
client,
loading: false,
isPasswordlessUser: false,
isGuest: false,
userName: client.getUserIdLocalpart(),
});
return client;
}, []);
const register = useCallback(async (username, password, passwordlessUser) => {
const registrationClient = matrix.createClient(homeserverUrl);
const { user_id, device_id, access_token } =
await registrationClient.register(username, password, null, {
type: "m.login.dummy",
});
const client = await initClient({
baseUrl: homeserverUrl,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
localStorage.setItem(
"matrix-auth-store",
JSON.stringify({ user_id, device_id, access_token, passwordlessUser })
);
setState({
client,
loading: false,
isGuest: false,
isPasswordlessUser: passwordlessUser,
userName: client.getUserIdLocalpart(),
});
return client;
}, []);
const logout = useCallback(() => {
localStorage.removeItem("matrix-auth-store");
window.location = "/";
}, [history]);
const context = useMemo(
() => ({
loading,
isPasswordlessUser,
isGuest,
client,
login,
register,
logout,
userName,
}),
[
loading,
isPasswordlessUser,
isGuest,
client,
login,
register,
logout,
userName,
]
);
return (
<ClientContext.Provider value={context}>{children}</ClientContext.Provider>
);
}
export function useClient() {
return useContext(ClientContext);
}
function roomAliasFromRoomName(roomName) {
return roomName
.trim()
.replace(/\s/g, "-")
.replace(/[^\w-]/g, "")
.toLowerCase();
}
export async function createRoom(client, name) {
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,
},
},
});
await client.setGuestAccess(room_id, {
allowJoin: true,
allowRead: true,
});
await client.createGroupCall(
room_id,
GroupCallType.Video,
GroupCallIntent.Prompt
);
return room_alias || room_id;
}
export function useCreateRoom() {
const { register, client } = useClient();
const [creatingRoom, setCreatingRoom] = useState(false);
const [createRoomError, setCreateRoomError] = useState();
const onCreateRoom = useCallback(
(roomName, userName) => {
async function onCreateRoom(roomName, userName) {
let _client = client;
if (!_client) {
_client = await register(userName, randomString(16), true);
}
return await createRoom(_client, roomName);
}
setCreateRoomError(undefined);
setCreatingRoom(true);
return onCreateRoom(roomName, userName).catch((error) => {
setCreateRoomError(error);
setCreatingRoom(false);
});
},
[register, client]
);
return {
creatingRoom,
createRoomError,
createRoom: onCreateRoom,
};
}
export function useLoadGroupCall(client, roomId, viaServers) {
const [state, setState] = useState({
loading: true,
error: undefined,
groupCall: undefined,
});
useEffect(() => {
setState({ loading: true });
fetchGroupCall(client, roomId, viaServers, 30000)
.then((groupCall) => setState({ loading: false, groupCall }))
.catch((error) => setState({ loading: false, error }));
}, [client, roomId]);
return state;
}
const tsCache = {};
function getLastTs(client, r) {
if (tsCache[r.roomId]) {
return tsCache[r.roomId];
}
if (!r || !r.timeline) {
const ts = Number.MAX_SAFE_INTEGER;
tsCache[r.roomId] = ts;
return ts;
}
const myUserId = client.getUserId();
if (r.getMyMembership() !== "join") {
const membershipEvent = r.currentState.getStateEvents(
"m.room.member",
myUserId
);
if (membershipEvent && !Array.isArray(membershipEvent)) {
const ts = membershipEvent.getTs();
tsCache[r.roomId] = ts;
return ts;
}
}
for (let i = r.timeline.length - 1; i >= 0; --i) {
const ev = r.timeline[i];
const ts = ev.getTs();
if (ts) {
tsCache[r.roomId] = ts;
return ts;
}
}
const ts = Number.MAX_SAFE_INTEGER;
tsCache[r.roomId] = ts;
return ts;
}
function sortRooms(client, rooms) {
return rooms.sort((a, b) => {
return getLastTs(client, b) - getLastTs(client, a);
});
}
export function useGroupCallRooms(client) {
const [rooms, setRooms] = useState([]);
useEffect(() => {
function updateRooms() {
const groupCalls = client.groupCallEventHandler.groupCalls.values();
const rooms = Array.from(groupCalls).map((groupCall) => groupCall.room);
const sortedRooms = sortRooms(client, rooms);
const items = sortedRooms.map((room) => {
const groupCall = client.getGroupCallForRoom(room.roomId);
return {
roomId: room.getCanonicalAlias() || room.roomId,
roomName: room.name,
avatarUrl: null,
room,
groupCall,
participants: [...groupCall.participants],
};
});
setRooms(items);
}
updateRooms();
client.on("GroupCall.incoming", updateRooms);
client.on("GroupCall.participants", updateRooms);
return () => {
client.removeListener("GroupCall.incoming", updateRooms);
client.removeListener("GroupCall.participants", updateRooms);
};
}, []);
return rooms;
}
export function usePublicRooms(client, publicSpaceRoomId, maxRooms = 50) {
const [rooms, setRooms] = useState([]);
useEffect(() => {
if (publicSpaceRoomId) {
client.getRoomHierarchy(publicSpaceRoomId, maxRooms).then(({ rooms }) => {
const filteredRooms = rooms
.filter((room) => room.room_type !== "m.space")
.map((room) => ({
roomId: room.room_alias || room.room_id,
roomName: room.name,
avatarUrl: null,
room,
participants: [],
}));
setRooms(filteredRooms);
});
} else {
setRooms([]);
}
}, [publicSpaceRoomId]);
return rooms;
}
export function getRoomUrl(roomId) {
if (roomId.startsWith("#")) {
const [localPart, host] = roomId.replace("#", "").split(":");
if (host !== window.location.host) {
return `${window.location.host}/room/${roomId}`;
} else {
return `${window.location.host}/${localPart}`;
}
} else {
return `${window.location.host}/room/${roomId}`;
}
}
export function useDisplayName(client) {
const [{ loading, displayName, error, success }, setState] = useState(() => ({
success: false,
loading: false,
displayName: client?.getUser(client.getUserId())?.displayName,
error: null,
}));
useEffect(() => {
const onChangeDisplayName = (_event, { displayName }) => {
setState({ success: false, loading: false, displayName, error: null });
};
let user;
if (client) {
const userId = client.getUserId();
user = client.getUser(userId);
user.on("User.displayName", onChangeDisplayName);
}
return () => {
if (user) {
user.removeListener("User.displayName", onChangeDisplayName);
}
};
}, [client]);
const setDisplayName = useCallback(
(displayName) => {
if (client) {
setState((prev) => ({
...prev,
loading: true,
error: null,
success: false,
}));
client
.setDisplayName(displayName)
.then(() => {
setState((prev) => ({
...prev,
displayName,
loading: false,
success: true,
}));
})
.catch((error) => {
setState((prev) => ({
...prev,
loading: false,
error,
success: false,
}));
});
} else {
console.error("Client not initialized before calling setDisplayName");
}
},
[client]
);
return { loading, error, displayName, setDisplayName, success };
}

View File

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

View File

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

36
src/GridLayoutMenu.jsx Normal file
View File

@@ -0,0 +1,36 @@
import React from "react";
import { ButtonTooltip, Button } from "./button";
import { PopoverMenuTrigger } from "./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 { Menu } from "./Menu";
import { Item } from "@react-stately/collections";
export function GridLayoutMenu({ layout, setLayout }) {
return (
<PopoverMenuTrigger placement="bottom right">
<Button variant="icon">
<ButtonTooltip>Layout Type</ButtonTooltip>
{layout === "spotlight" ? <SpotlightIcon /> : <FreedomIcon />}
</Button>
{(props) => (
<Menu {...props} label="Grid layout menu" onAction={setLayout}>
<Item key="freedom" textValue="Freedom">
<FreedomIcon />
<span>Freedom</span>
{layout === "freedom" && <CheckIcon className={styles.checkIcon} />}
</Item>
<Item key="spotlight" textValue="Spotlight">
<SpotlightIcon />
<span>Spotlight</span>
{layout === "spotlight" && (
<CheckIcon className={styles.checkIcon} />
)}
</Item>
</Menu>
)}
</PopoverMenuTrigger>
);
}

View File

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

204
src/GroupCallInspector.jsx Normal file
View File

@@ -0,0 +1,204 @@
import { Resizable } from "re-resizable";
import React, { useEffect, useState, useMemo } from "react";
import { useCallback } from "react";
import ReactJson from "react-json-view";
function getCallUserId(call) {
return call.getOpponentMember()?.userId || call.invitee || null;
}
function getCallState(call) {
return {
id: call.callId,
opponentMemberId: getCallUserId(call),
state: call.state,
direction: call.direction,
};
}
function getHangupCallState(call) {
return {
...getCallState(call),
hangupReason: call.hangupReason,
};
}
export function GroupCallInspector({ client, groupCall, show }) {
const [roomStateEvents, setRoomStateEvents] = useState([]);
const [toDeviceEvents, setToDeviceEvents] = useState([]);
const [state, setState] = useState({
userId: client.getUserId(),
});
const updateState = useCallback(
(next) => setState((prev) => ({ ...prev, ...next })),
[]
);
useEffect(() => {
function onUpdateRoomState(event) {
if (event) {
setRoomStateEvents((prev) => [
...prev,
{
eventType: event.getType(),
stateKey: event.getStateKey(),
content: event.getContent(),
},
]);
}
const roomEvent = groupCall.room.currentState
.getStateEvents("org.matrix.msc3401.call", groupCall.groupCallId)
.getContent();
const memberEvents = Object.fromEntries(
groupCall.room.currentState
.getStateEvents("org.matrix.msc3401.call.member")
.map((event) => [event.getStateKey(), event.getContent()])
);
updateState({
["org.matrix.msc3401.call"]: roomEvent,
["org.matrix.msc3401.call.member"]: memberEvents,
});
}
function onCallsChanged() {
const calls = groupCall.calls.reduce((obj, call) => {
obj[
`${call.callId} (${call.getOpponentMember()?.userId || call.sender})`
] = getCallState(call);
return obj;
}, {});
updateState({ calls });
}
function onCallHangup(call) {
setState(({ hangupCalls, ...rest }) => ({
...rest,
hangupCalls: {
...hangupCalls,
[`${call.callId} (${
call.getOpponentMember()?.userId || call.sender
})`]: getHangupCallState(call),
},
}));
}
function onToDeviceEvent(event) {
const eventType = event.getType();
if (
!(
eventType.startsWith("m.call.") ||
eventType.startsWith("org.matrix.call.")
)
) {
return;
}
const content = event.getContent();
if (content.conf_id && content.conf_id !== groupCall.groupCallId) {
return;
}
setToDeviceEvents((prev) => [
...prev,
{ eventType, content, sender: event.getSender() },
]);
}
client.on("RoomState.events", onUpdateRoomState);
groupCall.on("calls_changed", onCallsChanged);
client.on("state", onCallsChanged);
client.on("hangup", onCallHangup);
client.on("toDeviceEvent", onToDeviceEvent);
onUpdateRoomState();
}, [client, groupCall]);
const toDeviceEventsByCall = useMemo(() => {
const result = {};
for (const event of toDeviceEvents) {
const callId = event.content.call_id;
const key = `${callId} (${event.sender})`;
result[key] = result[key] || [];
result[key].push(event);
}
return result;
}, [toDeviceEvents]);
useEffect(() => {
let timeout;
async function updateCallStats() {
const callIds = groupCall.calls.map(
(call) =>
`${call.callId} (${call.getOpponentMember()?.userId || call.sender})`
);
const stats = await Promise.all(
groupCall.calls.map((call) =>
call.peerConn
? call.peerConn
.getStats(null)
.then((stats) =>
Object.fromEntries(
Array.from(stats).map(([_id, report], i) => [
report.type + i,
report,
])
)
)
: Promise.resolve(null)
)
);
const callStats = {};
for (let i = 0; i < groupCall.calls.length; i++) {
callStats[callIds[i]] = stats[i];
}
updateState({ callStats });
timeout = setTimeout(updateCallStats, 1000);
}
if (show) {
updateCallStats();
}
return () => {
clearTimeout(timeout);
};
}, [show]);
if (!show) {
return null;
}
return (
<Resizable enable={{ top: true }} defaultSize={{ height: 200 }}>
<ReactJson
theme="monokai"
src={{
...state,
roomStateEvents,
toDeviceEvents,
toDeviceEventsByCall,
}}
name={null}
indentWidth={2}
collapsed={1}
displayDataTypes={false}
displayObjectSize={false}
enableClipboard={false}
style={{ height: "100%", overflowY: "scroll" }}
/>
</Resizable>
);
}

View File

@@ -6,8 +6,6 @@ import { ReactComponent as Logo } from "./icons/Logo.svg";
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
import { ReactComponent as ArrowLeftIcon } from "./icons/ArrowLeft.svg";
import { useButton } from "@react-aria/button";
import { Subtitle } from "./typography/Typography";
import { Avatar } from "./Avatar";
export function Header({ children, className, ...rest }) {
return (
@@ -17,15 +15,10 @@ export function Header({ children, className, ...rest }) {
);
}
export function LeftNav({ children, className, hideMobile, ...rest }) {
export function LeftNav({ children, className, ...rest }) {
return (
<div
className={classNames(
styles.nav,
styles.leftNav,
{ [styles.hideMobile]: hideMobile },
className
)}
className={classNames(styles.nav, styles.leftNav, className)}
{...rest}
>
{children}
@@ -33,15 +26,10 @@ export function LeftNav({ children, className, hideMobile, ...rest }) {
);
}
export function RightNav({ children, className, hideMobile, ...rest }) {
export function RightNav({ children, className, ...rest }) {
return (
<div
className={classNames(
styles.nav,
styles.rightNav,
{ [styles.hideMobile]: hideMobile },
className
)}
className={classNames(styles.nav, styles.rightNav, className)}
{...rest}
>
{children}
@@ -49,38 +37,32 @@ export function RightNav({ children, className, hideMobile, ...rest }) {
);
}
export function HeaderLogo({ className }) {
export function HeaderLogo() {
return (
<Link className={classNames(styles.headerLogo, className)} to="/">
<Link className={styles.logo} to="/">
<Logo />
</Link>
);
}
export function RoomHeaderInfo({ roomName, avatarUrl }) {
export function RoomHeaderInfo({ roomName }) {
return (
<>
<div className={styles.roomAvatar}>
<Avatar
size="md"
src={avatarUrl}
bgKey={roomName}
fallback={roomName.slice(0, 1).toUpperCase()}
/>
<VideoIcon width={16} height={16} />
</div>
<Subtitle fontWeight="semiBold">{roomName}</Subtitle>
<h3>{roomName}</h3>
</>
);
}
export function RoomSetupHeaderInfo({ roomName, avatarUrl, ...rest }) {
export function RoomSetupHeaderInfo({ roomName, ...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} avatarUrl={avatarUrl} />
<RoomHeaderInfo roomName={roomName} />
</button>
);
}

View File

@@ -16,24 +16,16 @@
height: 64px;
}
.headerLogo {
display: none;
.logo {
display: flex;
align-items: center;
text-decoration: none;
}
.leftNav.hideMobile {
display: none;
}
.leftNav > * {
margin-right: 12px;
}
.leftNav h3 {
margin: 0;
}
.rightNav {
justify-content: flex-end;
}
@@ -42,17 +34,13 @@
margin-right: 24px;
}
.rightNav.hideMobile {
display: none;
}
.nav > :last-child {
margin-right: 0;
}
.roomAvatar {
position: relative;
display: none;
display: flex;
justify-content: center;
align-items: center;
width: 36px;
@@ -70,7 +58,7 @@
background: transparent;
border: none;
display: flex;
color: var(--primary-content);
color: var(--textColor1);
cursor: pointer;
align-items: center;
}
@@ -105,18 +93,7 @@
}
@media (min-width: 800px) {
.headerLogo,
.roomAvatar,
.leftNav.hideMobile,
.rightNav.hideMobile {
display: flex;
}
.leftNav h3 {
font-size: 18px;
}
.nav {
height: 76px;
height: 98px;
}
}

View File

@@ -1,106 +0,0 @@
import React from "react";
import { GridLayoutMenu } from "./room/GridLayoutMenu";
import {
Header,
HeaderLogo,
LeftNav,
RightNav,
RoomHeaderInfo,
} from "./Header";
import { UserMenu } from "./UserMenu";
export default {
title: "Header",
component: Header,
parameters: {
layout: "fullscreen",
},
};
export const HomeAnonymous = () => (
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenu />
</RightNav>
</Header>
);
export const HomeNamedGuest = () => (
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenu isAuthenticated isPasswordlessUser displayName="Yara" />
</RightNav>
</Header>
);
export const HomeLoggedIn = () => (
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenu isAuthenticated displayName="Yara" />
</RightNav>
</Header>
);
export const LobbyNamedGuest = () => (
<Header>
<LeftNav>
<RoomHeaderInfo roomName="Q4Roadmap" />
</LeftNav>
<RightNav>
<UserMenu isAuthenticated isPasswordlessUser displayName="Yara" />
</RightNav>
</Header>
);
export const LobbyLoggedIn = () => (
<Header>
<LeftNav>
<RoomHeaderInfo roomName="Q4Roadmap" />
</LeftNav>
<RightNav>
<UserMenu isAuthenticated displayName="Yara" />
</RightNav>
</Header>
);
export const InRoomNamedGuest = () => (
<Header>
<LeftNav>
<RoomHeaderInfo roomName="Q4Roadmap" />
</LeftNav>
<RightNav>
<GridLayoutMenu layout="freedom" />
<UserMenu isAuthenticated isPasswordlessUser displayName="Yara" />
</RightNav>
</Header>
);
export const InRoomLoggedIn = () => (
<Header>
<LeftNav>
<RoomHeaderInfo roomName="Q4Roadmap" />
</LeftNav>
<RightNav>
<GridLayoutMenu layout="freedom" />
<UserMenu isAuthenticated displayName="Yara" />
</RightNav>
</Header>
);
export const CreateAccount = () => (
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav></RightNav>
</Header>
);

297
src/Home.jsx Normal file
View File

@@ -0,0 +1,297 @@
/*
Copyright 2021 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 React, { useCallback } from "react";
import { useHistory, Link } from "react-router-dom";
import {
useClient,
useGroupCallRooms,
usePublicRooms,
useCreateRoom,
} from "./ConferenceCallManagerHooks";
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
import styles from "./Home.module.css";
import { FieldRow, InputField, ErrorMessage } from "./Input";
import { UserMenu } from "./UserMenu";
import { Button } from "./button";
import { CallList } from "./CallList";
import classNames from "classnames";
import { ErrorView, LoadingView } from "./FullScreenView";
export function Home() {
const { isGuest, isPasswordlessUser, loading, error, client } = useClient();
const history = useHistory();
const { createRoomError, creatingRoom, createRoom } = useCreateRoom();
const onCreateRoom = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const roomName = data.get("roomName");
const userName = data.get("userName");
createRoom(roomName, userName).then((roomIdOrAlias) => {
if (roomIdOrAlias) {
history.push(`/room/${roomIdOrAlias}`);
}
});
},
[history]
);
const onJoinRoom = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const roomId = data.get("roomId");
history.push(`/${roomId}`);
},
[history]
);
if (loading) {
return <LoadingView />;
} else if (error || createRoomError) {
return <ErrorView error={error || createRoomError} />;
} else if (isGuest) {
return (
<UnregisteredView
onCreateRoom={onCreateRoom}
createRoomError={createRoomError}
creatingRoom={creatingRoom}
onJoinRoom={onJoinRoom}
/>
);
} else {
return (
<RegisteredView
client={client}
isPasswordlessUser={isPasswordlessUser}
isGuest={isGuest}
onCreateRoom={onCreateRoom}
createRoomError={createRoomError}
creatingRoom={creatingRoom}
onJoinRoom={onJoinRoom}
/>
);
}
}
function UnregisteredView({
onCreateRoom,
createRoomError,
creatingRoom,
onJoinRoom,
}) {
return (
<div className={classNames(styles.home, styles.fullWidth)}>
<Header className={styles.header}>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenu />
</RightNav>
</Header>
<div className={styles.splitContainer}>
<div className={styles.left}>
<div className={styles.content}>
<div className={styles.centered}>
<form onSubmit={onJoinRoom}>
<h1>Join a call</h1>
<FieldRow className={styles.fieldRow}>
<InputField
id="roomId"
name="roomId"
label="Call ID"
type="text"
required
autoComplete="off"
placeholder="Call ID"
/>
</FieldRow>
<FieldRow className={styles.fieldRow}>
<Button className={styles.button} type="submit">
Join call
</Button>
</FieldRow>
</form>
<hr />
<form onSubmit={onCreateRoom}>
<h1>Create a call</h1>
<FieldRow className={styles.fieldRow}>
<InputField
id="userName"
name="userName"
label="Username"
type="text"
required
autoComplete="off"
placeholder="Username"
/>
</FieldRow>
<FieldRow className={styles.fieldRow}>
<InputField
id="roomName"
name="roomName"
label="Room Name"
type="text"
required
autoComplete="off"
placeholder="Room Name"
/>
</FieldRow>
{createRoomError && (
<FieldRow className={styles.fieldRow}>
<ErrorMessage>{createRoomError.message}</ErrorMessage>
</FieldRow>
)}
<FieldRow className={styles.fieldRow}>
<Button
className={styles.button}
type="submit"
disabled={creatingRoom}
>
{creatingRoom ? "Creating call..." : "Create call"}
</Button>
</FieldRow>
</form>
<div className={styles.authLinks}>
<p>
Not registered yet?{" "}
<Link to="/register">Create an account</Link>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
function RegisteredView({
client,
isPasswordlessUser,
isGuest,
onCreateRoom,
createRoomError,
creatingRoom,
onJoinRoom,
}) {
const publicRooms = usePublicRooms(
client,
import.meta.env.VITE_PUBLIC_SPACE_ROOM_ID
);
const recentRooms = useGroupCallRooms(client);
const hideCallList = publicRooms.length === 0 && recentRooms.length === 0;
return (
<div
className={classNames(styles.home, {
[styles.fullWidth]: hideCallList,
})}
>
<Header className={styles.header}>
<LeftNav className={styles.leftNav}>
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenu />
</RightNav>
</Header>
<div className={styles.splitContainer}>
<div className={styles.left}>
<div className={styles.content}>
<div className={styles.centered}>
<form onSubmit={onJoinRoom}>
<h1>Join a call</h1>
<FieldRow className={styles.fieldRow}>
<InputField
id="roomId"
name="roomId"
label="Call ID"
type="text"
required
autoComplete="off"
placeholder="Call ID"
/>
</FieldRow>
<FieldRow className={styles.fieldRow}>
<Button className={styles.button} type="submit">
Join call
</Button>
</FieldRow>
</form>
<hr />
<form onSubmit={onCreateRoom}>
<h1>Create a call</h1>
<FieldRow className={styles.fieldRow}>
<InputField
id="roomName"
name="roomName"
label="Room Name"
type="text"
required
autoComplete="off"
placeholder="Room Name"
/>
</FieldRow>
{createRoomError && (
<FieldRow className={styles.fieldRow}>
<ErrorMessage>{createRoomError.message}</ErrorMessage>
</FieldRow>
)}
<FieldRow className={styles.fieldRow}>
<Button
className={styles.button}
type="submit"
disabled={creatingRoom}
>
{creatingRoom ? "Creating call..." : "Create call"}
</Button>
</FieldRow>
</form>
{(isPasswordlessUser || isGuest) && (
<div className={styles.authLinks}>
<p>
Not registered yet?{" "}
<Link to="/register">Create an account</Link>
</p>
</div>
)}
</div>
</div>
</div>
{!hideCallList && (
<div className={styles.right}>
<div className={styles.content}>
{publicRooms.length > 0 && (
<CallList title="Public Calls" rooms={publicRooms} />
)}
{recentRooms.length > 0 && (
<CallList title="Recent Calls" rooms={recentRooms} />
)}
</div>
</div>
)}
</div>
</div>
);
}

140
src/Home.module.css Normal file
View File

@@ -0,0 +1,140 @@
.home {
display: flex;
flex: 1;
flex-direction: column;
min-height: 100%;
}
.splitContainer {
display: flex;
flex: 1;
flex-direction: column;
}
.left,
.right {
display: flex;
flex-direction: column;
flex: 1;
}
.fullWidth {
background-color: var(--bgColor1);
}
.fullWidth .header {
background-color: var(--bgColor1);
}
.centered {
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
max-width: 512px;
min-width: 0;
}
.content {
flex: 1;
}
.left .content {
display: flex;
flex-direction: column;
padding-top: 40px;
align-items: center;
}
.left .content form > * {
margin-top: 0;
margin-bottom: 24px;
}
.left .content form > :last-child {
margin-bottom: 0;
}
.left .content hr:after {
background-color: var(--bgColor1);
content: "OR";
padding: 0 12px;
position: relative;
top: -12px;
}
.left .content form {
display: flex;
flex-direction: column;
align-items: center;
padding: 92px;
}
.fieldRow {
width: 100%;
}
.button {
height: 40px;
width: 100%;
font-size: 15px;
font-weight: 600;
}
.left .content form:first-child {
padding-top: 0;
}
.left .content form:last-child {
padding-bottom: 40px;
}
.right .content {
padding: 40px;
}
.right .content h3:first-child {
margin-top: 0;
}
.authLinks {
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
}
.authLinks {
margin-bottom: 100px;
font-size: 15px;
}
.authLinks a {
color: #0dbd8b;
font-weight: normal;
text-decoration: none;
}
@media (min-width: 800px) {
.left {
background-color: var(--bgColor2);
}
.home:not(.fullWidth) .left {
max-width: 50%;
}
.home:not(.fullWidth) .leftNav {
background-color: var(--bgColor2);
}
.splitContainer {
flex-direction: row;
}
.fullWidth .content hr:after,
.left .content hr:after,
.fullWidth .header {
background-color: var(--bgColor2);
}
}

View File

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

49
src/Input.jsx Normal file
View File

@@ -0,0 +1,49 @@
import React, { forwardRef } from "react";
import classNames from "classnames";
import styles from "./Input.module.css";
import { ReactComponent as CheckIcon } from "./icons/Check.svg";
export function FieldRow({ children, rightAlign, className, ...rest }) {
return (
<div
className={classNames(
styles.fieldRow,
{ [styles.rightAlign]: rightAlign },
className
)}
>
{children}
</div>
);
}
export function Field({ children, className, ...rest }) {
return <div className={classNames(styles.field, className)}>{children}</div>;
}
export const InputField = forwardRef(
({ id, label, className, type, checked, ...rest }, ref) => {
return (
<Field
className={classNames(
type === "checkbox" ? styles.checkboxField : styles.inputField,
className
)}
>
<input id={id} {...rest} ref={ref} type={type} checked={checked} />
<label htmlFor={id}>
{type === "checkbox" && (
<div className={styles.checkbox}>
<CheckIcon />
</div>
)}
{label}
</label>
</Field>
);
}
);
export function ErrorMessage({ children }) {
return <p className={styles.errorMessage}>{children}</p>;
}

View File

@@ -1,7 +1,6 @@
.fieldRow {
display: flex;
margin-bottom: 32px;
align-items: center;
}
.field {
@@ -26,52 +25,35 @@
.inputField {
border-radius: 4px;
transition: border-color 0.25s;
border: 1px solid var(--quinary-content);
border: 1px solid var(--inputBorderColor);
}
.inputField input,
.inputField textarea {
.inputField input {
font-weight: 400;
font-size: 15px;
border: none;
border-radius: 4px;
padding: 12px 9px 10px 9px;
color: var(--primary-content);
background-color: var(--background);
padding: 11px 9px;
color: var(--textColor1);
background-color: var(--bgColor1);
flex: 1;
min-width: 0;
}
.inputField.disabled input,
.inputField.disabled textarea,
.inputField.disabled span {
color: var(--quaternary-content);
}
.inputField span {
padding: 11px 9px;
}
.inputField span:first-child {
padding-right: 0;
}
.inputField input::placeholder,
.inputField textarea::placeholder {
.inputField input::placeholder {
transition: color 0.25s ease-in 0s;
color: transparent;
}
.inputField input:placeholder-shown:focus::placeholder,
.inputField textarea:placeholder-shown:focus::placeholder {
.inputField input:placeholder-shown:focus::placeholder {
transition: color 0.25s ease-in 0.1s;
color: var(--quaternary-content);
color: var(--textColor2);
}
.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(--tertiary-content);
color: var(--textColor3);
background-color: transparent;
font-size: 15px;
position: absolute;
@@ -87,21 +69,16 @@
}
.inputField:focus-within {
border-color: var(--links);
border-color: var(--inputBorderColorFocused);
}
.inputField input:focus,
.inputField textarea:focus {
.inputField input:focus {
outline: 0;
}
.inputField input:focus + label,
.inputField input:not(:placeholder-shown) + label,
.inputField.prefix input + label,
.inputField textarea:focus + label,
.inputField textarea:not(:placeholder-shown) + label,
.inputField.prefix textarea + label {
background-color: var(--system);
.inputField input:not(:placeholder-shown) + label {
background-color: var(--bgColor2);
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;
@@ -110,23 +87,20 @@
pointer-events: auto;
}
.inputField input:focus + label,
.inputField textarea:focus + label {
color: var(--links);
.inputField input:focus + label {
color: var(--inputBorderColorFocused);
}
.checkboxField {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
}
.checkboxField label {
display: flex;
align-items: center;
flex-grow: 1;
font-size: 15px;
line-height: 24px;
font-size: 13px;
}
.checkboxField input {
@@ -156,12 +130,12 @@
}
.checkbox svg * {
stroke: var(--primary-content);
stroke: #fff;
}
.checkboxField input[type="checkbox"]:checked + label > .checkbox {
background: var(--accent);
border-color: var(--accent);
background: var(--primaryColor);
border-color: var(--primaryColor);
}
.checkboxField input[type="checkbox"]:checked + label > .checkbox svg {
@@ -169,18 +143,12 @@
}
.checkboxField:focus-within .checkbox {
border: 1.5px solid var(--links) !important;
border: 1.5px solid var(--inputBorderColorFocused) !important;
}
.errorMessage {
margin: 0;
font-size: 13px;
color: var(--alert);
color: #ff5b55;
font-weight: 600;
}
.description {
color: var(--secondary-content);
margin-left: 26px;
width: 100%; /* Ensure that it breaks onto the next row */
}

21
src/InviteModal.jsx Normal file
View File

@@ -0,0 +1,21 @@
import React from "react";
import { Modal, ModalContent } from "./Modal";
import { CopyButton } from "./button";
import { getRoomUrl } from "./ConferenceCallManagerHooks";
import styles from "./InviteModal.module.css";
export function InviteModal({ roomId, ...rest }) {
return (
<Modal
title="Invite People"
isDismissable
className={styles.inviteModal}
{...rest}
>
<ModalContent>
<p>Copy and share this meeting link</p>
<CopyButton className={styles.copyButton} value={getRoomUrl(roomId)} />
</ModalContent>
</Modal>
);
}

View File

@@ -5,8 +5,8 @@
overflow-y: auto;
list-style: none;
background-color: transparent;
border: 1px solid var(--quinary-content);
background-color: var(--background);
border: 1px solid var(--inputBorderColor);
background-color: var(--bgColor1);
border-radius: 8px;
}
@@ -15,7 +15,7 @@
align-items: center;
justify-content: space-between;
background-color: transparent;
color: var(--primary-content);
color: var(--textColor1);
padding: 8px 16px;
outline: none;
cursor: pointer;
@@ -23,11 +23,15 @@
min-height: 32px;
}
.option.selected {
color: #0dbd8b;
}
.option.focused {
background-color: rgba(111, 120, 130, 0.2);
}
.option.disabled {
color: var(--quaternary-content);
color: var(--textColor2);
background-color: var(--bgColor3);
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2021-2022 New Vector Ltd
Copyright 2021 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,49 +14,35 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {
FC,
FormEvent,
useCallback,
useRef,
useState,
useMemo,
} from "react";
import React, { useCallback, useRef, useState } 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";
import { ReactComponent as Logo } from "./icons/LogoLarge.svg";
import { FieldRow, InputField, ErrorMessage } from "./Input";
import { Button } from "./button";
import { useClient } from "./ConferenceCallManagerHooks";
import styles from "./LoginPage.module.css";
import { useInteractiveLogin } from "./useInteractiveLogin";
import { usePageTitle } from "../usePageTitle";
export const LoginPage: FC = () => {
usePageTitle("Login");
const { setClient } = useClient();
const login = useInteractiveLogin();
const homeserver = defaultHomeserver; // TODO: Make this configurable
const usernameRef = useRef<HTMLInputElement>();
const passwordRef = useRef<HTMLInputElement>();
export function LoginPage() {
const { login } = useClient();
const [homeserver, setHomeServer] = useState(
`${window.location.protocol}//${window.location.host}`
);
const usernameRef = useRef();
const passwordRef = useRef();
const history = useHistory();
const location = useLocation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>();
const [error, setError] = useState();
// TODO: Handle hitting login page with authenticated client
const onSubmitLoginForm = useCallback(
(e: FormEvent<HTMLFormElement>) => {
(e) => {
e.preventDefault();
setLoading(true);
login(homeserver, usernameRef.current.value, passwordRef.current.value)
.then(([client, session]) => {
setClient(client, session);
.then(() => {
if (location.state && location.state.from) {
history.push(location.state.from);
} else {
@@ -68,17 +54,9 @@ export const LoginPage: FC = () => {
setLoading(false);
});
},
[login, location, history, homeserver, setClient]
[login, location, history, homeserver]
);
const homeserverHost = useMemo(() => {
try {
return new URL(homeserver).host;
} catch (error) {
return defaultHomeserverHost;
}
}, [homeserver]);
return (
<>
<div className={styles.container}>
@@ -89,6 +67,17 @@ export const LoginPage: FC = () => {
<h2>Log In</h2>
<h4>To continue to Element</h4>
<form onSubmit={onSubmitLoginForm}>
<FieldRow>
<InputField
type="text"
value={homeserver}
onChange={(e) => setHomeServer(e.target.value)}
placeholder="Homeserver"
label="Homeserver"
autoCorrect="off"
autoCapitalize="none"
/>
</FieldRow>
<FieldRow>
<InputField
type="text"
@@ -97,8 +86,6 @@ export const LoginPage: FC = () => {
label="Username"
autoCorrect="off"
autoCapitalize="none"
prefix="@"
suffix={`:${homeserverHost}`}
/>
</FieldRow>
<FieldRow>
@@ -133,4 +120,4 @@ export const LoginPage: FC = () => {
</div>
</>
);
};
}

View File

@@ -1,7 +1,6 @@
.logo {
max-width: 300px;
margin: 80px 0;
height: auto;
}
.container {
@@ -65,7 +64,7 @@
}
.authLinks a {
color: var(--accent);
color: #0dbd8b;
text-decoration: none;
font-weight: normal;
}

View File

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

View File

@@ -52,12 +52,6 @@
}
@media (max-width: 799px) {
.modalHeader {
display: flex;
justify-content: space-between;
padding: 24px 24px 0 24px;
}
.modal.mobileFullScreen {
position: fixed;
left: 0;

70
src/OverflowMenu.jsx Normal file
View File

@@ -0,0 +1,70 @@
import React, { useCallback } from "react";
import { ButtonTooltip, Button } from "./button";
import { Menu } from "./Menu";
import { PopoverMenuTrigger } from "./PopoverMenu";
import { Item } from "@react-stately/collections";
import { ReactComponent as SettingsIcon } from "./icons/Settings.svg";
import { ReactComponent as AddUserIcon } from "./icons/AddUser.svg";
import { ReactComponent as OverflowIcon } from "./icons/Overflow.svg";
import { useModalTriggerState } from "./Modal";
import { SettingsModal } from "./SettingsModal";
import { InviteModal } from "./InviteModal";
export function OverflowMenu({
roomId,
setShowInspector,
showInspector,
client,
}) {
const { modalState: inviteModalState, modalProps: inviteModalProps } =
useModalTriggerState();
const { modalState: settingsModalState, modalProps: settingsModalProps } =
useModalTriggerState();
// TODO: On closing modal, focus should be restored to the trigger button
// https://github.com/adobe/react-spectrum/issues/2444
const onAction = useCallback((key) => {
switch (key) {
case "invite":
inviteModalState.open();
break;
case "settings":
settingsModalState.open();
break;
}
});
return (
<>
<PopoverMenuTrigger disableOnState>
<Button variant="toolbar">
<ButtonTooltip>More</ButtonTooltip>
<OverflowIcon />
</Button>
{(props) => (
<Menu {...props} label="More menu" onAction={onAction}>
<Item key="invite" textValue="Invite people">
<AddUserIcon />
<span>Invite people</span>
</Item>
<Item key="settings" textValue="Settings">
<SettingsIcon />
<span>Settings</span>
</Item>
</Menu>
)}
</PopoverMenuTrigger>
{settingsModalState.isOpen && (
<SettingsModal
{...settingsModalProps}
setShowInspector={setShowInspector}
showInspector={showInspector}
client={client}
/>
)}
{inviteModalState.isOpen && (
<InviteModal roomId={roomId} {...inviteModalProps} />
)}
</>
);
}

View File

@@ -1,29 +1,13 @@
/*
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, { forwardRef, useRef } from "react";
import { DismissButton, useOverlay } from "@react-aria/overlays";
import { FocusScope } from "@react-aria/focus";
import classNames from "classnames";
import styles from "./Popover.module.css";
import { useObjectRef } from "@react-aria/utils";
export const Popover = forwardRef(
({ isOpen = true, onClose, className, children, ...rest }, ref) => {
const popoverRef = useObjectRef(ref);
const fallbackRef = useRef();
const popoverRef = ref || fallbackRef;
const { overlayProps } = useOverlay(
{

View File

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

72
src/PopoverMenu.jsx Normal file
View File

@@ -0,0 +1,72 @@
import React, { useRef } from "react";
import styles from "./PopoverMenu.module.css";
import { useMenuTriggerState } from "@react-stately/menu";
import { useMenuTrigger } from "@react-aria/menu";
import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays";
import classNames from "classnames";
import { Popover } from "./Popover";
export function PopoverMenuTrigger({
children,
placement,
className,
disableOnState,
...rest
}) {
const popoverMenuState = useMenuTriggerState(rest);
const buttonRef = useRef();
const { menuTriggerProps, menuProps } = useMenuTrigger(
{},
popoverMenuState,
buttonRef
);
const popoverRef = useRef();
const { overlayProps } = useOverlayPosition({
targetRef: buttonRef,
overlayRef: popoverRef,
placement: placement || "top",
offset: 5,
isOpen: popoverMenuState.isOpen,
});
if (
!Array.isArray(children) ||
children.length > 2 ||
typeof children[1] !== "function"
) {
throw new Error(
"PopoverMenu must have two props. The first being a button and the second being a render prop."
);
}
const [popoverTrigger, popoverMenu] = children;
return (
<div className={classNames(styles.popoverMenuTrigger, className)}>
<popoverTrigger.type
{...popoverTrigger.props}
{...menuTriggerProps}
on={!disableOnState && popoverMenuState.isOpen}
ref={buttonRef}
/>
{popoverMenuState.isOpen && (
<OverlayContainer>
<Popover
{...overlayProps}
isOpen={popoverMenuState.isOpen}
onClose={popoverMenuState.close}
ref={popoverRef}
>
{popoverMenu({
...menuProps,
autoFocus: popoverMenuState.focusStrategy,
onClose: popoverMenuState.close,
})}
</Popover>
</OverlayContainer>
)}
</div>
);
}

76
src/ProfileModal.jsx Normal file
View File

@@ -0,0 +1,76 @@
import React, { useCallback, useEffect, useState } from "react";
import { Button } from "./button";
import { useDisplayName } from "./ConferenceCallManagerHooks";
import { FieldRow, InputField, ErrorMessage } from "./Input";
import { Modal, ModalContent } from "./Modal";
export function ProfileModal({ client, ...rest }) {
const { onClose } = rest;
const {
success,
error,
loading,
displayName: initialDisplayName,
setDisplayName: submitDisplayName,
} = useDisplayName(client);
const [displayName, setDisplayName] = useState(initialDisplayName || "");
const onChangeDisplayName = useCallback(
(e) => {
setDisplayName(e.target.value);
},
[setDisplayName]
);
const onSubmit = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const displayName = data.get("displayName");
console.log(displayName);
submitDisplayName(displayName);
},
[setDisplayName]
);
useEffect(() => {
if (success) {
onClose();
}
}, [success, onClose]);
return (
<Modal title="Profile" isDismissable {...rest}>
<ModalContent>
<form onSubmit={onSubmit}>
<FieldRow>
<InputField
id="displayName"
name="displayName"
label="Display Name"
type="text"
required
autoComplete="off"
placeholder="Display Name"
value={displayName}
onChange={onChangeDisplayName}
/>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
</FieldRow>
)}
<FieldRow rightAlign>
<Button type="button" variant="secondary" onPress={onClose}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Saving..." : "Save"}
</Button>
</FieldRow>
</form>
</ModalContent>
</Modal>
);
}

108
src/RegisterPage.jsx Normal file
View File

@@ -0,0 +1,108 @@
/*
Copyright 2021 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 React, { useCallback, useRef, useState } from "react";
import { useHistory, useLocation, Link } from "react-router-dom";
import { FieldRow, InputField, ErrorMessage } from "./Input";
import { Button } from "./button";
import { useClient } from "./ConferenceCallManagerHooks";
import styles from "./LoginPage.module.css";
import { ReactComponent as Logo } from "./icons/LogoLarge.svg";
export function RegisterPage() {
// TODO: Handle hitting login page with authenticated client
const { register } = useClient();
const registerUsernameRef = useRef();
const registerPasswordRef = useRef();
const history = useHistory();
const location = useLocation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const onSubmitRegisterForm = useCallback(
(e) => {
e.preventDefault();
setLoading(true);
register(
registerUsernameRef.current.value,
registerPasswordRef.current.value
)
.then(() => {
if (location.state && location.state.from) {
history.push(location.state.from);
} else {
history.push("/");
}
})
.catch((error) => {
setError(error);
setLoading(false);
});
},
[register, location, history]
);
return (
<>
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.formContainer}>
<Logo width="auto" height="auto" className={styles.logo} />
<h2>Create your account</h2>
<form onSubmit={onSubmitRegisterForm}>
<FieldRow>
<InputField
type="text"
ref={registerUsernameRef}
placeholder="Username"
label="Username"
autoCorrect="off"
autoCapitalize="none"
/>
</FieldRow>
<FieldRow>
<InputField
type="password"
ref={registerPasswordRef}
placeholder="Password"
label="Password"
/>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
</FieldRow>
)}
<FieldRow>
<Button type="submit" disabled={loading}>
{loading ? "Registering..." : "Register"}
</Button>
</FieldRow>
</form>
</div>
<div className={styles.authLinks}>
<p>Already have an account?</p>
<p>
<Link to="/login">Log in</Link>
{" Or "}
<Link to="/">Access as a guest</Link>
</p>
</div>
</div>
</div>
</>
);
}

495
src/Room.jsx Normal file
View File

@@ -0,0 +1,495 @@
/*
Copyright 2021 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 React, { useCallback, useEffect, useMemo, useState } from "react";
import styles from "./Room.module.css";
import { useLocation, useParams, useHistory, Link } from "react-router-dom";
import {
Button,
CopyButton,
HangupButton,
MicButton,
VideoButton,
ScreenshareButton,
LinkButton,
} from "./button";
import {
Header,
LeftNav,
RightNav,
RoomHeaderInfo,
RoomSetupHeaderInfo,
} from "./Header";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import VideoGrid, {
useVideoGridLayout,
} from "matrix-react-sdk/src/components/views/voip/GroupCallView/VideoGrid";
import SimpleVideoGrid from "matrix-react-sdk/src/components/views/voip/GroupCallView/SimpleVideoGrid";
import "matrix-react-sdk/res/css/views/voip/GroupCallView/_VideoGrid.scss";
import { useGroupCall } from "matrix-react-sdk/src/hooks/useGroupCall";
import { useCallFeed } from "matrix-react-sdk/src/hooks/useCallFeed";
import { useMediaStream } from "matrix-react-sdk/src/hooks/useMediaStream";
import { useClient, useLoadGroupCall } from "./ConferenceCallManagerHooks";
import { ErrorView, LoadingView, FullScreenView } from "./FullScreenView";
import { GroupCallInspector } from "./GroupCallInspector";
import * as Sentry from "@sentry/react";
import { OverflowMenu } from "./OverflowMenu";
import { GridLayoutMenu } from "./GridLayoutMenu";
import { UserMenu } from "./UserMenu";
import classNames from "classnames";
const canScreenshare = "getDisplayMedia" in navigator.mediaDevices;
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
// or with getUsermedia and getDisplaymedia being used within the same session.
// For now we can disable screensharing in Safari.
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
export function Room() {
const { loading, error, client, isGuest } = useClient();
if (loading) {
return <LoadingView />;
}
if (error) {
return <ErrorView error={error} />;
}
return <GroupCall client={client} isGuest={isGuest} />;
}
export function GroupCall({ client, isGuest }) {
const { roomId: maybeRoomId } = useParams();
const { hash, search } = useLocation();
const [simpleGrid, viaServers] = useMemo(() => {
const params = new URLSearchParams(search);
return [params.has("simple"), params.getAll("via")];
}, [search]);
const roomId = maybeRoomId || hash;
const { loading, error, groupCall } = useLoadGroupCall(
client,
roomId,
viaServers
);
useEffect(() => {
window.groupCall = groupCall;
}, [groupCall]);
if (loading) {
return <LoadingRoomView />;
}
if (error) {
return <ErrorView error={error} />;
}
return (
<GroupCallView
isGuest={isGuest}
client={client}
roomId={roomId}
groupCall={groupCall}
simpleGrid={simpleGrid}
/>
);
}
export function GroupCallView({
client,
isGuest,
roomId,
groupCall,
simpleGrid,
}) {
const [showInspector, setShowInspector] = useState(false);
const {
state,
error,
activeSpeaker,
userMediaFeeds,
microphoneMuted,
localVideoMuted,
localCallFeed,
initLocalCallFeed,
enter,
leave,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
toggleScreensharing,
isScreensharing,
localScreenshareFeed,
screenshareFeeds,
hasLocalParticipant,
} = useGroupCall(groupCall);
useEffect(() => {
function onHangup(call) {
if (call.hangupReason === "ice_failed") {
Sentry.captureException(new Error("Call hangup due to ICE failure."));
}
}
function onError(error) {
Sentry.captureException(error);
}
if (groupCall) {
groupCall.on("hangup", onHangup);
groupCall.on("error", onError);
}
return () => {
if (groupCall) {
groupCall.removeListener("hangup", onHangup);
groupCall.removeListener("error", onError);
}
};
}, [groupCall]);
const [left, setLeft] = useState(false);
const history = useHistory();
const onLeave = useCallback(() => {
leave();
if (!isGuest) {
history.push("/");
} else {
setLeft(true);
}
}, [leave, history, isGuest]);
if (error) {
return <ErrorView error={error} />;
} else if (state === GroupCallState.Entered) {
return (
<InRoomView
groupCall={groupCall}
client={client}
roomName={groupCall.room.name}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
userMediaFeeds={userMediaFeeds}
activeSpeaker={activeSpeaker}
onLeave={onLeave}
toggleScreensharing={toggleScreensharing}
isScreensharing={isScreensharing}
localScreenshareFeed={localScreenshareFeed}
screenshareFeeds={screenshareFeeds}
simpleGrid={simpleGrid}
setShowInspector={setShowInspector}
showInspector={showInspector}
roomId={roomId}
/>
);
} else if (state === GroupCallState.Entering) {
return <EnteringRoomView />;
} else if (left) {
return <CallEndedScreen />;
} else {
return (
<RoomSetupView
isGuest={isGuest}
client={client}
hasLocalParticipant={hasLocalParticipant}
roomName={groupCall.room.name}
state={state}
onInitLocalCallFeed={initLocalCallFeed}
localCallFeed={localCallFeed}
onEnter={enter}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
setShowInspector={setShowInspector}
showInspector={showInspector}
roomId={roomId}
/>
);
}
}
export function LoadingRoomView() {
return (
<FullScreenView>
<h1>Loading room...</h1>
</FullScreenView>
);
}
export function EnteringRoomView() {
return (
<FullScreenView>
<h1>Entering room...</h1>
</FullScreenView>
);
}
function RoomSetupView({
client,
isGuest,
roomName,
state,
onInitLocalCallFeed,
onEnter,
localCallFeed,
microphoneMuted,
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
setShowInspector,
showInspector,
roomId,
}) {
const { stream } = useCallFeed(localCallFeed);
const videoRef = useMediaStream(stream, true);
const location = useLocation();
useEffect(() => {
onInitLocalCallFeed();
}, [onInitLocalCallFeed]);
return (
<div className={styles.room}>
<Header>
<LeftNav>
<RoomHeaderInfo roomName={roomName} />
</LeftNav>
<RightNav>
{isGuest ? (
<LinkButton to={{ pathname: "/login", state: { from: location } }}>
Log in
</LinkButton>
) : (
<UserMenu />
)}
</RightNav>
</Header>
<div className={styles.joinRoom}>
<div className={styles.joinRoomContent}>
<h1>{roomName}</h1>
<div className={styles.preview}>
{state === GroupCallState.LocalCallFeedUninitialized && (
<p className={styles.webcamPermissions}>
Webcam/microphone permissions needed to join the call.
</p>
)}
{state === GroupCallState.InitializingLocalCallFeed && (
<p className={styles.webcamPermissions}>
Accept webcam/microphone permissions to join the call.
</p>
)}
<video ref={videoRef} muted playsInline disablePictureInPicture />
{state === GroupCallState.LocalCallFeedInitialized && (
<>
<Button
className={styles.joinCallButton}
disabled={state !== GroupCallState.LocalCallFeedInitialized}
onPress={onEnter}
>
Join call now
</Button>
<div className={styles.previewButtons}>
<MicButton
muted={microphoneMuted}
onPress={toggleMicrophoneMuted}
/>
<VideoButton
muted={localVideoMuted}
onPress={toggleLocalVideoMuted}
/>
<OverflowMenu
roomId={roomId}
setShowInspector={setShowInspector}
showInspector={showInspector}
client={client}
/>
</div>
</>
)}
</div>
<p>Or</p>
<CopyButton
value={window.location.href}
className={styles.copyButton}
copiedMessage="Call link copied"
>
Copy call link and join later
</CopyButton>
</div>
<div className={styles.joinRoomFooter}>
<Link className={styles.homeLink} to="/">
Take me Home
</Link>
</div>
</div>
</div>
);
}
function InRoomView({
client,
groupCall,
roomName,
microphoneMuted,
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
userMediaFeeds,
activeSpeaker,
onLeave,
toggleScreensharing,
isScreensharing,
screenshareFeeds,
simpleGrid,
setShowInspector,
showInspector,
roomId,
}) {
const [layout, setLayout] = useVideoGridLayout();
const items = useMemo(() => {
const participants = [];
for (const callFeed of userMediaFeeds) {
participants.push({
id: callFeed.stream.id,
usermediaCallFeed: callFeed,
isActiveSpeaker:
screenshareFeeds.length === 0
? callFeed.userId === activeSpeaker
: false,
});
}
for (const callFeed of screenshareFeeds) {
const participant = participants.find(
(p) => p.usermediaCallFeed.userId === callFeed.userId
);
participant.screenshareCallFeed = callFeed;
}
return participants;
}, [userMediaFeeds, activeSpeaker, screenshareFeeds]);
const onFocusTile = useCallback(
(tiles, focusedTile) => {
if (layout === "freedom") {
return tiles.map((tile) => {
if (tile === focusedTile) {
return { ...tile, presenter: !tile.presenter };
}
return tile;
});
} else {
setLayout("spotlight");
return tiles.map((tile) => {
if (tile === focusedTile) {
return { ...tile, presenter: true };
}
return { ...tile, presenter: false };
});
}
},
[layout, setLayout]
);
return (
<div className={classNames(styles.room, styles.inRoom)}>
<Header>
<LeftNav>
<RoomHeaderInfo roomName={roomName} />
</LeftNav>
<RightNav>
<GridLayoutMenu layout={layout} setLayout={setLayout} />
<UserMenu />
</RightNav>
</Header>
{items.length === 0 ? (
<div className={styles.centerMessage}>
<p>Waiting for other participants...</p>
</div>
) : simpleGrid ? (
<SimpleVideoGrid items={items} />
) : (
<VideoGrid
items={items}
layout={layout}
onFocusTile={onFocusTile}
disableAnimations={isSafari}
/>
)}
<div className={styles.footer}>
<MicButton muted={microphoneMuted} onPress={toggleMicrophoneMuted} />
<VideoButton muted={localVideoMuted} onPress={toggleLocalVideoMuted} />
{canScreenshare && !isSafari && (
<ScreenshareButton
enabled={isScreensharing}
onPress={toggleScreensharing}
/>
)}
<OverflowMenu
roomId={roomId}
setShowInspector={setShowInspector}
showInspector={showInspector}
client={client}
/>
<HangupButton onPress={onLeave} />
</div>
<GroupCallInspector
client={client}
groupCall={groupCall}
show={showInspector}
/>
</div>
);
}
export function CallEndedScreen() {
return (
<FullScreenView className={styles.callEndedScreen}>
<h1>Your call is now ended</h1>
<hr />
<h2>Why not finish by creating an account?</h2>
<p>You'll be able to:</p>
<ul>
<li>Easily access all your previous call links</li>
<li>Set a username and avatar</li>
<li>
Get your own Matrix ID so you can log in to{" "}
<a href="https://element.io" target="_blank">
Element
</a>
</li>
</ul>
<LinkButton
className={styles.callEndedButton}
size="lg"
variant="default"
to="/login"
>
Create account
</LinkButton>
<Link to="/">Not now, return to home screen</Link>
</FullScreenView>
);
}

203
src/Room.module.css Normal file
View File

@@ -0,0 +1,203 @@
/*
Copyright 2021 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.
*/
.room {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 100%;
}
.inRoom {
position: fixed;
height: 100%;
width: 100%;
}
.joinRoom {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
overflow: hidden;
height: 100%;
}
.joinRoomContent {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.joinRoomContent h1 {
display: none;
margin: 0;
}
.joinRoomFooter {
margin: 20px 0;
}
.homeLink {
margin-top: 50px;
color: #0dbd8b;
text-decoration: none;
font-weight: normal;
font-size: 15px;
}
.preview {
position: relative;
max-width: 816px;
max-height: 75vh;
border-radius: 24px;
overflow: hidden;
background-color: var(--bgColor3);
margin: 40px 20px 20px 20px;
}
.preview video {
width: 100%;
height: 100%;
object-fit: contain;
background-color: black;
transform: scaleX(-1);
}
.webcamPermissions {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
font-size: 13px;
font-weight: 600;
text-align: center;
}
.previewButtons {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 66px;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(23, 25, 28, 0.9);
}
.joinCallButton {
position: absolute;
width: 100%;
max-width: 222px;
height: 40px;
bottom: 86px;
left: 50%;
font-weight: 600;
font-size: 15px;
transform: translateX(-50%);
}
.copyButton {
width: 320px !important;
}
.previewButtons > * {
margin-right: 30px;
}
.previewButtons > :last-child {
margin-right: 0px;
}
.centerMessage {
display: flex;
flex: 1;
justify-content: center;
align-items: center;
flex-direction: column;
}
.centerMessage p {
display: block;
margin-bottom: 0;
}
.roomContainer {
overflow: hidden;
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-height: 0;
}
.footer {
position: relative;
display: flex;
justify-content: center;
align-items: center;
height: 64px;
}
.footer > * {
margin-right: 30px;
}
.footer > :last-child {
margin-right: 0px;
}
.callEndedScreen h1 {
margin-bottom: 60px;
}
.callEndedScreen h2 {
font-size: 24px;
font-weight: 600;
margin-bottom: 32px;
}
.callEndedScreen p {
margin: 0 0 16px 0;
}
.callEndedScreen ul {
padding: 0;
margin-bottom: 40px;
}
.callEndedButton {
width: 100%;
max-width: 360px;
}
@media (min-width: 800px) {
.roomContainer {
flex-direction: row;
}
.footer {
height: 118px;
}
.joinRoomContent h1 {
display: block;
}
}

View File

@@ -1,28 +1,12 @@
/*
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, { useRef } from "react";
import { HiddenSelect, useSelect } from "@react-aria/select";
import { useButton } from "@react-aria/button";
import { useSelectState } from "@react-stately/select";
import { Popover } from "../popover/Popover";
import { ListBox } from "../ListBox";
import { Popover } from "./Popover";
import { ListBox } from "./ListBox";
import styles from "./SelectInput.module.css";
import classNames from "classnames";
import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
import { ReactComponent as ArrowDownIcon } from "./icons/ArrowDown.svg";
export function SelectInput(props) {
const state = useSelectState(props);

View File

@@ -17,20 +17,16 @@
align-items: center;
justify-content: space-between;
padding: 0 12px;
background-color: var(--background);
background-color: var(--bgColor1);
border-radius: 8px;
border: 1px solid var(--quinary-content);
border: 1px solid var(--inputBorderColor);
font-size: 15px;
color: var(--primary-content);
color: var(--textColor1);
height: 40px;
max-width: 100%;
width: 100%;
}
.selectTrigger:focus {
outline: auto;
}
.selectedItem {
white-space: nowrap;
overflow: hidden;

View File

@@ -1,41 +0,0 @@
import React, { useCallback, useState } from "react";
import { SequenceDiagramViewer } from "./room/GroupCallInspector";
import { FieldRow, InputField } from "./input/Input";
import { usePageTitle } from "./usePageTitle";
export function SequenceDiagramViewerPage() {
usePageTitle("Inspector");
const [debugLog, setDebugLog] = useState();
const [selectedUserId, setSelectedUserId] = useState();
const onChangeDebugLog = useCallback((e) => {
if (e.target.files && e.target.files.length > 0) {
e.target.files[0].text().then((text) => {
setDebugLog(JSON.parse(text));
});
}
}, []);
return (
<div style={{ marginTop: 20 }}>
<FieldRow>
<InputField
type="file"
id="debugLog"
name="debugLog"
label="Debug Log"
onChange={onChangeDebugLog}
/>
</FieldRow>
{debugLog && (
<SequenceDiagramViewer
localUserId={debugLog.localUserId}
selectedUserId={selectedUserId}
onSelectUserId={setSelectedUserId}
remoteUserIds={debugLog.remoteUserIds}
events={debugLog.eventsByUserId[selectedUserId]}
/>
)}
</div>
);
}

95
src/SettingsModal.jsx Normal file
View File

@@ -0,0 +1,95 @@
import React from "react";
import { Modal } from "./Modal";
import styles from "./SettingsModal.module.css";
import { TabContainer, TabItem } from "./Tabs";
import { ReactComponent as AudioIcon } from "./icons/Audio.svg";
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
import { ReactComponent as DeveloperIcon } from "./icons/Developer.svg";
import { SelectInput } from "./SelectInput";
import { Item } from "@react-stately/collections";
import { useMediaHandler } from "./useMediaHandler";
import { FieldRow, InputField } from "./Input";
export function SettingsModal({
client,
setShowInspector,
showInspector,
...rest
}) {
const {
audioInput,
audioInputs,
setAudioInput,
videoInput,
videoInputs,
setVideoInput,
} = useMediaHandler(client);
return (
<Modal
title="Settings"
isDismissable
mobileFullScreen
className={styles.settingsModal}
{...rest}
>
<TabContainer className={styles.tabContainer}>
<TabItem
title={
<>
<AudioIcon width={16} height={16} />
<span>Audio</span>
</>
}
>
<SelectInput
label="Microphone"
selectedKey={audioInput}
onSelectionChange={setAudioInput}
>
{audioInputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
))}
</SelectInput>
</TabItem>
<TabItem
title={
<>
<VideoIcon width={16} height={16} />
<span>Video</span>
</>
}
>
<SelectInput
label="Webcam"
selectedKey={videoInput}
onSelectionChange={setVideoInput}
>
{videoInputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
))}
</SelectInput>
</TabItem>
<TabItem
title={
<>
<DeveloperIcon width={16} height={16} />
<span>Developer</span>
</>
}
>
<FieldRow>
<InputField
id="showInspector"
name="inspector"
label="Show Call Inspector"
type="checkbox"
checked={showInspector}
onChange={(e) => setShowInspector(e.target.checked)}
/>
</FieldRow>
</TabItem>
</TabContainer>
</Modal>
);
}

View File

@@ -6,7 +6,3 @@
.tabContainer {
margin: 27px 16px;
}
.fieldRowText {
margin-bottom: 0;
}

View File

@@ -1,19 +1,3 @@
/*
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, { useRef } from "react";
import { useTabList, useTab, useTabPanel } from "@react-aria/tabs";
import { Item } from "@react-stately/collections";

View File

@@ -1,14 +1,15 @@
.tabContainer {
width: 100%;
display: flex;
flex: 1;
flex-direction: column;
}
.tabList {
display: flex;
flex-direction: column;
list-style: none;
padding: 0;
margin: 0 auto 24px auto;
margin: 0;
}
.tab {
@@ -19,18 +20,18 @@
background-color: transparent;
display: flex;
align-items: center;
padding: 0 8px;
padding: 0 16px;
border: none;
cursor: pointer;
}
.tab > * {
color: var(--secondary-content);
margin: 0 8px 0 0;
color: var(--textColor4);
margin-right: 16px;
}
.tab svg * {
fill: var(--secondary-content);
fill: var(--textColor4);
}
.tab > :last-child {
@@ -38,15 +39,15 @@
}
.tab.selected {
background-color: var(--accent);
background-color: #0dbd8b;
}
.tab.selected * {
color: var(--primary-content);
color: #ffffff;
}
.tab.selected svg * {
fill: var(--primary-content);
fill: #ffffff;
}
.tab.disabled {
@@ -56,31 +57,6 @@
display: flex;
flex-direction: column;
flex: 1;
padding: 0;
padding: 0 40px;
overflow-y: auto;
}
@media (min-width: 800px) {
.tab {
padding: 0 16px;
}
.tab > * {
margin: 0 16px 0 0;
}
.tabContainer {
width: 100%;
flex-direction: row;
margin: 27px 16px;
}
.tabList {
flex-direction: column;
margin-bottom: 0;
}
.tabPanel {
padding: 0 40px;
}
}

View File

@@ -1,76 +0,0 @@
import React, { forwardRef, useRef } from "react";
import { useTooltipTriggerState } from "@react-stately/tooltip";
import { FocusableProvider } from "@react-aria/focus";
import { useTooltipTrigger, useTooltip } from "@react-aria/tooltip";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import styles from "./Tooltip.module.css";
import classNames from "classnames";
import { OverlayContainer, useOverlayPosition } from "@react-aria/overlays";
export const Tooltip = forwardRef(
({ position, state, className, ...props }, ref) => {
let { tooltipProps } = useTooltip(props, state);
return (
<div
className={classNames(styles.tooltip, className)}
{...mergeProps(props, tooltipProps)}
ref={ref}
>
{props.children}
</div>
);
}
);
export const TooltipTrigger = forwardRef(({ children, ...rest }, ref) => {
const tooltipState = useTooltipTriggerState(rest);
const triggerRef = useObjectRef(ref);
const overlayRef = useRef();
const { triggerProps, tooltipProps } = useTooltipTrigger(
rest,
tooltipState,
triggerRef
);
const { overlayProps } = useOverlayPosition({
placement: rest.placement || "top",
targetRef: triggerRef,
overlayRef,
isOpen: tooltipState.isOpen,
offset: 5,
});
if (
!Array.isArray(children) ||
children.length > 2 ||
typeof children[1] !== "function"
) {
throw new Error(
"TooltipTrigger must have two props. The first being a button and the second being a render prop."
);
}
const [tooltipTrigger, tooltip] = children;
return (
<FocusableProvider ref={triggerRef} {...triggerProps}>
{<tooltipTrigger.type {...mergeProps(tooltipTrigger.props, rest)} />}
{tooltipState.isOpen && (
<OverlayContainer>
<Tooltip
state={tooltipState}
{...mergeProps(tooltipProps, overlayProps)}
ref={overlayRef}
>
{tooltip()}
</Tooltip>
</OverlayContainer>
)}
</FocusableProvider>
);
});
TooltipTrigger.defaultProps = {
delay: 250,
};

View File

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

View File

@@ -1,92 +1,99 @@
import React, { useMemo } from "react";
import { Item } from "@react-stately/collections";
import { Button, LinkButton } from "./button";
import { PopoverMenuTrigger } from "./popover/PopoverMenu";
import { Menu } from "./Menu";
import { Tooltip, TooltipTrigger } from "./Tooltip";
import { Avatar } from "./Avatar";
import React, { useCallback, useMemo } from "react";
import { ButtonTooltip, Button } from "./button";
import { PopoverMenuTrigger } from "./PopoverMenu";
import { ReactComponent as UserIcon } from "./icons/User.svg";
import { ReactComponent as LoginIcon } from "./icons/Login.svg";
import { ReactComponent as LogoutIcon } from "./icons/Logout.svg";
import styles from "./UserMenu.module.css";
import { useLocation } from "react-router-dom";
import { Body } from "./typography/Typography";
import { Item } from "@react-stately/collections";
import { Menu } from "./Menu";
import { useHistory, useLocation } from "react-router-dom";
import { useClient, useDisplayName } from "./ConferenceCallManagerHooks";
import { useModalTriggerState } from "./Modal";
import { ProfileModal } from "./ProfileModal";
export function UserMenu({
preventNavigation,
isAuthenticated,
isPasswordlessUser,
displayName,
avatarUrl,
onAction,
}) {
export function UserMenu() {
const location = useLocation();
const history = useHistory();
const { isGuest, isPasswordlessUser, logout, userName, client } = useClient();
const { displayName } = useDisplayName(client);
const { modalState, modalProps } = useModalTriggerState();
const onAction = useCallback(
(value) => {
switch (value) {
case "user":
modalState.open();
break;
case "logout":
logout();
break;
case "login":
history.push("/login", { state: { from: location } });
break;
case "register":
history.push("/register", { state: { from: location } });
break;
}
},
[history, location, logout, modalState]
);
const items = useMemo(() => {
const arr = [];
if (isAuthenticated) {
arr.push({
const arr = [
{
key: "user",
icon: UserIcon,
label: displayName,
label: displayName || userName,
},
];
if (isGuest || isPasswordlessUser) {
arr.push({
key: "login",
label: "Sign In",
icon: LoginIcon,
});
}
if (isPasswordlessUser && !preventNavigation) {
arr.push({
key: "login",
label: "Sign In",
icon: LoginIcon,
});
}
if (isGuest) {
arr.push({
key: "register",
label: "Register",
icon: LoginIcon,
});
}
if (!isPasswordlessUser && !preventNavigation) {
arr.push({
key: "logout",
label: "Sign Out",
icon: LogoutIcon,
});
}
if (!isGuest) {
arr.push({
key: "logout",
label: "Sign Out",
icon: LogoutIcon,
});
}
return arr;
}, [isAuthenticated, isPasswordlessUser, displayName, preventNavigation]);
if (!isAuthenticated) {
return (
<LinkButton to={{ pathname: "/login", state: { from: location } }}>
Log in
</LinkButton>
);
}
}, [isGuest, isPasswordlessUser, userName, displayName]);
return (
<PopoverMenuTrigger placement="bottom right">
<TooltipTrigger placement="bottom left">
<>
<PopoverMenuTrigger placement="bottom right">
<Button variant="icon" className={styles.userButton}>
{isAuthenticated && (!isPasswordlessUser || avatarUrl) ? (
<Avatar
size="sm"
className={styles.avatar}
src={avatarUrl}
fallback={displayName.slice(0, 1).toUpperCase()}
/>
) : (
<UserIcon />
)}
<ButtonTooltip>Profile</ButtonTooltip>
<UserIcon />
</Button>
{() => "Profile"}
</TooltipTrigger>
{(props) => (
<Menu {...props} label="User menu" onAction={onAction}>
{items.map(({ key, icon: Icon, label }) => (
<Item key={key} textValue={label} className={styles.menuItem}>
<Icon width={24} height={24} className={styles.menuIcon} />
<Body overflowEllipsis>{label}</Body>
</Item>
))}
</Menu>
)}
</PopoverMenuTrigger>
{(props) => (
<Menu {...props} label="User menu" onAction={onAction}>
{items.map(({ key, icon: Icon, label }) => (
<Item key={key} textValue={label}>
<Icon />
<span>{label}</span>
</Item>
))}
</Menu>
)}
</PopoverMenuTrigger>
{modalState.isOpen && <ProfileModal client={client} {...modalProps} />}
</>
);
}

View File

@@ -1,22 +1,3 @@
.menuIcon {
width: 24px;
height: 24px;
}
.userButton svg * {
fill: var(--primary-content);
}
.avatar {
width: 24px;
height: 24px;
font-size: 12px;
}
@media (min-width: 800px) {
.avatar {
width: 32px;
height: 32px;
font-size: 15px;
}
fill: var(--textColor1);
}

View File

@@ -1,49 +0,0 @@
import React, { useCallback } from "react";
import { useHistory, useLocation } from "react-router-dom";
import { useClient } from "./ClientContext";
import { useProfile } from "./profile/useProfile";
import { useModalTriggerState } from "./Modal";
import { ProfileModal } from "./profile/ProfileModal";
import { UserMenu } from "./UserMenu";
export function UserMenuContainer({ preventNavigation }) {
const location = useLocation();
const history = useHistory();
const { isAuthenticated, isPasswordlessUser, logout, userName, client } =
useClient();
const { displayName, avatarUrl } = useProfile(client);
const { modalState, modalProps } = useModalTriggerState();
const onAction = useCallback(
(value) => {
switch (value) {
case "user":
modalState.open();
break;
case "logout":
logout();
break;
case "login":
history.push("/login", { state: { from: location } });
break;
}
},
[history, location, logout, modalState]
);
return (
<>
<UserMenu
preventNavigation={preventNavigation}
isAuthenticated={isAuthenticated}
isPasswordlessUser={isPasswordlessUser}
avatarUrl={avatarUrl}
onAction={onAction}
displayName={
displayName || (userName ? userName.replace("@", "") : undefined)
}
/>
{modalState.isOpen && <ProfileModal client={client} {...modalProps} />}
</>
);
}

View File

@@ -1,226 +0,0 @@
/*
Copyright 2021-2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
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";
import { defaultHomeserverHost } from "../matrix-utils";
import { useInteractiveRegistration } from "./useInteractiveRegistration";
import styles from "./LoginPage.module.css";
import { ReactComponent as Logo } from "../icons/LogoLarge.svg";
import { LoadingView } from "../FullScreenView";
import { useRecaptcha } from "./useRecaptcha";
import { Caption, Link } from "../typography/Typography";
import { usePageTitle } from "../usePageTitle";
export const RegisterPage: FC = () => {
usePageTitle("Register");
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<Error>();
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const onSubmitRegisterForm = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
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;
const submit = async () => {
setRegistering(true);
const recaptchaResponse = await execute();
const [newClient, session] = await register(
userName,
password,
userName,
recaptchaResponse
);
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) {
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?.from) {
history.push(location.state.from);
} else {
history.push("/");
}
})
.catch((error) => {
setError(error);
setRegistering(false);
reset();
});
},
[
register,
location,
history,
isPasswordlessUser,
reset,
execute,
client,
setClient,
]
);
useEffect(() => {
if (password && passwordConfirmation && password !== passwordConfirmation) {
confirmPasswordRef.current?.setCustomValidity("Passwords must match");
} else {
confirmPasswordRef.current?.setCustomValidity("");
}
}, [password, passwordConfirmation]);
useEffect(() => {
if (!loading && isAuthenticated && !isPasswordlessUser && !registering) {
history.push("/");
}
}, [loading, history, isAuthenticated, isPasswordlessUser, registering]);
if (loading) {
return <LoadingView />;
}
return (
<>
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.formContainer}>
<Logo width="auto" height="auto" className={styles.logo} />
<h2>Create your account</h2>
<form onSubmit={onSubmitRegisterForm}>
<FieldRow>
<InputField
type="text"
name="userName"
placeholder="Username"
label="Username"
autoCorrect="off"
autoCapitalize="none"
prefix="@"
suffix={`:${defaultHomeserverHost}`}
/>
</FieldRow>
<FieldRow>
<InputField
required
name="password"
type="password"
onChange={(e) => setPassword(e.target.value)}
value={password}
placeholder="Password"
label="Password"
/>
</FieldRow>
<FieldRow>
<InputField
required
type="password"
name="passwordConfirmation"
onChange={(e) => setPasswordConfirmation(e.target.value)}
value={passwordConfirmation}
placeholder="Confirm Password"
label="Confirm Password"
ref={confirmPasswordRef}
/>
</FieldRow>
<Caption>
This site is protected by ReCAPTCHA and the Google{" "}
<Link href="https://www.google.com/policies/privacy/">
Privacy Policy
</Link>{" "}
and{" "}
<Link href="https://policies.google.com/terms">
Terms of Service
</Link>{" "}
apply.
<br />
By clicking "Register", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
</Caption>
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
</FieldRow>
)}
<FieldRow>
<Button type="submit" disabled={registering}>
{registering ? "Registering..." : "Register"}
</Button>
</FieldRow>
<div id={recaptchaId} />
</form>
</div>
<div className={styles.authLinks}>
<p>Already have an account?</p>
<p>
<Link to="/login">Log in</Link>
{" Or "}
<Link to="/">Access as a guest</Link>
</p>
</div>
</div>
</div>
</>
);
};

View File

@@ -1,154 +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 {
uniqueNamesGenerator,
adjectives,
colors,
animals,
Config,
} from "unique-names-generator";
const elements = [
"hydrogen",
"helium",
"lithium",
"beryllium",
"boron",
"carbon",
"nitrogen",
"oxygen",
"fluorine",
"neon",
"sodium",
"magnesium",
"aluminum",
"silicon",
"phosphorus",
"sulfur",
"chlorine",
"argon",
"potassium",
"calcium",
"scandium",
"titanium",
"vanadium",
"chromium",
"manganese",
"iron",
"cobalt",
"nickel",
"copper",
"zinc",
"gallium",
"germanium",
"arsenic",
"selenium",
"bromine",
"krypton",
"rubidium",
"strontium",
"yttrium",
"zirconium",
"niobium",
"molybdenum",
"technetium",
"ruthenium",
"rhodium",
"palladium",
"silver",
"cadmium",
"indium",
"tin",
"antimony",
"tellurium",
"iodine",
"xenon",
"cesium",
"barium",
"lanthanum",
"cerium",
"praseodymium",
"neodymium",
"promethium",
"samarium",
"europium",
"gadolinium",
"terbium",
"dysprosium",
"holmium",
"erbium",
"thulium",
"ytterbium",
"lutetium",
"hafnium",
"tantalum",
"wolfram",
"rhenium",
"osmium",
"iridium",
"platinum",
"gold",
"mercury",
"thallium",
"lead",
"bismuth",
"polonium",
"astatine",
"radon",
"francium",
"radium",
"actinium",
"thorium",
"protactinium",
"uranium",
"neptunium",
"plutonium",
"americium",
"curium",
"berkelium",
"californium",
"einsteinium",
"fermium",
"mendelevium",
"nobelium",
"lawrencium",
"rutherfordium",
"dubnium",
"seaborgium",
"bohrium",
"hassium",
"meitnerium",
"darmstadtium",
"roentgenium",
"copernicium",
"nihonium",
"flerovium",
"moscovium",
"livermorium",
"tennessine",
"oganesson",
];
export function generateRandomName(config: Config): string {
return uniqueNamesGenerator({
dictionaries: [colors, adjectives, animals, elements],
style: "lowerCase",
length: 3,
separator: "-",
...config,
});
}

View File

@@ -1,69 +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 { 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

@@ -1,124 +0,0 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useState, useEffect, useCallback, useRef } 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 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 authClient = useRef<MatrixClient>();
if (!authClient.current) {
authClient.current = createClient(defaultHomeserver);
}
useEffect(() => {
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: string,
password: string,
displayName: string,
recaptchaResponse: string,
passwordlessUser?: boolean
): Promise<[MatrixClient, Session]> => {
const interactiveAuth = new InteractiveAuth({
matrixClient: authClient.current,
doRequest: (auth) =>
authClient.current.registerRequest({
username,
password,
auth: auth || undefined,
}),
stateUpdated: (nextStage, status) => {
if (status.error) {
throw new Error(status.error);
}
if (nextStage === "m.login.terms") {
interactiveAuth.submitAuthDict({
type: "m.login.terms",
});
} else if (nextStage === "m.login.recaptcha") {
interactiveAuth.submitAuthDict({
type: "m.login.recaptcha",
response: recaptchaResponse,
});
}
},
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 client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
await client.setDisplayName(displayName);
const session: Session = {
user_id,
device_id,
access_token,
passwordlessUser,
};
/* eslint-enable camelcase */
if (passwordlessUser) {
session.tempPassword = password;
}
const user = client.getUser(client.getUserId());
user.setRawDisplayName(displayName);
user.setDisplayName(displayName);
return [client, session];
},
[]
);
return [privacyPolicyUrl, recaptchaKey, register];
};

View File

@@ -1,118 +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 { 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";
interface RecaptchaPromiseRef {
resolve: (response: string) => void;
reject: (error: Error) => void;
}
export const useRecaptcha = (sitekey: string) => {
const [recaptchaId] = useState(() => randomString(16));
const promiseRef = useRef<RecaptchaPromiseRef>();
useEffect(() => {
if (!sitekey) return;
const onRecaptchaLoaded = () => {
if (!document.getElementById(recaptchaId)) return;
window.grecaptcha.render(recaptchaId, {
sitekey,
size: "invisible",
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?.render === "function") {
onRecaptchaLoaded();
} else {
window.mxOnRecaptchaLoaded = onRecaptchaLoaded;
if (!document.querySelector(`script[src="${RECAPTCHA_SCRIPT_URL}"]`)) {
const scriptTag = document.createElement("script") as HTMLScriptElement;
scriptTag.src = RECAPTCHA_SCRIPT_URL;
scriptTag.async = true;
document.body.appendChild(scriptTag);
}
}
}, [recaptchaId, sitekey]);
const execute = useCallback(() => {
if (!sitekey) {
return Promise.resolve(null);
}
if (!window.grecaptcha) {
console.log("Recaptcha not loaded");
return Promise.reject(new Error("Recaptcha not loaded"));
}
return new Promise((resolve, reject) => {
const observer = new MutationObserver((mutationsList) => {
for (const item of mutationsList) {
if ((item.target as HTMLElement)?.style?.visibility !== "visible") {
reject(new Error("Recaptcha dismissed"));
observer.disconnect();
return;
}
}
});
promiseRef.current = {
resolve: (value) => {
resolve(value);
observer.disconnect();
},
reject: (error) => {
reject(error);
observer.disconnect();
},
};
window.grecaptcha.execute();
const iframe = document.querySelector<HTMLIFrameElement>(
'iframe[src*="recaptcha/api2/bframe"]'
);
if (iframe?.parentNode?.parentNode) {
observer.observe(iframe?.parentNode?.parentNode, {
attributes: true,
});
}
});
}, [sitekey]);
const reset = useCallback(() => {
window.grecaptcha?.reset();
}, []);
return { execute, reset, recaptchaId };
};

View File

@@ -1,19 +1,3 @@
/*
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, { forwardRef } from "react";
import classNames from "classnames";
import styles from "./Button.module.css";
@@ -23,24 +7,16 @@ import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import { ReactComponent as DisableVideoIcon } from "../icons/DisableVideo.svg";
import { ReactComponent as HangupIcon } from "../icons/Hangup.svg";
import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg";
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
import { useButton } from "@react-aria/button";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import { TooltipTrigger } from "../Tooltip";
import { useObjectRef } from "@react-aria/utils";
export const variantToClassName = {
default: [styles.button],
toolbar: [styles.toolbarButton],
toolbarSecondary: [styles.toolbarButtonSecondary],
icon: [styles.iconButton],
secondary: [styles.secondary],
copy: [styles.copyButton],
secondaryCopy: [styles.secondaryCopy, styles.copyButton],
iconCopy: [styles.iconCopyButton],
secondaryHangup: [styles.secondaryHangup],
dropdown: [styles.dropdownButton],
};
export const sizeToClassName = {
@@ -57,17 +33,12 @@ export const Button = forwardRef(
iconStyle,
className,
children,
onPress,
onPressStart,
...rest
},
ref
) => {
const buttonRef = useObjectRef(ref);
const { buttonProps } = useButton(
{ onPress, onPressStart, ...rest },
buttonRef
);
const { buttonProps } = useButton(rest, buttonRef);
// TODO: react-aria's useButton hook prevents form submission via keyboard
// Remove the hack below after this is merged https://github.com/adobe/react-spectrum/pull/904
@@ -90,82 +61,65 @@ export const Button = forwardRef(
[styles.off]: off,
}
)}
{...mergeProps(rest, filteredButtonProps)}
{...filteredButtonProps}
ref={buttonRef}
>
{children}
{variant === "dropdown" && <ArrowDownIcon />}
</button>
);
}
);
export function ButtonTooltip({ className, children }) {
return (
<div className={classNames(styles.buttonTooltip, className)}>
{children}
</div>
);
}
export function MicButton({ muted, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest} off={muted}>
{muted ? <MuteMicIcon /> : <MicIcon />}
</Button>
{() => (muted ? "Unmute microphone" : "Mute microphone")}
</TooltipTrigger>
<Button variant="toolbar" {...rest} off={muted}>
<ButtonTooltip>
{muted ? "Unmute microphone" : "Mute microphone"}
</ButtonTooltip>
{muted ? <MuteMicIcon /> : <MicIcon />}
</Button>
);
}
export function VideoButton({ muted, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest} off={muted}>
{muted ? <DisableVideoIcon /> : <VideoIcon />}
</Button>
{() => (muted ? "Turn on camera" : "Turn off camera")}
</TooltipTrigger>
<Button variant="toolbar" {...rest} off={muted}>
<ButtonTooltip>
{muted ? "Turn on camera" : "Turn off camera"}
</ButtonTooltip>
{muted ? <DisableVideoIcon /> : <VideoIcon />}
</Button>
);
}
export function ScreenshareButton({ enabled, className, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbarSecondary" {...rest} on={enabled}>
<ScreenshareIcon />
</Button>
{() => (enabled ? "Stop sharing screen" : "Share screen")}
</TooltipTrigger>
<Button variant="toolbar" {...rest} on={enabled}>
<ButtonTooltip>
{enabled ? "Stop sharing screen" : "Share screen"}
</ButtonTooltip>
<ScreenshareIcon />
</Button>
);
}
export function HangupButton({ className, ...rest }) {
return (
<TooltipTrigger>
<Button
variant="toolbar"
className={classNames(styles.hangupButton, className)}
{...rest}
>
<HangupIcon />
</Button>
{() => "Leave"}
</TooltipTrigger>
);
}
export function SettingsButton({ className, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest}>
<SettingsIcon />
</Button>
{() => "Settings"}
</TooltipTrigger>
);
}
export function InviteButton({ className, ...rest }) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest}>
<AddUserIcon />
</Button>
{() => "Invite"}
</TooltipTrigger>
<Button
variant="toolbar"
className={classNames(styles.hangupButton, className)}
{...rest}
>
<ButtonTooltip>Leave</ButtonTooltip>
<HangupIcon />
</Button>
);
}

View File

@@ -16,13 +16,10 @@ limitations under the License.
.button,
.toolbarButton,
.toolbarButtonSecondary,
.iconButton,
.iconCopyButton,
.secondary,
.secondaryHangup,
.copyButton,
.dropdownButton {
.copyButton {
position: relative;
display: flex;
justify-content: center;
@@ -36,7 +33,6 @@ limitations under the License.
}
.secondary,
.secondaryHangup,
.button,
.copyButton {
padding: 7px 15px;
@@ -46,94 +42,91 @@ limitations under the License.
}
.button {
color: var(--primary-content);
background-color: var(--accent);
color: #fff;
background-color: var(--primaryColor);
}
.button:focus,
.toolbarButton:focus,
.toolbarButtonSecondary:focus,
.iconButton:focus,
.iconCopyButton:focus,
.secondary:focus,
.secondaryHangup:focus,
.copyButton:focus {
outline: auto;
}
.toolbarButton,
.toolbarButtonSecondary {
.toolbarButton {
width: 50px;
height: 50px;
border-radius: 50px;
background-color: var(--system);
background-color: var(--bgColor2);
}
.toolbarButton:hover,
.toolbarButtonSecondary:hover {
background-color: var(--quinary-content);
.toolbarButton:hover {
background-color: var(--bgColor4);
}
.toolbarButton.on,
.toolbarButton.off {
background-color: var(--primary-content);
}
.toolbarButtonSecondary.on {
background-color: var(--accent);
background-color: #ffffff;
}
.iconButton:not(.stroke) svg * {
fill: var(--primary-content);
fill: #ffffff;
}
.iconButton:not(.stroke):hover svg * {
fill: var(--accent);
fill: #0dbd8b;
}
.iconButton.on:not(.stroke) svg * {
fill: var(--accent);
fill: #0dbd8b;
}
.iconButton.on.stroke svg * {
stroke: var(--accent);
stroke: #0dbd8b;
}
.hangupButton,
.hangupButton:hover {
background-color: var(--alert);
background-color: #ff5b55;
}
.toolbarButton.on svg * {
fill: var(--accent);
fill: #0dbd8b;
}
.toolbarButton.off svg * {
fill: #21262c;
}
.toolbarButtonSecondary.on svg * {
fill: var(--primary-content);
.buttonTooltip {
display: none;
background-color: var(--bgColor2);
position: absolute;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 8px 10px;
color: var(--textColor1);
border-radius: 8px;
max-width: 135px;
width: max-content;
z-index: 1;
}
.buttonTooltip.bottomRight {
right: 0;
}
.toolbarButton:hover .buttonTooltip {
display: flex;
bottom: calc(100% + 6px);
}
.iconButton:hover .buttonTooltip {
display: flex;
top: calc(100% + 6px);
}
.secondary,
.copyButton {
color: var(--accent);
border: 2px solid var(--accent);
color: #0dbd8b;
border: 2px solid #0dbd8b;
background-color: transparent;
}
.secondaryHangup {
color: var(--alert);
border: 2px solid var(--alert);
background-color: transparent;
}
.copyButton.secondaryCopy {
color: var(--primary-content);
border-color: var(--primary-content);
}
.copyButton {
width: 100%;
height: 40px;
@@ -154,12 +147,12 @@ limitations under the License.
}
.copyButton:not(.on) svg * {
fill: var(--accent);
fill: #0dbd8b;
}
.copyButton.on {
border-color: transparent;
background-color: var(--accent);
background-color: #0dbd8b;
color: white;
}
@@ -167,41 +160,18 @@ limitations under the License.
stroke: white;
}
.copyButton.secondaryCopy:not(.on) svg * {
fill: var(--primary-content);
}
.iconCopyButton svg * {
fill: var(--tertiary-content);
fill: var(--textColor3);
}
.iconCopyButton:hover svg * {
fill: var(--accent);
fill: #0dbd8b;
}
.iconCopyButton.on svg *,
.iconCopyButton.on:hover svg * {
fill: transparent;
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);
stroke: #0dbd8b;
}
.lg {

View File

@@ -1,20 +1,4 @@
/*
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 from "react";
import React, { useCallback } from "react";
import useClipboard from "react-use-clipboard";
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
import { ReactComponent as CopyIcon } from "../icons/Copy.svg";
@@ -23,7 +7,7 @@ import { Button } from "./Button";
export function CopyButton({
value,
children,
className,
onClassName,
variant,
copiedMessage,
...rest
@@ -33,9 +17,9 @@ export function CopyButton({
return (
<Button
{...rest}
variant={variant === "icon" ? "iconCopy" : variant || "copy"}
variant={variant === "icon" ? "iconCopy" : "copy"}
on={isCopied}
className={className}
onClassName={onClassName}
onPress={setCopied}
iconStyle={isCopied ? "stroke" : "fill"}
>

View File

@@ -1,19 +1,3 @@
/*
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 from "react";
import { Link } from "react-router-dom";
import classNames from "classnames";

View File

@@ -1,19 +1,3 @@
/*
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.
*/
export * from "./Button";
export * from "./CopyButton";
export * from "./LinkButton";

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1,27 +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 classNames from "classnames";
import React, { forwardRef } from "react";
import styles from "./Form.module.css";
export const Form = forwardRef(({ children, className, ...rest }, ref) => {
return (
<form {...rest} className={classNames(styles.form, className)} ref={ref}>
{children}
</form>
);
});

View File

@@ -1,4 +0,0 @@
.form {
display: flex;
flex-direction: column;
}

View File

@@ -1,92 +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 React from "react";
import { Link } from "react-router-dom";
import { CopyButton } from "../button";
import { Facepile } from "../Facepile";
import { Avatar } from "../Avatar";
import styles from "./CallList.module.css";
import { getRoomUrl } from "../matrix-utils";
import { Body, Caption } from "../typography/Typography";
export function CallList({ rooms, client, disableFacepile }) {
return (
<>
<div className={styles.callList}>
{rooms.map(({ roomId, roomName, avatarUrl, participants }) => (
<CallTile
key={roomId}
client={client}
name={roomName}
avatarUrl={avatarUrl}
roomId={roomId}
participants={participants}
disableFacepile={disableFacepile}
/>
))}
{rooms.length > 3 && (
<>
<div className={styles.callTileSpacer} />
<div className={styles.callTileSpacer} />
</>
)}
</div>
</>
);
}
function CallTile({
name,
avatarUrl,
roomId,
participants,
client,
disableFacepile,
}) {
return (
<div className={styles.callTile}>
<Link to={`/room/${roomId}`} className={styles.callTileLink}>
<Avatar
size="lg"
bgKey={name}
src={avatarUrl}
fallback={name.slice(0, 1).toUpperCase()}
className={styles.avatar}
/>
<div className={styles.callInfo}>
<Body overflowEllipsis fontWeight="semiBold">
{name}
</Body>
<Caption overflowEllipsis>{getRoomUrl(roomId)}</Caption>
{participants && !disableFacepile && (
<Facepile
className={styles.facePile}
client={client}
participants={participants}
/>
)}
</div>
<div className={styles.copyButtonSpacer} />
</Link>
<CopyButton
className={styles.copyButton}
variant="icon"
value={getRoomUrl(roomId)}
/>
</div>
);
}

View File

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

View File

@@ -1,69 +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 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

@@ -1,41 +0,0 @@
/*
Copyright 2021 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 React from "react";
import { useClient } from "../ClientContext";
import { ErrorView, LoadingView } from "../FullScreenView";
import { UnauthenticatedView } from "./UnauthenticatedView";
import { RegisteredView } from "./RegisteredView";
import { usePageTitle } from "../usePageTitle";
export function HomePage() {
usePageTitle("Home");
const { isAuthenticated, isPasswordlessUser, loading, error, client } =
useClient();
if (loading) {
return <LoadingView />;
} else if (error) {
return <ErrorView error={error} />;
} else {
return isAuthenticated ? (
<RegisteredView isPasswordlessUser={isPasswordlessUser} client={client} />
) : (
<UnauthenticatedView />
);
}
}

View File

@@ -1,35 +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 React from "react";
import { Modal, ModalContent } from "../Modal";
import { Button } from "../button";
import { FieldRow } from "../input/Input";
import styles from "./JoinExistingCallModal.module.css";
export function JoinExistingCallModal({ onJoin, ...rest }) {
return (
<Modal title="Join existing call?" isDismissable {...rest}>
<ModalContent>
<p>This call already exists, would you like to join?</p>
<FieldRow rightAlign className={styles.buttons}>
<Button onPress={rest.onClose}>No</Button>
<Button onPress={onJoin}>Yes, join call</Button>
</FieldRow>
</ModalContent>
</Modal>
);
}

View File

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

View File

@@ -1,141 +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 React, { useState, useCallback } from "react";
import { createRoom, roomAliasLocalpartFromRoomName } from "../matrix-utils";
import { useGroupCallRooms } from "./useGroupCallRooms";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import commonStyles from "./common.module.css";
import styles from "./RegisteredView.module.css";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
import { CallList } from "./CallList";
import { UserMenuContainer } from "../UserMenuContainer";
import { useModalTriggerState } from "../Modal";
import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { useHistory } from "react-router-dom";
import { Title } from "../typography/Typography";
import { Form } from "../form/Form";
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 onSubmit = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const roomName = data.get("callName");
const ptt = callType === CallType.Radio;
async function submit() {
setError(undefined);
setLoading(true);
const roomIdOrAlias = await createRoom(client, roomName, ptt);
if (roomIdOrAlias) {
history.push(`/room/${roomIdOrAlias}`);
}
}
submit().catch((error) => {
if (error.errcode === "M_ROOM_IN_USE") {
setExistingRoomId(roomAliasLocalpartFromRoomName(roomName));
setLoading(false);
setError(undefined);
modalState.open();
} else {
console.error(error);
setLoading(false);
setError(error);
reset();
}
});
},
[client, callType]
);
const recentRooms = useGroupCallRooms(client);
const { modalState, modalProps } = useModalTriggerState();
const [existingRoomId, setExistingRoomId] = useState();
const onJoinExistingRoom = useCallback(() => {
history.push(`/${existingRoomId}`);
}, [history, existingRoomId]);
const callNameLabel =
callType === CallType.Video ? "Video call name" : "Walkie-talkie call name";
return (
<>
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenuContainer />
</RightNav>
</Header>
<div className={commonStyles.container}>
<main className={commonStyles.main}>
<HeaderLogo className={commonStyles.logo} />
<CallTypeDropdown callType={callType} setCallType={setCallType} />
<Form className={styles.form} onSubmit={onSubmit}>
<FieldRow className={styles.fieldRow}>
<InputField
id="callName"
name="callName"
label={callNameLabel}
placeholder={callNameLabel}
type="text"
required
autoComplete="off"
/>
<Button
type="submit"
size="lg"
className={styles.button}
disabled={loading}
>
{loading ? "Loading..." : "Go"}
</Button>
</FieldRow>
{error && (
<FieldRow className={styles.fieldRow}>
<ErrorMessage>{error.message}</ErrorMessage>
</FieldRow>
)}
</Form>
{recentRooms.length > 0 && (
<>
<Title className={styles.recentCallsTitle}>
Your recent Calls
</Title>
<CallList rooms={recentRooms} client={client} disableFacepile />
</>
)}
</main>
</div>
{modalState.isOpen && (
<JoinExistingCallModal onJoin={onJoinExistingRoom} {...modalProps} />
)}
</>
);
}

View File

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

View File

@@ -1,180 +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 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, 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";
export function UnauthenticatedView() {
const { setClient } = useClient();
const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
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 = callType === CallType.Radio;
async function submit() {
setError(undefined);
setLoading(true);
const recaptchaResponse = await execute();
const userName = generateRandomName();
const [client, session] = await register(
userName,
randomString(16),
displayName,
recaptchaResponse,
true
);
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) => {
console.error(error);
setLoading(false);
setError(error);
reset();
});
},
[register, reset, execute, history, callType]
);
const callNameLabel =
callType === CallType.Video ? "Video call name" : "Walkie-talkie call name";
return (
<>
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav hideMobile>
<UserMenuContainer />
</RightNav>
</Header>
<div className={commonStyles.container}>
<main className={commonStyles.main}>
<HeaderLogo className={commonStyles.logo} />
<CallTypeDropdown callType={callType} setCallType={setCallType} />
<Form className={styles.form} onSubmit={onSubmit}>
<FieldRow>
<InputField
id="callName"
name="callName"
label={callNameLabel}
placeholder={callNameLabel}
type="text"
required
autoComplete="off"
/>
</FieldRow>
<FieldRow>
<InputField
id="displayName"
name="displayName"
label="Display Name"
placeholder="Display Name"
type="text"
required
autoComplete="off"
/>
</FieldRow>
<Caption>
By clicking "Go", you agree to our{" "}
<Link href={privacyPolicyUrl}>Terms and conditions</Link>
</Caption>
{error && (
<FieldRow>
<ErrorMessage>{error.message}</ErrorMessage>
</FieldRow>
)}
<Button type="submit" size="lg" disabled={loading}>
{loading ? "Loading..." : "Go"}
</Button>
<div id={recaptchaId} />
</Form>
</main>
<footer className={styles.footer}>
<Body className={styles.mobileLoginLink}>
<Link color="primary" to="/login">
Login to your account
</Link>
</Body>
<Body>
Not registered yet?{" "}
<Link color="primary" to="/register">
Create an account
</Link>
</Body>
</footer>
</div>
{modalState.isOpen && (
<JoinExistingCallModal onJoin={onFinished} {...modalProps} />
)}
</>
);
}

View File

@@ -1,31 +0,0 @@
.footer {
display: flex;
flex-direction: column;
align-items: center;
padding: 28px;
}
.footer p {
margin-bottom: 0;
}
.footer .mobileLoginLink {
display: flex;
margin-bottom: 24px;
}
.form {
padding: 0 24px;
justify-content: center;
max-width: 360px;
}
.form > * + * {
margin-bottom: 24px;
}
@media (min-width: 800px) {
.mobileLoginLink {
display: none;
}
}

Some files were not shown because too many files have changed in this diff Show More