Compare commits

...

62 Commits

Author SHA1 Message Date
David Baker
810cdeeab4 Merge pull request #482 from vector-im/dbkr/fix_screenshare_crash
Fix crash on screen share
2022-07-21 11:48:55 +01:00
David Baker
075049abc4 Merge pull request #479 from vector-im/dbkr/wait_for_room
Fix 'cannot find room' error
2022-07-21 11:48:23 +01:00
David Baker
56afbe6eb1 Fix crash on screen share
Don't try to wire up audio nodes if the stream has no audio track,
'cos it'll crash.

Fixes https://github.com/vector-im/element-call/issues/421
2022-07-20 20:49:07 +01:00
David Baker
32b37ed8f0 Fix 'cannot find room' error
We weren't waiting for rooms to arrive down the sync stream after
joining them but before trying to use them.

More regression details in linked issue.

Fixes https://github.com/vector-im/element-call/issues/477
2022-07-20 16:01:29 +01:00
Šimon Brandner
6d7f52d2d6 Merge pull request #472 from vector-im/SimonBrandner/task/vs-code 2022-07-16 17:49:58 +02:00
Šimon Brandner
d77d953f84 Be more explicit in .vscode/settings.json
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-16 08:28:08 +02:00
Robin
6456a6b0c0 Merge pull request #471 from robintown/await-room-creation
Make room setup more reliable
2022-07-15 16:17:55 -04:00
Robin Townsend
996c5f86c1 Refactor to use fewer else's 2022-07-15 16:08:26 -04:00
Robin Townsend
daeecc9b68 Add a missing type 2022-07-15 13:07:19 -04:00
Robin Townsend
982398b32f Remove unnecessary complexity from createRoom
With fae4c504c9, the changes from
b4a56f6dd7 are no longer necessary.
2022-07-15 13:05:06 -04:00
Robin Townsend
fae4c504c9 Consolidate all group call creation into useLoadGroupCall
This enables us to automatically create a group call in rooms that
exist, but contain no calls.
2022-07-15 12:59:54 -04:00
Robin Townsend
b4a56f6dd7 Wait for the created room to come down sync before placing a group call 2022-07-15 11:31:52 -04:00
Šimon Brandner
034552a063 Merge pull request #469 from vector-im/SimonBrandner/task/env 2022-07-15 15:59:40 +02:00
Šimon Brandner
bb505273f4 Add .env instruction
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-15 11:32:07 +02:00
Šimon Brandner
f876df6acc Remove .env
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-15 11:30:52 +02:00
Šimon Brandner
d097223d41 Add .env to .gitignore
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-15 11:29:25 +02:00
Šimon Brandner
d01f7be58a Add .env.example
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-15 11:28:16 +02:00
David Baker
cc7a44dc17 Merge pull request #466 from vector-im/dbkr/check_indexeddb
Don't restore session unless crypto data is found
2022-07-14 13:43:21 +01:00
David Baker
873e68e1e1 Add notes from thinking through the need for storing what crypto db we use 2022-07-14 13:24:22 +01:00
David Baker
4f44a68198 Merge pull request #465 from vector-im/dbkr/display_name_url_param
Auto-register if displayName URL param is given
2022-07-14 13:20:36 +01:00
David Baker
1eab957d85 Fix typescript syntax 2022-07-14 13:11:47 +01:00
David Baker
4c145af7a3 Don't restore session unless crypto data is found
Add a check to ensure that we find crypto data in the crypto store
when we're restoring a session and otherwise abort the session restore.

This will prevent us from restoring a session and generating new keys
when there was a previous session with different keys.

***This will force a logout for all users***

See the linked issue (and the comment in code) for more detail.

Fixes https://github.com/vector-im/element-call/issues/464
2022-07-14 13:07:30 +01:00
David Baker
c1e45c4a30 Missed a file 2022-07-13 16:02:17 +01:00
David Baker
5784a005dc Auto-register if displayName URL param is given
Fixes https://github.com/vector-im/element-call/issues/442
2022-07-13 14:34:15 +01:00
David Baker
a3e4d6998f Merge pull request #463 from vector-im/dbkr/create_room_ptt
Add ptt URL param to control what mode rooms are created in
2022-07-11 14:34:42 +01:00
David Baker
32907764b3 Add ptt URL param to control what mode rooms are created in 2022-07-11 13:23:03 +01:00
David Baker
cb34b1634d Merge pull request #462 from vector-im/dbkr/bypass_lobby_in_embed_mode
Bypass lobby in embedded mode
2022-07-11 12:42:15 +01:00
David Baker
5199fd2566 Prettier 2022-07-08 21:17:27 +01:00
David Baker
b31c6c6780 Bypass lobby in embedded mode 2022-07-08 20:55:18 +01:00
David Baker
aeec2c076e Merge pull request #458 from vector-im/dbkr/fix_network_waiting_after_timeout
Fix 'waiting for network' after reaching time limit
2022-07-08 19:03:55 +01:00
David Baker
8bbce188ef Merge pull request #457 from vector-im/dbkr/yarn_upgrade_20220708
yarn upgrade
2022-07-08 19:03:34 +01:00
David Baker
dbdc010764 Updgrade postcss-preset-env
as it was complaining that it didn't work with our version of postcss
2022-07-08 17:19:13 +01:00
David Baker
a81c48cc22 Fix 'waiting for network' after reaching time limit
If you spoke for the maximum amount of time and got cut off, the
next time you tried to speak you'd just get the 'waiting for network'
state. Key repeats would cause more delayed state timeouts to queue
up.
2022-07-08 15:52:32 +01:00
David Baker
6eb77b7c2f Fix types 2022-07-08 14:56:00 +01:00
David Baker
92a50fe51d yarn upgrade
Fixes https://github.com/vector-im/element-call/issues/456 as
the various react libraries had got out of sync (also we were well
overdue a dependency update).
2022-07-08 14:26:08 +01:00
David Baker
572caf6826 Merge pull request #453 from vector-im/dbkr/fix_facepile_display
Fix facepile display issues
2022-07-08 09:59:11 +01:00
David Baker
b0c8ceb302 Merge pull request #455 from vector-im/dbkr/fix_talkover
Fix talking collision not colliding properly
2022-07-08 09:51:26 +01:00
David Baker
c9ae6532a0 Bump js-sdk 2022-07-08 09:48:40 +01:00
Timo
e5cfcb601b Merge pull request #397 from toger5/ts_button 2022-07-07 22:03:28 +02:00
David Baker
2b92bf3694 Fix talking collision not colliding properly
The code was only entering the blocked state if the user was speaking,
which often won't be the case when another person starts speaking because
we'll have pressed the button but not got the ack back from the server
yet. Add the transmitblocked flag instead so we don't enter that state
again if we've already decided we've been blocked.

We were also starting with blocked = false and so resetting it when it
shouldn't have been reset.

Also requires https://github.com/matrix-org/matrix-js-sdk/pull/2502
2022-07-07 19:42:15 +01:00
David Baker
cd42d09ea9 Fix facepile display issues
Fixes https://github.com/vector-im/element-call/issues/434 and a
separate bug where the facepile would just disappear off to the left
(because we kept increasing the size even though we capped the number
of circles at 8 plus the overflow one).
2022-07-07 14:30:28 +01:00
David Baker
c56b1c8a86 Merge pull request #452 from Johennes/feature/no-empty-labels-pt2
Prevent empty device labels in audio preview
2022-07-07 14:30:12 +01:00
Johannes Marbach
e8d99e15f7 Prevent empty device labels in audio preview
Fixes: #324
Signed-off-by: Johannes Marbach <johannesm@element.io>
2022-07-07 13:32:23 +02:00
David Baker
4dcec504ca Merge pull request #449 from Johennes/feature/no-empty-labels
Prevent empty device labels
2022-07-07 12:16:18 +01:00
Johannes Marbach
1308e52e42 Enumerate devices 2022-07-07 12:10:08 +02:00
Johannes Marbach
f6d356c5ce Prettify the thing 2022-07-07 10:31:44 +02:00
Johannes Marbach
eb2de869b8 Prevent empty device labels
Fixes: #324
Signed-off-by: Johannes Marbach <johannesm@element.io>
2022-07-07 10:21:38 +02:00
David Baker
c6030d33ca Merge pull request #448 from vector-im/dbkr/country_roads
Remove the 'Take Me Home' link in embed mode
2022-07-06 22:01:54 +01:00
David Baker
655058a7e6 Remove the 'Take Me Home' link in embed mode 2022-07-06 18:27:30 +01:00
David Baker
16d4ffbe3a Merge pull request #446 from vector-im/dbkr/fix_talking_view
Fix view when another person is talking
2022-07-06 18:08:32 +01:00
David Baker
775125c8a7 Fix view when another person is talking
Fixes https://github.com/vector-im/element-call/issues/445
2022-07-06 13:44:17 +01:00
Robin
631e63a0b5 Merge pull request #444 from robintown/wt-small
Adapt walkie-talkie layout to hide controls at small sizes
2022-07-05 16:07:55 -04:00
Robin Townsend
4cb2306de0 Make button be constrained primarily by width rather than height 2022-07-05 15:49:48 -04:00
Robin Townsend
f15ee439a9 Fix page layout 2022-07-05 15:41:57 -04:00
Robin Townsend
b9a2473d19 Adapt walkie-talkie layout to hide controls at small sizes 2022-07-05 13:47:53 -04:00
Timo K
5b58223f9d fix refs 2022-07-05 17:44:09 +02:00
Timo K
f34fd0bd00 update @react-aria/button 2022-07-05 17:42:03 +02:00
Timo K
713136672a make className an optional param 2022-07-02 21:45:31 +02:00
Timo K
f1bd47be8c Merge branch 'main' into ts_button 2022-07-02 21:42:15 +02:00
Timo K
2e82960ae6 ButtonVariant ButtonSize 2022-07-02 21:20:53 +02:00
Timo K
18ca92cec4 js->ts 2022-06-11 23:21:20 +02:00
Timo K
dc11814695 rename files js->ts 2022-06-11 15:23:33 +02:00
34 changed files with 3918 additions and 3920 deletions

View File

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
node_modules
.DS_Store
.env
dist
dist-ssr
*.local

18
.vscode/settings.json vendored
View File

@@ -2,5 +2,21 @@
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.insertSpaces": true,
"editor.tabSize": 2
"editor.tabSize": 2,
"[typescriptreact]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
},
"[javascriptreact]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
},
"[typescript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
},
"[javascript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
}
}

View File

@@ -27,6 +27,7 @@ git clone https://github.com/vector-im/element-call.git
cd element-call
yarn
yarn link matrix-js-sdk
cp .env.example .env
yarn dev
```

View File

@@ -38,11 +38,11 @@
"classnames": "^2.3.1",
"color-hash": "^2.0.1",
"events": "^3.3.0",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#9a15094374f52053ca9f833269d2b1c6c7f964d2",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#984dd26a138411ef73903ff4e635f2752e0829f2",
"mermaid": "^8.13.8",
"normalize.css": "^8.0.1",
"pako": "^2.0.4",
"postcss-preset-env": "^6.7.0",
"postcss-preset-env": "^7",
"re-resizable": "^6.9.0",
"react": "^17.0.0",
"react-dom": "^17.0.0",

View File

@@ -71,7 +71,11 @@ type ClientProviderState = Omit<
"changePassword" | "logout" | "setClient"
> & { error?: Error };
export const ClientProvider: FC = ({ children }) => {
interface Props {
children: JSX.Element;
}
export const ClientProvider: FC<Props> = ({ children }) => {
const history = useHistory();
const [
{ loading, isAuthenticated, isPasswordlessUser, client, userName, error },
@@ -97,12 +101,15 @@ export const ClientProvider: FC = ({ children }) => {
const { user_id, device_id, access_token, passwordlessUser } =
session;
const client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
const client = await initClient(
{
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
true
);
/* eslint-enable camelcase */
return { client, isPasswordlessUser: passwordlessUser };

View File

@@ -24,7 +24,11 @@ export function Facepile({
<div
className={classNames(styles.facepile, styles[size], className)}
title={participants.map((member) => member.name).join(", ")}
style={{ width: participants.length * (_size - _overlap) + _overlap }}
style={{
width:
Math.min(participants.length, max + 1) * (_size - _overlap) +
_overlap,
}}
{...rest}
>
{participants.slice(0, max).map((member, i) => {

View File

@@ -19,7 +19,6 @@ import {
adjectives,
colors,
animals,
Config,
} from "unique-names-generator";
const elements = [
@@ -143,12 +142,11 @@ const elements = [
"oganesson",
];
export function generateRandomName(config: Config): string {
export function generateRandomName(): string {
return uniqueNamesGenerator({
dictionaries: [colors, adjectives, animals, elements],
style: "lowerCase",
length: 3,
separator: "-",
...config,
});
}

View File

@@ -57,12 +57,15 @@ export const useInteractiveLogin = () =>
passwordlessUser: false,
};
const client = await initClient({
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
});
const client = await initClient(
{
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
false
);
/* eslint-enable camelcase */
return [client, session];

View File

@@ -90,12 +90,15 @@ export const useInteractiveRegistration = (): [
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,
});
const client = await initClient(
{
baseUrl: defaultHomeserver,
accessToken: access_token,
userId: user_id,
deviceId: device_id,
},
false
);
await client.setDisplayName(displayName);

View File

@@ -0,0 +1,59 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useCallback } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useClient } from "../ClientContext";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { generateRandomName } from "../auth/generateRandomName";
import { useRecaptcha } from "../auth/useRecaptcha";
export interface UseRegisterPasswordlessUserType {
privacyPolicyUrl: string;
registerPasswordlessUser: (displayName: string) => Promise<void>;
recaptchaId: string;
}
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
const { setClient } = useClient();
const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const registerPasswordlessUser = useCallback(
async (displayName: string) => {
try {
const recaptchaResponse = await execute();
const userName = generateRandomName();
const [client, session] = await register(
userName,
randomString(16),
displayName,
recaptchaResponse,
true
);
setClient(client, session);
} catch (e) {
reset();
throw e;
}
},
[execute, reset, register, setClient]
);
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
}

View File

@@ -13,9 +13,12 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { forwardRef } from "react";
import { PressEvent } from "@react-types/shared";
import classNames from "classnames";
import { useButton } from "@react-aria/button";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import styles from "./Button.module.css";
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
import { ReactComponent as MuteMicIcon } from "../icons/MuteMic.svg";
@@ -26,10 +29,21 @@ import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg";
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
import { ReactComponent as 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";
export type ButtonVariant =
| "default"
| "toolbar"
| "toolbarSecondary"
| "icon"
| "secondary"
| "copy"
| "secondaryCopy"
| "iconCopy"
| "secondaryHangup"
| "dropdown"
| "link";
export const variantToClassName = {
default: [styles.button],
toolbar: [styles.toolbarButton],
@@ -44,11 +58,24 @@ export const variantToClassName = {
link: [styles.linkButton],
};
export const sizeToClassName = {
export type ButtonSize = "lg";
export const sizeToClassName: { lg: string[] } = {
lg: [styles.lg],
};
export const Button = forwardRef(
interface Props {
variant: ButtonVariant;
size: ButtonSize;
on: () => void;
off: () => void;
iconStyle: string;
className: string;
children: Element[];
onPress: (e: PressEvent) => void;
onPressStart: (e: PressEvent) => void;
[index: string]: unknown;
}
export const Button = forwardRef<HTMLButtonElement, Props>(
(
{
variant = "default",
@@ -64,7 +91,7 @@ export const Button = forwardRef(
},
ref
) => {
const buttonRef = useObjectRef(ref);
const buttonRef = useObjectRef<HTMLButtonElement>(ref);
const { buttonProps } = useButton(
{ onPress, onPressStart, ...rest },
buttonRef
@@ -75,7 +102,7 @@ export const Button = forwardRef(
let filteredButtonProps = buttonProps;
if (rest.type === "submit" && !rest.onPress) {
const { onKeyDown, onKeyUp, ...filtered } = buttonProps;
const { ...filtered } = buttonProps;
filteredButtonProps = filtered;
}
@@ -94,14 +121,22 @@ export const Button = forwardRef(
{...mergeProps(rest, filteredButtonProps)}
ref={buttonRef}
>
{children}
{variant === "dropdown" && <ArrowDownIcon />}
<>
{children}
{variant === "dropdown" && <ArrowDownIcon />}
</>
</button>
);
}
);
export function MicButton({ muted, ...rest }) {
export function MicButton({
muted,
...rest
}: {
muted: boolean;
[index: string]: unknown;
}) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest} off={muted}>
@@ -112,7 +147,13 @@ export function MicButton({ muted, ...rest }) {
);
}
export function VideoButton({ muted, ...rest }) {
export function VideoButton({
muted,
...rest
}: {
muted: boolean;
[index: string]: unknown;
}) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest} off={muted}>
@@ -123,7 +164,15 @@ export function VideoButton({ muted, ...rest }) {
);
}
export function ScreenshareButton({ enabled, className, ...rest }) {
export function ScreenshareButton({
enabled,
className,
...rest
}: {
enabled: boolean;
className?: string;
[index: string]: unknown;
}) {
return (
<TooltipTrigger>
<Button variant="toolbarSecondary" {...rest} on={enabled}>
@@ -134,7 +183,13 @@ export function ScreenshareButton({ enabled, className, ...rest }) {
);
}
export function HangupButton({ className, ...rest }) {
export function HangupButton({
className,
...rest
}: {
className?: string;
[index: string]: unknown;
}) {
return (
<TooltipTrigger>
<Button
@@ -149,7 +204,13 @@ export function HangupButton({ className, ...rest }) {
);
}
export function SettingsButton({ className, ...rest }) {
export function SettingsButton({
className,
...rest
}: {
className?: string;
[index: string]: unknown;
}) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest}>
@@ -160,7 +221,13 @@ export function SettingsButton({ className, ...rest }) {
);
}
export function InviteButton({ className, ...rest }) {
export function InviteButton({
className,
...rest
}: {
className?: string;
[index: string]: unknown;
}) {
return (
<TooltipTrigger>
<Button variant="toolbar" {...rest}>

View File

@@ -16,10 +16,18 @@ limitations under the License.
import React from "react";
import useClipboard from "react-use-clipboard";
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
import { ReactComponent as CopyIcon } from "../icons/Copy.svg";
import { Button } from "./Button";
import { Button, ButtonVariant } from "./Button";
interface Props {
value: string;
children: JSX.Element;
className: string;
variant: ButtonVariant;
copiedMessage: string;
}
export function CopyButton({
value,
children,
@@ -27,7 +35,7 @@ export function CopyButton({
variant,
copiedMessage,
...rest
}) {
}: Props) {
const [isCopied, setCopied] = useClipboard(value, { successDuration: 3000 });
return (

View File

@@ -17,9 +17,28 @@ limitations under the License.
import React from "react";
import { Link } from "react-router-dom";
import classNames from "classnames";
import { variantToClassName, sizeToClassName } from "./Button";
export function LinkButton({ className, variant, size, children, ...rest }) {
import {
variantToClassName,
sizeToClassName,
ButtonVariant,
ButtonSize,
} from "./Button";
interface Props {
className: string;
variant: ButtonVariant;
size: ButtonSize;
children: JSX.Element;
[index: string]: unknown;
}
export function LinkButton({
className,
variant,
size,
children,
...rest
}: Props) {
return (
<Link
className={classNames(

View File

@@ -47,7 +47,7 @@ export function RegisteredView({ client }) {
setError(undefined);
setLoading(true);
const roomIdOrAlias = await createRoom(client, roomName, ptt);
const [roomIdOrAlias] = await createRoom(client, roomName, ptt);
if (roomIdOrAlias) {
history.push(`/room/${roomIdOrAlias}`);

View File

@@ -70,7 +70,7 @@ export function UnauthenticatedView() {
let roomIdOrAlias;
try {
roomIdOrAlias = await createRoom(client, roomName, ptt);
[roomIdOrAlias] = await createRoom(client, roomName, ptt);
} catch (error) {
if (error.errcode === "M_ROOM_IN_USE") {
setOnFinished(() => () => {

View File

@@ -9,10 +9,6 @@ import { createClient, MatrixClient } from "matrix-js-sdk/src/matrix";
import { ICreateClientOpts } from "matrix-js-sdk/src/matrix";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { Visibility, Preset } from "matrix-js-sdk/src/@types/partials";
import {
GroupCallIntent,
GroupCallType,
} from "matrix-js-sdk/src/webrtc/groupCall";
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
import { logger } from "matrix-js-sdk/src/logger";
@@ -24,6 +20,19 @@ export const defaultHomeserver =
export const defaultHomeserverHost = new URL(defaultHomeserver).host;
export class CryptoStoreIntegrityError extends Error {
constructor() {
super("Crypto store data was expected, but none was found");
}
}
const SYNC_STORE_NAME = "element-call-sync";
// Note that the crypto store name has changed from previous versions
// deliberately in order to force a logout for all users due to
// https://github.com/vector-im/element-call/issues/464
// (It's a good opportunity to make the database names consistent.)
const CRYPTO_STORE_NAME = "element-call-crypto";
function waitForSync(client: MatrixClient) {
return new Promise<void>((resolve, reject) => {
const onSync = (
@@ -43,8 +52,18 @@ function waitForSync(client: MatrixClient) {
});
}
/**
* Initialises and returns a new Matrix Client
* If true is passed for the 'restore' parameter, a check will be made
* to ensure that corresponding crypto data is stored and recovered.
* If the check fails, CryptoStoreIntegrityError will be thrown.
* @param clientOptions Object of options passed through to the client
* @param restore Whether the session is being restored from storage
* @returns The MatrixClient instance
*/
export async function initClient(
clientOptions: ICreateClientOpts
clientOptions: ICreateClientOpts,
restore: boolean
): Promise<MatrixClient> {
// TODO: https://gitlab.matrix.org/matrix-org/olm/-/issues/10
window.OLM_OPTIONS = {};
@@ -62,17 +81,45 @@ export async function initClient(
storeOpts.store = new IndexedDBStore({
indexedDB: window.indexedDB,
localStorage,
dbName: "element-call-sync",
dbName: SYNC_STORE_NAME,
workerFactory: () => new IndexedDBWorker(),
});
} else if (localStorage) {
storeOpts.store = new MemoryStore({ localStorage });
}
// Check whether we have crypto data store. If we are restoring a session
// from storage then we will have started the crypto store and therefore
// have generated keys for that device, so if we can't recover those keys,
// we must not continue or we'll generate new keys and anyone who saw our
// previous keys will not accept our new key.
// It's worth mentioning here that if support for indexeddb or localstorage
// appears or disappears between sessions (it happens) then the failure mode
// here will be that we'll try a different store, not find crypto data and
// fail to restore the session. An alternative would be to continue using
// whatever we were using before, but that could be confusing since you could
// enable indexeddb and but the app would still not be using it.
if (restore) {
if (indexedDB) {
const cryptoStoreExists = await IndexedDBCryptoStore.exists(
indexedDB,
CRYPTO_STORE_NAME
);
if (!cryptoStoreExists) throw new CryptoStoreIntegrityError();
} else if (localStorage) {
if (!LocalStorageCryptoStore.exists(localStorage))
throw new CryptoStoreIntegrityError();
} else {
// if we get here then we're using the memory store, which cannot
// possibly have remembered a session, so it's an error.
throw new CryptoStoreIntegrityError();
}
}
if (indexedDB) {
storeOpts.cryptoStore = new IndexedDBCryptoStore(
indexedDB,
"matrix-js-sdk:crypto"
CRYPTO_STORE_NAME
);
} else if (localStorage) {
storeOpts.cryptoStore = new LocalStorageCryptoStore(localStorage);
@@ -172,10 +219,9 @@ export function isLocalRoomId(roomId: string): boolean {
export async function createRoom(
client: MatrixClient,
name: string,
isPtt = false
): Promise<string> {
const createRoomResult = await client.createRoom({
name: string
): Promise<[string, string]> {
const result = await client.createRoom({
visibility: Visibility.Private,
preset: Preset.PublicChat,
name,
@@ -205,16 +251,7 @@ export async function createRoom(
},
});
console.log(`Creating ${isPtt ? "PTT" : "video"} group call room`);
await client.createGroupCall(
createRoomResult.room_id,
isPtt ? GroupCallType.Voice : GroupCallType.Video,
isPtt,
GroupCallIntent.Prompt
);
return fullAliasFromRoomName(name, client);
return [fullAliasFromRoomName(name, client), result.room_id];
}
export function getRoomUrl(roomId: string): string {

View File

@@ -53,8 +53,12 @@ export function AudioPreview({
onSelectionChange={setAudioInput}
className={styles.inputField}
>
{audioInputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
{audioInputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: `Microphone ${index + 1}`}
</Item>
))}
</SelectInput>
{audioOutputs.length > 0 && (
@@ -64,8 +68,12 @@ export function AudioPreview({
onSelectionChange={setAudioOutput}
className={styles.inputField}
>
{audioOutputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
{audioOutputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: `Speaker ${index + 1}`}
</Item>
))}
</SelectInput>
)}

View File

@@ -19,12 +19,18 @@ import { useLoadGroupCall } from "./useLoadGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView";
import { usePageTitle } from "../usePageTitle";
export function GroupCallLoader({ client, roomId, viaServers, children }) {
export function GroupCallLoader({
client,
roomId,
viaServers,
createPtt,
children,
}) {
const { loading, error, groupCall } = useLoadGroupCall(
client,
roomId,
viaServers,
true
createPtt
);
usePageTitle(groupCall ? groupCall.room.name : "Loading...");

View File

@@ -61,7 +61,10 @@ export function GroupCallView({
useEffect(() => {
window.groupCall = groupCall;
}, [groupCall]);
// In embedded mode, bypass the lobby and just enter the call straight away
if (isEmbedded) groupCall.enter();
}, [groupCall, isEmbedded]);
useSentryGroupCallHandler(groupCall);
@@ -128,23 +131,32 @@ export function GroupCallView({
} else if (left) {
return <CallEndedView client={client} />;
} else {
return (
<LobbyView
client={client}
groupCall={groupCall}
hasLocalParticipant={hasLocalParticipant}
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
state={state}
onInitLocalCallFeed={initLocalCallFeed}
localCallFeed={localCallFeed}
onEnter={enter}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
roomId={roomId}
/>
);
if (isEmbedded) {
return (
<FullScreenView>
<h1>Loading room...</h1>
</FullScreenView>
);
} else {
return (
<LobbyView
client={client}
groupCall={groupCall}
hasLocalParticipant={hasLocalParticipant}
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
state={state}
onInitLocalCallFeed={initLocalCallFeed}
localCallFeed={localCallFeed}
onEnter={enter}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
roomId={roomId}
isEmbedded={isEmbedded}
/>
);
}
}
}

View File

@@ -42,6 +42,7 @@ export function LobbyView({
toggleLocalVideoMuted,
toggleMicrophoneMuted,
roomId,
isEmbedded,
}) {
const { stream } = useCallFeed(localCallFeed);
const {
@@ -122,11 +123,13 @@ export function LobbyView({
Copy call link and join later
</CopyButton>
</div>
<Body className={styles.joinRoomFooter}>
<Link color="primary" to="/">
Take me Home
</Link>
</Body>
{!isEmbedded && (
<Body className={styles.joinRoomFooter}>
<Link color="primary" to="/">
Take me Home
</Link>
</Body>
)}
</div>
</div>
);

View File

@@ -1,17 +1,28 @@
.pttButton {
width: 100vw;
height: 100vh;
max-height: 232px;
max-width: 232px;
aspect-ratio: 1;
max-height: min(232px, calc(100vh - 16px));
max-width: min(232px, calc(100vw - 16px));
border-radius: 116px;
color: var(--primary-content);
border: 6px solid var(--accent);
background-color: #21262c;
position: relative;
padding: 0;
margin: 4px;
cursor: pointer;
}
.micIcon {
max-height: 50%;
}
.avatar {
/* Remove explicit size to allow avatar to scale with the button */
width: 100% !important;
height: 100% !important;
}
.talking {
background-color: var(--accent);
cursor: unset;

View File

@@ -57,13 +57,17 @@ export const PTTButton: React.FC<Props> = ({
const buttonRef = useRef<HTMLButtonElement>();
const [activeTouchId, setActiveTouchId] = useState<number | null>(null);
const [buttonHeld, setButtonHeld] = useState(false);
const hold = useCallback(() => {
// This update is delayed so the user only sees it if latency is significant
if (buttonHeld) return;
setButtonHeld(true);
enqueueNetworkWaiting(true, 100);
startTalking();
}, [enqueueNetworkWaiting, startTalking]);
}, [enqueueNetworkWaiting, startTalking, buttonHeld]);
const unhold = useCallback(() => {
setButtonHeld(false);
setNetworkWaiting(false);
stopTalking();
}, [setNetworkWaiting, stopTalking]);

View File

@@ -28,6 +28,7 @@
display: flex;
flex-direction: column;
margin: 20px;
text-align: center;
}
.participants > p {
@@ -41,6 +42,7 @@
.talkingInfo {
display: flex;
flex-shrink: 0;
flex-direction: column;
align-items: center;
margin-bottom: 20px;

View File

@@ -113,6 +113,7 @@ export const PTTCallView: React.FC<Props> = ({
useModalTriggerState();
const [containerRef, bounds] = useMeasure({ polyfill: ResizeObserver });
const facepileSize = bounds.width < 800 ? "sm" : "md";
const showControls = bounds.height > 500;
const pttButtonSize = 232;
const { audioOutput } = useMediaHandler();
@@ -172,60 +173,67 @@ export const PTTCallView: React.FC<Props> = ({
// https://github.com/vector-im/element-call/issues/328
show={false}
/>
<Header className={styles.header}>
<LeftNav>
<RoomSetupHeaderInfo
roomName={roomName}
avatarUrl={avatarUrl}
onPress={onLeave}
isEmbedded={isEmbedded}
/>
</LeftNav>
<RightNav />
</Header>
{showControls && (
<Header className={styles.header}>
<LeftNav>
<RoomSetupHeaderInfo
roomName={roomName}
avatarUrl={avatarUrl}
onPress={onLeave}
isEmbedded={isEmbedded}
/>
</LeftNav>
<RightNav />
</Header>
)}
<div className={styles.center}>
<div className={styles.participants}>
<p>{`${participants.length} ${
participants.length > 1 ? "people" : "person"
} connected`}</p>
<Facepile
size={facepileSize}
max={8}
className={styles.facepile}
client={client}
participants={participants}
/>
</div>
<div className={styles.footer}>
<OverflowMenu
inCall
roomId={roomId}
client={client}
groupCall={groupCall}
showInvite={false}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
{!isEmbedded && <HangupButton onPress={onLeave} />}
<InviteButton onPress={() => inviteModalState.open()} />
</div>
{showControls && (
<>
<div className={styles.participants}>
<p>{`${participants.length} ${
participants.length > 1 ? "people" : "person"
} connected`}</p>
<Facepile
size={facepileSize}
max={8}
className={styles.facepile}
client={client}
participants={participants}
/>
</div>
<div className={styles.footer}>
<OverflowMenu
inCall
roomId={roomId}
client={client}
groupCall={groupCall}
showInvite={false}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
{!isEmbedded && <HangupButton onPress={onLeave} />}
<InviteButton onPress={() => inviteModalState.open()} />
</div>
</>
)}
<div className={styles.pttButtonContainer}>
{activeSpeakerUserId ? (
<div className={styles.talkingInfo}>
<h2>
{!activeSpeakerIsLocalUser && (
<AudioIcon className={styles.speakerIcon} />
)}
{activeSpeakerIsLocalUser
? "Talking..."
: `${activeSpeakerDisplayName} is talking...`}
</h2>
<Timer value={activeSpeakerUserId} />
</div>
) : (
<div className={styles.talkingInfo} />
)}
{showControls &&
(activeSpeakerUserId ? (
<div className={styles.talkingInfo}>
<h2>
{!activeSpeakerIsLocalUser && (
<AudioIcon className={styles.speakerIcon} />
)}
{activeSpeakerIsLocalUser
? "Talking..."
: `${activeSpeakerDisplayName} is talking...`}
</h2>
<Timer value={activeSpeakerUserId} />
</div>
) : (
<div className={styles.talkingInfo} />
))}
<PTTButton
enabled={!feedbackModalState.isOpen}
showTalkOverError={showTalkOverError}
@@ -241,18 +249,20 @@ export const PTTCallView: React.FC<Props> = ({
enqueueNetworkWaiting={enqueueTalkingExpected}
setNetworkWaiting={setTalkingExpected}
/>
<p className={styles.actionTip}>
{getPromptText(
networkWaiting,
showTalkOverError,
pttButtonHeld,
activeSpeakerIsLocalUser,
talkOverEnabled,
activeSpeakerUserId,
activeSpeakerDisplayName,
connected
)}
</p>
{showControls && (
<p className={styles.actionTip}>
{getPromptText(
networkWaiting,
showTalkOverError,
pttButtonHeld,
activeSpeakerIsLocalUser,
talkOverEnabled,
activeSpeakerUserId,
activeSpeakerDisplayName,
connected
)}
</p>
)}
{userMediaFeeds.map((callFeed) => (
<PTTFeed
key={callFeed.userId}
@@ -260,7 +270,7 @@ export const PTTCallView: React.FC<Props> = ({
audioOutputDevice={audioOutput}
/>
))}
{isAdmin && (
{isAdmin && showControls && (
<Toggle
isSelected={talkOverEnabled}
onChange={setTalkOverEnabled}
@@ -271,7 +281,7 @@ export const PTTCallView: React.FC<Props> = ({
</div>
</div>
{inviteModalState.isOpen && (
{inviteModalState.isOpen && showControls && (
<InviteModal roomId={roomId} {...inviteModalProps} />
)}
</div>

View File

@@ -16,26 +16,21 @@ limitations under the License.
import React, { useCallback, useState } from "react";
import styles from "./RoomAuthView.module.css";
import { useClient } from "../ClientContext";
import { Button } from "../button";
import { Body, Caption, Link, Headline } from "../typography/Typography";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { useLocation } from "react-router-dom";
import { useRecaptcha } from "../auth/useRecaptcha";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { Form } from "../form/Form";
import { UserMenuContainer } from "../UserMenuContainer";
import { generateRandomName } from "../auth/generateRandomName";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
export function RoomAuthView() {
const { setClient } = useClient();
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
const [privacyPolicyUrl, recaptchaKey, register] =
useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const { registerPasswordlessUser, recaptchaId, privacyPolicyUrl } =
useRegisterPasswordlessUser();
const onSubmit = useCallback(
(e) => {
@@ -43,29 +38,13 @@ export function RoomAuthView() {
const data = new FormData(e.target);
const displayName = data.get("displayName");
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
);
setClient(client, session);
}
submit().catch((error) => {
console.error(error);
registerPasswordlessUser(displayName).catch((error) => {
console.error("Failed to register passwordless user", e);
setLoading(false);
setError(error);
reset();
});
},
[register, reset, execute]
[registerPasswordlessUser]
);
const location = useLocation();

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useMemo } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { useLocation, useParams } from "react-router-dom";
import { useClient } from "../ClientContext";
import { ErrorView, LoadingView } from "../FullScreenView";
@@ -22,6 +22,7 @@ import { RoomAuthView } from "./RoomAuthView";
import { GroupCallLoader } from "./GroupCallLoader";
import { GroupCallView } from "./GroupCallView";
import { MediaHandlerProvider } from "../settings/useMediaHandler";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
export function RoomPage() {
const { loading, isAuthenticated, error, client, isPasswordlessUser } =
@@ -29,13 +30,37 @@ export function RoomPage() {
const { roomId: maybeRoomId } = useParams();
const { hash, search } = useLocation();
const [viaServers, isEmbedded] = useMemo(() => {
const [viaServers, isEmbedded, isPtt, displayName] = useMemo(() => {
const params = new URLSearchParams(search);
return [params.getAll("via"), params.has("embed")];
return [
params.getAll("via"),
params.has("embed"),
params.get("ptt") === "true",
params.get("displayName"),
];
}, [search]);
const roomId = (maybeRoomId || hash || "").toLowerCase();
const { registerPasswordlessUser, recaptchaId } =
useRegisterPasswordlessUser();
const [isRegistering, setIsRegistering] = useState(false);
if (loading) {
useEffect(() => {
// If we're not already authed and we've been given a display name as
// a URL param, automatically register a passwordless user
if (!isAuthenticated && displayName) {
setIsRegistering(true);
registerPasswordlessUser(displayName).finally(() => {
setIsRegistering(false);
});
}
}, [
isAuthenticated,
displayName,
setIsRegistering,
registerPasswordlessUser,
]);
if (loading || isRegistering) {
return <LoadingView />;
}
@@ -49,7 +74,12 @@ export function RoomPage() {
return (
<MediaHandlerProvider client={client}>
<GroupCallLoader client={client} roomId={roomId} viaServers={viaServers}>
<GroupCallLoader
client={client}
roomId={roomId}
viaServers={viaServers}
createPtt={isPtt}
>
{(groupCall) => (
<GroupCallView
client={client}

View File

@@ -1,108 +0,0 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useState, useEffect } from "react";
import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
async function fetchGroupCall(
client,
roomIdOrAlias,
viaServers = undefined,
timeout = 5000
) {
const { roomId } = await client.joinRoom(roomIdOrAlias, { viaServers });
return new Promise((resolve, reject) => {
let timeoutId;
function onGroupCallIncoming(groupCall) {
if (groupCall && groupCall.room.roomId === roomId) {
clearTimeout(timeoutId);
client.removeListener("GroupCall.incoming", onGroupCallIncoming);
resolve(groupCall);
}
}
const groupCall = client.getGroupCallForRoom(roomId);
if (groupCall) {
resolve(groupCall);
}
client.on("GroupCall.incoming", onGroupCallIncoming);
if (timeout) {
timeoutId = setTimeout(() => {
client.removeListener("GroupCall.incoming", onGroupCallIncoming);
reject(new Error("Fetching group call timed out."));
}, timeout);
}
});
}
export function useLoadGroupCall(client, roomId, viaServers, createIfNotFound) {
const [state, setState] = useState({
loading: true,
error: undefined,
groupCall: undefined,
});
useEffect(() => {
async function fetchOrCreateGroupCall() {
try {
const groupCall = await fetchGroupCall(
client,
roomId,
viaServers,
30000
);
return groupCall;
} catch (error) {
if (
createIfNotFound &&
(error.errcode === "M_NOT_FOUND" ||
(error.message &&
error.message.indexOf("Failed to fetch alias") !== -1)) &&
isLocalRoomId(roomId)
) {
const roomName = roomNameFromRoomId(roomId);
await createRoom(client, roomName);
const groupCall = await fetchGroupCall(
client,
roomId,
viaServers,
30000
);
return groupCall;
}
throw error;
}
}
setState({ loading: true });
fetchOrCreateGroupCall()
.then((groupCall) =>
setState((prevState) => ({ ...prevState, loading: false, groupCall }))
)
.catch((error) =>
setState((prevState) => ({ ...prevState, loading: false, error }))
);
}, [client, roomId, state.reloadId, createIfNotFound, viaServers]);
return state;
}

View File

@@ -0,0 +1,154 @@
/*
Copyright 2022 Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { useState, useEffect } from "react";
import { EventType } from "matrix-js-sdk/src/@types/event";
import {
GroupCallType,
GroupCallIntent,
} from "matrix-js-sdk/src/webrtc/groupCall";
import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler";
import { ClientEvent } from "matrix-js-sdk/src/client";
import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
export interface GroupCallLoadState {
loading: boolean;
error?: Error;
groupCall?: GroupCall;
}
export const useLoadGroupCall = (
client: MatrixClient,
roomIdOrAlias: string,
viaServers: string[],
createPtt: boolean
): GroupCallLoadState => {
const [state, setState] = useState<GroupCallLoadState>({ loading: true });
useEffect(() => {
setState({ loading: true });
const waitForRoom = async (roomId: string): Promise<Room> => {
const room = client.getRoom(roomId);
if (room) return room;
console.log(`Room ${roomId} hasn't arrived yet: waiting`);
const waitPromise = new Promise<Room>((resolve) => {
const onRoomEvent = async (room: Room) => {
if (room.roomId === roomId) {
client.removeListener(ClientEvent.Room, onRoomEvent);
resolve(room);
}
};
client.on(ClientEvent.Room, onRoomEvent);
});
// race the promise with a timeout so we don't
// wait forever for the room
const timeoutPromise = new Promise<Room>((_, reject) => {
setTimeout(() => {
reject(new Error("Timed out trying to join room"));
}, 30000);
});
return Promise.race([waitPromise, timeoutPromise]);
};
const fetchOrCreateRoom = async (): Promise<Room> => {
try {
const room = await client.joinRoom(roomIdOrAlias, { viaServers });
// wait for the room to come down the sync stream, otherwise
// client.getRoom() won't return the room.
return waitForRoom(room.roomId);
} catch (error) {
if (
isLocalRoomId(roomIdOrAlias) &&
(error.errcode === "M_NOT_FOUND" ||
(error.message &&
error.message.indexOf("Failed to fetch alias") !== -1))
) {
// The room doesn't exist, but we can create it
const [, roomId] = await createRoom(
client,
roomNameFromRoomId(roomIdOrAlias)
);
// likewise, wait for the room
return await waitForRoom(roomId);
} else {
throw error;
}
}
};
const fetchOrCreateGroupCall = async (): Promise<GroupCall> => {
const room = await fetchOrCreateRoom();
const groupCall = client.getGroupCallForRoom(room.roomId);
if (groupCall) return groupCall;
if (
room.currentState.mayClientSendStateEvent(
EventType.GroupCallPrefix,
client
)
) {
// The call doesn't exist, but we can create it
console.log(`Creating ${createPtt ? "PTT" : "video"} group call room`);
return await client.createGroupCall(
room.roomId,
createPtt ? GroupCallType.Voice : GroupCallType.Video,
createPtt,
GroupCallIntent.Room
);
}
// We don't have permission to create the call, so all we can do is wait
// for one to come in
return new Promise((resolve, reject) => {
const onGroupCallIncoming = (groupCall: GroupCall) => {
if (groupCall?.room.roomId === room.roomId) {
clearTimeout(timeout);
client.off(
GroupCallEventHandlerEvent.Incoming,
onGroupCallIncoming
);
resolve(groupCall);
}
};
client.on(GroupCallEventHandlerEvent.Incoming, onGroupCallIncoming);
const timeout = setTimeout(() => {
client.off(GroupCallEventHandlerEvent.Incoming, onGroupCallIncoming);
reject(new Error("Fetching group call timed out."));
}, 30000);
});
};
fetchOrCreateGroupCall()
.then((groupCall) =>
setState((prevState) => ({ ...prevState, loading: false, groupCall }))
)
.catch((error) =>
setState((prevState) => ({ ...prevState, loading: false, error }))
);
}, [client, roomIdOrAlias, viaServers, createPtt]);
return state;
};

View File

@@ -130,7 +130,7 @@ export const usePTT = (
const onMuteStateChanged = useCallback(() => {
const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall);
let blocked = false;
let blocked = transmitBlocked;
if (activeSpeakerUserId === null && activeSpeakerFeed !== null) {
if (activeSpeakerFeed.userId === client.getUserId()) {
playClip(PTTClipID.START_TALKING_LOCAL);
@@ -141,8 +141,8 @@ export const usePTT = (
playClip(PTTClipID.END_TALKING);
} else if (
pttButtonHeld &&
activeSpeakerUserId === client.getUserId() &&
activeSpeakerFeed?.userId !== client.getUserId()
activeSpeakerFeed?.userId !== client.getUserId() &&
!transmitBlocked
) {
// We were talking but we've been cut off: mute our own mic
// (this is the easier way of cutting other speakers off if an
@@ -167,6 +167,7 @@ export const usePTT = (
client,
userMediaFeeds,
setMicMuteWrapper,
transmitBlocked,
]);
useEffect(() => {

View File

@@ -77,8 +77,12 @@ export const SettingsModal = (props: Props) => {
selectedKey={audioInput}
onSelectionChange={setAudioInput}
>
{audioInputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
{audioInputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: `Microphone ${index + 1}`}
</Item>
))}
</SelectInput>
{audioOutputs.length > 0 && (
@@ -87,8 +91,12 @@ export const SettingsModal = (props: Props) => {
selectedKey={audioOutput}
onSelectionChange={setAudioOutput}
>
{audioOutputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
{audioOutputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: `Speaker ${index + 1}`}
</Item>
))}
</SelectInput>
)}
@@ -118,8 +126,12 @@ export const SettingsModal = (props: Props) => {
selectedKey={videoInput}
onSelectionChange={setVideoInput}
>
{videoInputs.map(({ deviceId, label }) => (
<Item key={deviceId}>{label}</Item>
{videoInputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: `Camera ${index + 1}`}
</Item>
))}
</SelectInput>
</TabItem>

View File

@@ -202,7 +202,12 @@ export const useSpatialMediaStream = (
const sourceRef = useRef<MediaStreamAudioSourceNode>();
useEffect(() => {
if (spatialAudio && tileRef.current && !mute) {
if (
spatialAudio &&
tileRef.current &&
!mute &&
stream.getAudioTracks().length > 0
) {
if (!pannerNodeRef.current) {
pannerNodeRef.current = new PannerNode(audioContext, {
panningModel: "HRTF",

6848
yarn.lock

File diff suppressed because it is too large Load Diff