Merge branch 'main' into matroska
This commit is contained in:
commit
7fab4ca1ba
34 changed files with 4059 additions and 3874 deletions
.github/ISSUE_TEMPLATE
package.jsonsrc
ClientContext.tsxFacepile.jsxHeader.jsx
yarn.lockbutton
main.tsxmatrix-utils.tsprofile
room
AudioPreview.jsxGroupCallLoader.jsxGroupCallView.jsxInCallView.jsxLobbyView.jsxPTTButton.module.cssPTTButton.tsxPTTCallView.module.cssPTTCallView.tsxRoomPage.jsxuseLoadGroupCall.jsusePTT.ts
settings
sound
video-grid
67
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
67
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
|
@ -0,0 +1,67 @@
|
|||
name: Bug report
|
||||
description: Create a report to help us improve
|
||||
labels: [T-Defect]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
|
||||
Please report security issues by email to security@matrix.org
|
||||
- type: textarea
|
||||
id: reproduction-steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Please attach screenshots, videos or logs if you can.
|
||||
placeholder: Tell us what you see!
|
||||
value: |
|
||||
1. Where are you starting? What can you see?
|
||||
2. What do you click?
|
||||
3. More steps…
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: result
|
||||
attributes:
|
||||
label: Outcome
|
||||
placeholder: Tell us what went wrong
|
||||
value: |
|
||||
#### What did you expect?
|
||||
|
||||
#### What happened instead?
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
placeholder: Windows, macOS, Ubuntu, Android…
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser information
|
||||
description: Which browser are you using? Which version?
|
||||
placeholder: e.g. Chromium Version 92.0.4515.131
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: webapp-url
|
||||
attributes:
|
||||
label: URL for webapp
|
||||
description: Which URL are you using to access the webapp? If a private server, tell us what version of Element Call you are using.
|
||||
placeholder: e.g. call.element.io
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: rageshake
|
||||
attributes:
|
||||
label: Will you send logs?
|
||||
description: |
|
||||
To send them, press the 'Submit Feedback' button and check 'Include Debug Logs'. Please link to this issue in the description field.
|
||||
options:
|
||||
- 'Yes'
|
||||
- 'No'
|
||||
validations:
|
||||
required: true
|
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions & support
|
||||
url: https://matrix.to/#/#webrtc:matrix.org
|
||||
about: Please ask and answer questions here.
|
36
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
36
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
|
@ -0,0 +1,36 @@
|
|||
name: Enhancement request
|
||||
description: Do you have a suggestion or feature request?
|
||||
labels: [T-Enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to propose a new feature or make a suggestion.
|
||||
- type: textarea
|
||||
id: usecase
|
||||
attributes:
|
||||
label: Your use case
|
||||
description: What would you like to be able to do? Please feel welcome to include screenshots or mock ups.
|
||||
placeholder: Tell us what you would like to do!
|
||||
value: |
|
||||
#### What would you like to do?
|
||||
|
||||
#### Why would you like to do it?
|
||||
|
||||
#### How would you like to achieve it?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternative
|
||||
attributes:
|
||||
label: Have you considered any alternatives?
|
||||
placeholder: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional context
|
||||
placeholder: Is there anything else you'd like to add?
|
||||
validations:
|
||||
required: false
|
|
@ -38,12 +38,12 @@
|
|||
"classnames": "^2.3.1",
|
||||
"color-hash": "^2.0.1",
|
||||
"events": "^3.3.0",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#ebcb26f1b3b9e2d709615fde03f9ce6ac77871f1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#984dd26a138411ef73903ff4e635f2752e0829f2",
|
||||
"matrix-widget-api": "^0.1.0-beta.18",
|
||||
"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",
|
||||
|
|
|
@ -72,7 +72,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 },
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -77,9 +77,23 @@ export function RoomHeaderInfo({ roomName, avatarUrl }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function RoomSetupHeaderInfo({ roomName, avatarUrl, ...rest }) {
|
||||
export function RoomSetupHeaderInfo({
|
||||
roomName,
|
||||
avatarUrl,
|
||||
isEmbedded,
|
||||
...rest
|
||||
}) {
|
||||
const ref = useRef();
|
||||
const { buttonProps } = useButton(rest, ref);
|
||||
|
||||
if (isEmbedded) {
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button className={styles.backButton} ref={ref} {...buttonProps}>
|
||||
<ArrowLeftIcon width={16} height={16} />
|
||||
|
|
|
@ -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}>
|
|
@ -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 (
|
|
@ -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(
|
|
@ -36,6 +36,14 @@ initRageshake();
|
|||
|
||||
console.info(`matrix-video-chat ${import.meta.env.VITE_APP_VERSION || "dev"}`);
|
||||
|
||||
if (!window.isSecureContext) {
|
||||
throw new Error(
|
||||
"This app cannot run in an insecure context. To fix this, access the app " +
|
||||
"via a local loopback address, or serve it over HTTPS.\n" +
|
||||
"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts"
|
||||
);
|
||||
}
|
||||
|
||||
if (import.meta.env.VITE_CUSTOM_THEME) {
|
||||
const style = document.documentElement.style;
|
||||
style.setProperty("--accent", import.meta.env.VITE_THEME_ACCENT as string);
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
|
||||
import { WidgetApi } from "matrix-widget-api";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import IndexedDBWorker from "./IndexedDBWorker?worker";
|
||||
|
||||
|
@ -138,6 +139,19 @@ export async function initClient(
|
|||
storeOpts.cryptoStore = new MemoryCryptoStore();
|
||||
}
|
||||
|
||||
// XXX: we read from the URL search params in RoomPage too:
|
||||
// it would be much better to read them in one place and pass
|
||||
// the values around, but we initialise the matrix client in
|
||||
// many different places so we'd have to pass it into all of
|
||||
// them.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
// disable e2e only if enableE2e=false is given
|
||||
const enableE2e = params.get("enableE2e") !== "false";
|
||||
|
||||
if (!enableE2e) {
|
||||
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
|
||||
}
|
||||
|
||||
const client = createClient({
|
||||
...storeOpts,
|
||||
...clientOptions,
|
||||
|
@ -145,6 +159,7 @@ export async function initClient(
|
|||
// Use a relatively low timeout for API calls: this is a realtime app
|
||||
// so we don't want API calls taking ages, we'd rather they just fail.
|
||||
localTimeoutMs: 5000,
|
||||
useE2eForGroupCall: enableE2e,
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
|
@ -15,6 +15,8 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { Button } from "../button";
|
||||
import { useProfile } from "./useProfile";
|
||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
|
@ -22,7 +24,12 @@ import { Modal, ModalContent } from "../Modal";
|
|||
import { AvatarInputField } from "../input/AvatarInputField";
|
||||
import styles from "./ProfileModal.module.css";
|
||||
|
||||
export function ProfileModal({ client, ...rest }) {
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
onClose: () => {};
|
||||
[rest: string]: unknown;
|
||||
}
|
||||
export function ProfileModal({ client, ...rest }: Props) {
|
||||
const { onClose } = rest;
|
||||
const {
|
||||
success,
|
||||
|
@ -50,13 +57,20 @@ export function ProfileModal({ client, ...rest }) {
|
|||
(e) => {
|
||||
e.preventDefault();
|
||||
const data = new FormData(e.target);
|
||||
const displayName = data.get("displayName");
|
||||
const avatar = data.get("avatar");
|
||||
const displayNameDataEntry = data.get("displayName");
|
||||
const avatar: File | string = data.get("avatar");
|
||||
|
||||
const avatarSize =
|
||||
typeof avatar == "string" ? avatar.length : avatar.size;
|
||||
const displayName =
|
||||
typeof displayNameDataEntry == "string"
|
||||
? displayNameDataEntry
|
||||
: displayNameDataEntry.name;
|
||||
|
||||
saveProfile({
|
||||
displayName,
|
||||
avatar: avatar && avatar.size > 0 ? avatar : undefined,
|
||||
removeAvatar: removeAvatar && (!avatar || avatar.size === 0),
|
||||
avatar: avatar && avatarSize > 0 ? avatar : undefined,
|
||||
removeAvatar: removeAvatar && (!avatar || avatarSize === 0),
|
||||
});
|
||||
},
|
||||
[saveProfile, removeAvatar]
|
|
@ -14,11 +14,33 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import { User, UserEvent } from "matrix-js-sdk/src/models/user";
|
||||
import { FileType } from "matrix-js-sdk/src/http-api";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
|
||||
export function useProfile(client) {
|
||||
interface ProfileLoadState {
|
||||
success?: boolean;
|
||||
loading?: boolean;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
type ProfileSaveCallback = ({
|
||||
displayName,
|
||||
avatar,
|
||||
removeAvatar,
|
||||
}: {
|
||||
displayName: string;
|
||||
avatar: FileType;
|
||||
removeAvatar: boolean;
|
||||
}) => Promise<void>;
|
||||
|
||||
export function useProfile(client: MatrixClient) {
|
||||
const [{ loading, displayName, avatarUrl, error, success }, setState] =
|
||||
useState(() => {
|
||||
useState<ProfileLoadState>(() => {
|
||||
const user = client?.getUser(client.getUserId());
|
||||
|
||||
return {
|
||||
|
@ -31,7 +53,10 @@ export function useProfile(client) {
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onChangeUser = (_event, { displayName, avatarUrl }) => {
|
||||
const onChangeUser = (
|
||||
_event: MatrixEvent,
|
||||
{ displayName, avatarUrl }: User
|
||||
) => {
|
||||
setState({
|
||||
success: false,
|
||||
loading: false,
|
||||
|
@ -41,24 +66,24 @@ export function useProfile(client) {
|
|||
});
|
||||
};
|
||||
|
||||
let user;
|
||||
let user: User;
|
||||
|
||||
if (client) {
|
||||
const userId = client.getUserId();
|
||||
user = client.getUser(userId);
|
||||
user.on("User.displayName", onChangeUser);
|
||||
user.on("User.avatarUrl", onChangeUser);
|
||||
user.on(UserEvent.DisplayName, onChangeUser);
|
||||
user.on(UserEvent.AvatarUrl, onChangeUser);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (user) {
|
||||
user.removeListener("User.displayName", onChangeUser);
|
||||
user.removeListener("User.avatarUrl", onChangeUser);
|
||||
user.removeListener(UserEvent.DisplayName, onChangeUser);
|
||||
user.removeListener(UserEvent.AvatarUrl, onChangeUser);
|
||||
}
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
const saveProfile = useCallback(
|
||||
const saveProfile = useCallback<ProfileSaveCallback>(
|
||||
async ({ displayName, avatar, removeAvatar }) => {
|
||||
if (client) {
|
||||
setState((prev) => ({
|
||||
|
@ -71,7 +96,7 @@ export function useProfile(client) {
|
|||
try {
|
||||
await client.setDisplayName(displayName);
|
||||
|
||||
let mxcAvatarUrl;
|
||||
let mxcAvatarUrl: string;
|
||||
|
||||
if (removeAvatar) {
|
||||
await client.setAvatarUrl("");
|
||||
|
@ -87,11 +112,11 @@ export function useProfile(client) {
|
|||
loading: false,
|
||||
success: true,
|
||||
}));
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error,
|
||||
error: error instanceof Error ? error : Error(error as string),
|
||||
success: false,
|
||||
}));
|
||||
}
|
||||
|
@ -102,5 +127,12 @@ export function useProfile(client) {
|
|||
[client]
|
||||
);
|
||||
|
||||
return { loading, error, displayName, avatarUrl, saveProfile, success };
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
saveProfile,
|
||||
success,
|
||||
};
|
||||
}
|
|
@ -53,8 +53,12 @@ export function AudioPreview({
|
|||
onSelectionChange={setAudioInput}
|
||||
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>
|
||||
)}
|
||||
|
|
|
@ -19,12 +19,19 @@ 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
|
||||
true,
|
||||
createPtt
|
||||
);
|
||||
|
||||
usePageTitle(groupCall ? groupCall.room.name : "Loading...");
|
||||
|
|
|
@ -30,6 +30,7 @@ import { useLocationNavigation } from "../useLocationNavigation";
|
|||
export function GroupCallView({
|
||||
client,
|
||||
isPasswordlessUser,
|
||||
isEmbedded,
|
||||
roomId,
|
||||
groupCall,
|
||||
}) {
|
||||
|
@ -60,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);
|
||||
|
||||
|
@ -92,6 +96,7 @@ export function GroupCallView({
|
|||
participants={participants}
|
||||
userMediaFeeds={userMediaFeeds}
|
||||
onLeave={onLeave}
|
||||
isEmbedded={isEmbedded}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
@ -126,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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,7 +44,7 @@ import { useShowInspector } from "../settings/useSetting";
|
|||
import { useModalTriggerState } from "../Modal";
|
||||
import { useAudioContext } from "../video-grid/useMediaStream";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in navigator.mediaDevices;
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
|
||||
// or with getUsermedia and getDisplaymedia being used within the same session.
|
||||
// For now we can disable screensharing in Safari.
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState, createRef } from "react";
|
||||
import React, { useCallback, useState, useRef } from "react";
|
||||
import classNames from "classnames";
|
||||
import { useSpring, animated } from "@react-spring/web";
|
||||
|
||||
|
@ -54,16 +54,20 @@ export const PTTButton: React.FC<Props> = ({
|
|||
enqueueNetworkWaiting,
|
||||
setNetworkWaiting,
|
||||
}) => {
|
||||
const buttonRef = createRef<HTMLButtonElement>();
|
||||
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]);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -93,6 +93,7 @@ interface Props {
|
|||
participants: RoomMember[];
|
||||
userMediaFeeds: CallFeed[];
|
||||
onLeave: () => void;
|
||||
isEmbedded: boolean;
|
||||
}
|
||||
|
||||
export const PTTCallView: React.FC<Props> = ({
|
||||
|
@ -104,6 +105,7 @@ export const PTTCallView: React.FC<Props> = ({
|
|||
participants,
|
||||
userMediaFeeds,
|
||||
onLeave,
|
||||
isEmbedded,
|
||||
}) => {
|
||||
const { modalState: inviteModalState, modalProps: inviteModalProps } =
|
||||
useModalTriggerState();
|
||||
|
@ -111,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();
|
||||
|
@ -170,59 +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}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
<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}
|
||||
|
@ -238,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}
|
||||
|
@ -257,7 +270,7 @@ export const PTTCallView: React.FC<Props> = ({
|
|||
audioOutputDevice={audioOutput}
|
||||
/>
|
||||
))}
|
||||
{isAdmin && (
|
||||
{isAdmin && showControls && (
|
||||
<Toggle
|
||||
isSelected={talkOverEnabled}
|
||||
onChange={setTalkOverEnabled}
|
||||
|
@ -268,7 +281,7 @@ export const PTTCallView: React.FC<Props> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{inviteModalState.isOpen && (
|
||||
{inviteModalState.isOpen && showControls && (
|
||||
<InviteModal roomId={roomId} {...inviteModalProps} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -29,9 +29,13 @@ export function RoomPage() {
|
|||
|
||||
const { roomId: maybeRoomId } = useParams();
|
||||
const { hash, search } = useLocation();
|
||||
const [viaServers] = useMemo(() => {
|
||||
const [viaServers, isEmbedded, isPtt] = useMemo(() => {
|
||||
const params = new URLSearchParams(search);
|
||||
return [params.getAll("via")];
|
||||
return [
|
||||
params.getAll("via"),
|
||||
params.has("embed"),
|
||||
params.get("ptt") === "true",
|
||||
];
|
||||
}, [search]);
|
||||
const roomId = (maybeRoomId || hash || "").toLowerCase();
|
||||
|
||||
|
@ -49,13 +53,19 @@ 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}
|
||||
roomId={roomId}
|
||||
groupCall={groupCall}
|
||||
isPasswordlessUser={isPasswordlessUser}
|
||||
isEmbedded={isEmbedded}
|
||||
/>
|
||||
)}
|
||||
</GroupCallLoader>
|
||||
|
|
|
@ -54,7 +54,13 @@ async function fetchGroupCall(
|
|||
});
|
||||
}
|
||||
|
||||
export function useLoadGroupCall(client, roomId, viaServers, createIfNotFound) {
|
||||
export function useLoadGroupCall(
|
||||
client,
|
||||
roomId,
|
||||
viaServers,
|
||||
createIfNotFound,
|
||||
createPtt
|
||||
) {
|
||||
const [state, setState] = useState({
|
||||
loading: true,
|
||||
error: undefined,
|
||||
|
@ -80,7 +86,7 @@ export function useLoadGroupCall(client, roomId, viaServers, createIfNotFound) {
|
|||
isLocalRoomId(roomId)
|
||||
) {
|
||||
const roomName = roomNameFromRoomId(roomId);
|
||||
await createRoom(client, roomName);
|
||||
await createRoom(client, roomName, createPtt);
|
||||
const groupCall = await fetchGroupCall(
|
||||
client,
|
||||
roomId,
|
||||
|
@ -103,7 +109,7 @@ export function useLoadGroupCall(client, roomId, viaServers, createIfNotFound) {
|
|||
.catch((error) =>
|
||||
setState((prevState) => ({ ...prevState, loading: false, error }))
|
||||
);
|
||||
}, [client, roomId, state.reloadId, createIfNotFound, viaServers]);
|
||||
}, [client, roomId, state.reloadId, createIfNotFound, viaServers, createPtt]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -15,6 +15,8 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Item } from "@react-stately/collections";
|
||||
|
||||
import { Modal } from "../Modal";
|
||||
import styles from "./SettingsModal.module.css";
|
||||
import { TabContainer, TabItem } from "../tabs/Tabs";
|
||||
|
@ -22,7 +24,6 @@ import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
|
|||
import { ReactComponent as VideoIcon } from "../icons/Video.svg";
|
||||
import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
|
||||
import { SelectInput } from "../input/SelectInput";
|
||||
import { Item } from "@react-stately/collections";
|
||||
import { useMediaHandler } from "./useMediaHandler";
|
||||
import { useSpatialAudio, useShowInspector } from "./useSetting";
|
||||
import { FieldRow, InputField } from "../input/Input";
|
||||
|
@ -30,7 +31,13 @@ import { Button } from "../button";
|
|||
import { useDownloadDebugLog } from "./submit-rageshake";
|
||||
import { Body } from "../typography/Typography";
|
||||
|
||||
export const SettingsModal = (props) => {
|
||||
interface Props {
|
||||
setShowInspector: boolean;
|
||||
showInspector: boolean;
|
||||
[rest: string]: unknown;
|
||||
}
|
||||
|
||||
export const SettingsModal = (props: Props) => {
|
||||
const {
|
||||
audioInput,
|
||||
audioInputs,
|
||||
|
@ -42,6 +49,7 @@ export const SettingsModal = (props) => {
|
|||
audioOutputs,
|
||||
setAudioOutput,
|
||||
} = useMediaHandler();
|
||||
|
||||
const [spatialAudio, setSpatialAudio] = useSpatialAudio();
|
||||
const [showInspector, setShowInspector] = useShowInspector();
|
||||
|
||||
|
@ -69,8 +77,12 @@ export const SettingsModal = (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 && (
|
||||
|
@ -79,8 +91,12 @@ export const SettingsModal = (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>
|
||||
)}
|
||||
|
@ -91,7 +107,9 @@ export const SettingsModal = (props) => {
|
|||
type="checkbox"
|
||||
checked={spatialAudio}
|
||||
description="This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)"
|
||||
onChange={(e) => setSpatialAudio(e.target.checked)}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setSpatialAudio(event.target.checked)
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
</TabItem>
|
||||
|
@ -108,8 +126,12 @@ export const SettingsModal = (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>
|
||||
|
@ -133,7 +155,9 @@ export const SettingsModal = (props) => {
|
|||
label="Show Call Inspector"
|
||||
type="checkbox"
|
||||
checked={showInspector}
|
||||
onChange={(e) => setShowInspector(e.target.checked)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setShowInspector(e.target.checked)
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
/*
|
||||
Copyright 2017 OpenMarket Ltd
|
||||
Copyright 2018 New Vector Ltd
|
||||
|
@ -37,19 +38,33 @@ limitations under the License.
|
|||
// actually timestamps. We then purge the remaining logs. We also do this
|
||||
// purge on startup to prevent logs from accumulating.
|
||||
|
||||
// the frequency with which we flush to indexeddb
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
|
||||
// the frequency with which we flush to indexeddb
|
||||
const FLUSH_RATE_MS = 30 * 1000;
|
||||
|
||||
// the length of log data we keep in indexeddb (and include in the reports)
|
||||
const MAX_LOG_SIZE = 1024 * 1024 * 5; // 5 MB
|
||||
|
||||
// A class which monkey-patches the global console and stores log lines.
|
||||
export class ConsoleLogger {
|
||||
logs = "";
|
||||
type LogFunction = (
|
||||
...args: (Error | DOMException | object | string)[]
|
||||
) => void;
|
||||
type LogFunctionName = "log" | "info" | "warn" | "error";
|
||||
|
||||
monkeyPatch(consoleObj) {
|
||||
// A class which monkey-patches the global console and stores log lines.
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
lines: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export class ConsoleLogger {
|
||||
private logs = "";
|
||||
private originalFunctions: { [key in LogFunctionName]?: LogFunction } = {};
|
||||
|
||||
public monkeyPatch(consoleObj: Console): void {
|
||||
// Monkey-patch console logging
|
||||
const consoleFunctionsToLevels = {
|
||||
log: "I",
|
||||
|
@ -60,6 +75,7 @@ export class ConsoleLogger {
|
|||
Object.keys(consoleFunctionsToLevels).forEach((fnName) => {
|
||||
const level = consoleFunctionsToLevels[fnName];
|
||||
const originalFn = consoleObj[fnName].bind(consoleObj);
|
||||
this.originalFunctions[fnName] = originalFn;
|
||||
consoleObj[fnName] = (...args) => {
|
||||
this.log(level, ...args);
|
||||
originalFn(...args);
|
||||
|
@ -67,7 +83,17 @@ export class ConsoleLogger {
|
|||
});
|
||||
}
|
||||
|
||||
log(level, ...args) {
|
||||
public bypassRageshake(
|
||||
fnName: LogFunctionName,
|
||||
...args: (Error | DOMException | object | string)[]
|
||||
): void {
|
||||
this.originalFunctions[fnName](...args);
|
||||
}
|
||||
|
||||
public log(
|
||||
level: string,
|
||||
...args: (Error | DOMException | object | string)[]
|
||||
): void {
|
||||
// We don't know what locale the user may be running so use ISO strings
|
||||
const ts = new Date().toISOString();
|
||||
|
||||
|
@ -78,21 +104,7 @@ export class ConsoleLogger {
|
|||
} else if (arg instanceof Error) {
|
||||
return arg.message + (arg.stack ? `\n${arg.stack}` : "");
|
||||
} else if (typeof arg === "object") {
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch (e) {
|
||||
// In development, it can be useful to log complex cyclic
|
||||
// objects to the console for inspection. This is fine for
|
||||
// the console, but default `stringify` can't handle that.
|
||||
// We workaround this by using a special replacer function
|
||||
// to only log values of the root object and avoid cycles.
|
||||
return JSON.stringify(arg, (key, value) => {
|
||||
if (key && typeof value === "object") {
|
||||
return "<object>";
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
return JSON.stringify(arg, getCircularReplacer());
|
||||
} else {
|
||||
return arg;
|
||||
}
|
||||
|
@ -116,7 +128,7 @@ export class ConsoleLogger {
|
|||
* @param {boolean} keepLogs True to not delete logs after flushing.
|
||||
* @return {string} \n delimited log lines to flush.
|
||||
*/
|
||||
flush(keepLogs) {
|
||||
public flush(keepLogs?: boolean): string {
|
||||
// The ConsoleLogger doesn't care how these end up on disk, it just
|
||||
// flushes them to the caller.
|
||||
if (keepLogs) {
|
||||
|
@ -130,24 +142,23 @@ export class ConsoleLogger {
|
|||
|
||||
// A class which stores log lines in an IndexedDB instance.
|
||||
export class IndexedDBLogStore {
|
||||
index = 0;
|
||||
db = null;
|
||||
flushPromise = null;
|
||||
flushAgainPromise = null;
|
||||
private index = 0;
|
||||
private db: IDBDatabase = null;
|
||||
private flushPromise: Promise<void> = null;
|
||||
private flushAgainPromise: Promise<void> = null;
|
||||
private id: string;
|
||||
|
||||
constructor(indexedDB, logger) {
|
||||
this.indexedDB = indexedDB;
|
||||
this.logger = logger;
|
||||
this.id = "instance-" + Math.random() + Date.now();
|
||||
constructor(private indexedDB: IDBFactory, private logger: ConsoleLogger) {
|
||||
this.id = "instance-" + randomString(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Promise} Resolves when the store is ready.
|
||||
*/
|
||||
connect() {
|
||||
public connect(): Promise<void> {
|
||||
const req = this.indexedDB.open("logs");
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = (event) => {
|
||||
req.onsuccess = (event: Event) => {
|
||||
// @ts-ignore
|
||||
this.db = event.target.result;
|
||||
// Periodically flush logs to local storage / indexeddb
|
||||
|
@ -206,7 +217,7 @@ export class IndexedDBLogStore {
|
|||
*
|
||||
* @return {Promise} Resolved when the logs have been flushed.
|
||||
*/
|
||||
flush() {
|
||||
public flush(): Promise<void> {
|
||||
// check if a flush() operation is ongoing
|
||||
if (this.flushPromise) {
|
||||
if (this.flushAgainPromise) {
|
||||
|
@ -225,7 +236,7 @@ export class IndexedDBLogStore {
|
|||
}
|
||||
// there is no flush promise or there was but it has finished, so do
|
||||
// a brand new one, destroying the chain which may have been built up.
|
||||
this.flushPromise = new Promise((resolve, reject) => {
|
||||
this.flushPromise = new Promise<void>((resolve, reject) => {
|
||||
if (!this.db) {
|
||||
// not connected yet or user rejected access for us to r/w to the db.
|
||||
reject(new Error("No connected database"));
|
||||
|
@ -243,6 +254,7 @@ export class IndexedDBLogStore {
|
|||
};
|
||||
txn.onerror = (event) => {
|
||||
logger.error("Failed to flush logs : ", event);
|
||||
// @ts-ignore
|
||||
reject(new Error("Failed to write logs: " + event.target.errorCode));
|
||||
};
|
||||
objStore.add(this.generateLogEntry(lines));
|
||||
|
@ -264,12 +276,12 @@ export class IndexedDBLogStore {
|
|||
* log ID). The objects have said log ID in an "id" field and "lines" which
|
||||
* is a big string with all the new-line delimited logs.
|
||||
*/
|
||||
async consume() {
|
||||
public async consume(): Promise<LogEntry[]> {
|
||||
const db = this.db;
|
||||
|
||||
// Returns: a string representing the concatenated logs for this ID.
|
||||
// Stops adding log fragments when the size exceeds maxSize
|
||||
function fetchLogs(id, maxSize) {
|
||||
function fetchLogs(id: string, maxSize: number): Promise<string> {
|
||||
const objectStore = db
|
||||
.transaction("logs", "readonly")
|
||||
.objectStore("logs");
|
||||
|
@ -280,9 +292,11 @@ export class IndexedDBLogStore {
|
|||
.openCursor(IDBKeyRange.only(id), "prev");
|
||||
let lines = "";
|
||||
query.onerror = (event) => {
|
||||
// @ts-ignore
|
||||
reject(new Error("Query failed: " + event.target.errorCode));
|
||||
};
|
||||
query.onsuccess = (event) => {
|
||||
// @ts-ignore
|
||||
const cursor = event.target.result;
|
||||
if (!cursor) {
|
||||
resolve(lines);
|
||||
|
@ -299,12 +313,12 @@ export class IndexedDBLogStore {
|
|||
}
|
||||
|
||||
// Returns: A sorted array of log IDs. (newest first)
|
||||
function fetchLogIds() {
|
||||
function fetchLogIds(): Promise<string[]> {
|
||||
// To gather all the log IDs, query for all records in logslastmod.
|
||||
const o = db
|
||||
.transaction("logslastmod", "readonly")
|
||||
.objectStore("logslastmod");
|
||||
return selectQuery(o, undefined, (cursor) => {
|
||||
return selectQuery<{ ts: number; id: string }>(o, undefined, (cursor) => {
|
||||
return {
|
||||
id: cursor.value.id,
|
||||
ts: cursor.value.ts,
|
||||
|
@ -319,13 +333,14 @@ export class IndexedDBLogStore {
|
|||
});
|
||||
}
|
||||
|
||||
function deleteLogs(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
function deleteLogs(id: number): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const txn = db.transaction(["logs", "logslastmod"], "readwrite");
|
||||
const o = txn.objectStore("logs");
|
||||
// only load the key path, not the data which may be huge
|
||||
const query = o.index("id").openKeyCursor(IDBKeyRange.only(id));
|
||||
query.onsuccess = (event) => {
|
||||
// @ts-ignore
|
||||
const cursor = event.target.result;
|
||||
if (!cursor) {
|
||||
return;
|
||||
|
@ -340,6 +355,7 @@ export class IndexedDBLogStore {
|
|||
reject(
|
||||
new Error(
|
||||
"Failed to delete logs for " +
|
||||
// @ts-ignore
|
||||
`'${id}' : ${event.target.errorCode}`
|
||||
)
|
||||
);
|
||||
|
@ -352,7 +368,7 @@ export class IndexedDBLogStore {
|
|||
|
||||
const allLogIds = await fetchLogIds();
|
||||
let removeLogIds = [];
|
||||
const logs = [];
|
||||
const logs: LogEntry[] = [];
|
||||
let size = 0;
|
||||
for (let i = 0; i < allLogIds.length; i++) {
|
||||
const lines = await fetchLogs(allLogIds[i], MAX_LOG_SIZE - size);
|
||||
|
@ -390,7 +406,7 @@ export class IndexedDBLogStore {
|
|||
return logs;
|
||||
}
|
||||
|
||||
generateLogEntry(lines) {
|
||||
private generateLogEntry(lines: string): LogEntry {
|
||||
return {
|
||||
id: this.id,
|
||||
lines: lines,
|
||||
|
@ -398,7 +414,7 @@ export class IndexedDBLogStore {
|
|||
};
|
||||
}
|
||||
|
||||
generateLastModifiedTime() {
|
||||
private generateLastModifiedTime(): { id: string; ts: number } {
|
||||
return {
|
||||
id: this.id,
|
||||
ts: Date.now(),
|
||||
|
@ -416,7 +432,11 @@ export class IndexedDBLogStore {
|
|||
* @return {Promise<T[]>} Resolves to an array of whatever you returned from
|
||||
* resultMapper.
|
||||
*/
|
||||
function selectQuery(store, keyRange, resultMapper) {
|
||||
function selectQuery<T>(
|
||||
store: IDBObjectStore,
|
||||
keyRange: IDBKeyRange,
|
||||
resultMapper: (cursor: IDBCursorWithValue) => T
|
||||
): Promise<T[]> {
|
||||
const query = store.openCursor(keyRange);
|
||||
return new Promise((resolve, reject) => {
|
||||
const results = [];
|
||||
|
@ -437,6 +457,16 @@ function selectQuery(store, keyRange, resultMapper) {
|
|||
};
|
||||
});
|
||||
}
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var, camelcase
|
||||
var mx_rage_store: IndexedDBLogStore;
|
||||
// eslint-disable-next-line no-var, camelcase
|
||||
var mx_rage_logger: ConsoleLogger;
|
||||
// eslint-disable-next-line no-var, camelcase
|
||||
var mx_rage_initPromise: Promise<void>;
|
||||
// eslint-disable-next-line no-var, camelcase
|
||||
var mx_rage_initStoragePromise: Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure rage shaking support for sending bug reports.
|
||||
|
@ -445,7 +475,7 @@ function selectQuery(store, keyRange, resultMapper) {
|
|||
* be set up immediately for the logs.
|
||||
* @return {Promise} Resolves when set up.
|
||||
*/
|
||||
export function init(setUpPersistence = true) {
|
||||
export function init(setUpPersistence = true): Promise<void> {
|
||||
if (global.mx_rage_initPromise) {
|
||||
return global.mx_rage_initPromise;
|
||||
}
|
||||
|
@ -465,7 +495,7 @@ export function init(setUpPersistence = true) {
|
|||
* then this no-ops.
|
||||
* @return {Promise} Resolves when complete.
|
||||
*/
|
||||
export function tryInitStorage() {
|
||||
export function tryInitStorage(): Promise<void> {
|
||||
if (global.mx_rage_initStoragePromise) {
|
||||
return global.mx_rage_initStoragePromise;
|
||||
}
|
||||
|
@ -491,7 +521,7 @@ export function tryInitStorage() {
|
|||
return global.mx_rage_initStoragePromise;
|
||||
}
|
||||
|
||||
export function flush() {
|
||||
export function flush(): Promise<void> {
|
||||
if (!global.mx_rage_store) {
|
||||
return;
|
||||
}
|
||||
|
@ -502,7 +532,7 @@ export function flush() {
|
|||
* Clean up old logs.
|
||||
* @return {Promise} Resolves if cleaned logs.
|
||||
*/
|
||||
export async function cleanup() {
|
||||
export async function cleanup(): Promise<void> {
|
||||
if (!global.mx_rage_store) {
|
||||
return;
|
||||
}
|
||||
|
@ -512,9 +542,9 @@ export async function cleanup() {
|
|||
/**
|
||||
* Get a recent snapshot of the logs, ready for attaching to a bug report
|
||||
*
|
||||
* @return {Array<{lines: string, id, string}>} list of log data
|
||||
* @return {LogEntry[]} list of log data
|
||||
*/
|
||||
export async function getLogsForReport() {
|
||||
export async function getLogsForReport(): Promise<LogEntry[]> {
|
||||
if (!global.mx_rage_logger) {
|
||||
throw new Error("No console logger, did you forget to call init()?");
|
||||
}
|
||||
|
@ -523,7 +553,7 @@ export async function getLogsForReport() {
|
|||
if (global.mx_rage_store) {
|
||||
// flush most recent logs
|
||||
await global.mx_rage_store.flush();
|
||||
return await global.mx_rage_store.consume();
|
||||
return global.mx_rage_store.consume();
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
|
@ -533,3 +563,24 @@ export async function getLogsForReport() {
|
|||
];
|
||||
}
|
||||
}
|
||||
|
||||
type StringifyReplacer = (
|
||||
this: unknown,
|
||||
key: string,
|
||||
value: unknown
|
||||
) => unknown;
|
||||
|
||||
// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value#circular_references
|
||||
// Injects `<$ cycle-trimmed $>` wherever it cuts a cyclical object relationship
|
||||
const getCircularReplacer = (): StringifyReplacer => {
|
||||
const seen = new WeakSet();
|
||||
return (key: string, value: unknown): unknown => {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return "<$ cycle-trimmed $>";
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
};
|
|
@ -15,14 +15,31 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { getLogsForReport } from "./rageshake";
|
||||
import pako from "pako";
|
||||
import { MatrixEvent } from "matrix-js-sdk";
|
||||
import { OverlayTriggerState } from "@react-stately/overlays";
|
||||
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
|
||||
|
||||
import { getLogsForReport } from "./rageshake";
|
||||
import { useClient } from "../ClientContext";
|
||||
import { InspectorContext } from "../room/GroupCallInspector";
|
||||
import { useModalTriggerState } from "../Modal";
|
||||
|
||||
export function useSubmitRageshake() {
|
||||
const { client } = useClient();
|
||||
interface RageShakeSubmitOptions {
|
||||
description: string;
|
||||
roomId: string;
|
||||
label: string;
|
||||
sendLogs: boolean;
|
||||
rageshakeRequestId: string;
|
||||
}
|
||||
|
||||
export function useSubmitRageshake(): {
|
||||
submitRageshake: (opts: RageShakeSubmitOptions) => Promise<void>;
|
||||
sending: boolean;
|
||||
sent: boolean;
|
||||
error: Error;
|
||||
} {
|
||||
const client: MatrixClient = useClient().client;
|
||||
const [{ json }] = useContext(InspectorContext);
|
||||
|
||||
const [{ sending, sent, error }, setState] = useState({
|
||||
|
@ -57,9 +74,12 @@ export function useSubmitRageshake() {
|
|||
opts.description || "User did not supply any additional text."
|
||||
);
|
||||
body.append("app", "matrix-video-chat");
|
||||
body.append("version", import.meta.env.VITE_APP_VERSION || "dev");
|
||||
body.append(
|
||||
"version",
|
||||
(import.meta.env.VITE_APP_VERSION as string) || "dev"
|
||||
);
|
||||
body.append("user_agent", userAgent);
|
||||
body.append("installed_pwa", false);
|
||||
body.append("installed_pwa", "false");
|
||||
body.append("touch_input", touchInput);
|
||||
|
||||
if (client) {
|
||||
|
@ -181,7 +201,11 @@ export function useSubmitRageshake() {
|
|||
|
||||
if (navigator.storage && navigator.storage.estimate) {
|
||||
try {
|
||||
const estimate = await navigator.storage.estimate();
|
||||
const estimate: {
|
||||
quota?: number;
|
||||
usage?: number;
|
||||
usageDetails?: { [x: string]: unknown };
|
||||
} = await navigator.storage.estimate();
|
||||
body.append("storageManager_quota", String(estimate.quota));
|
||||
body.append("storageManager_usage", String(estimate.usage));
|
||||
if (estimate.usageDetails) {
|
||||
|
@ -201,7 +225,6 @@ export function useSubmitRageshake() {
|
|||
for (const entry of logs) {
|
||||
// encode as UTF-8
|
||||
let buf = new TextEncoder().encode(entry.lines);
|
||||
|
||||
// compress
|
||||
buf = pako.gzip(buf);
|
||||
|
||||
|
@ -225,7 +248,7 @@ export function useSubmitRageshake() {
|
|||
}
|
||||
|
||||
await fetch(
|
||||
import.meta.env.VITE_RAGESHAKE_SUBMIT_URL ||
|
||||
(import.meta.env.VITE_RAGESHAKE_SUBMIT_URL as string) ||
|
||||
"https://element.io/bugreports/submit",
|
||||
{
|
||||
method: "POST",
|
||||
|
@ -250,7 +273,7 @@ export function useSubmitRageshake() {
|
|||
};
|
||||
}
|
||||
|
||||
export function useDownloadDebugLog() {
|
||||
export function useDownloadDebugLog(): () => void {
|
||||
const [{ json }] = useContext(InspectorContext);
|
||||
|
||||
const downloadDebugLog = useCallback(() => {
|
||||
|
@ -271,7 +294,10 @@ export function useDownloadDebugLog() {
|
|||
return downloadDebugLog;
|
||||
}
|
||||
|
||||
export function useRageshakeRequest() {
|
||||
export function useRageshakeRequest(): (
|
||||
roomId: string,
|
||||
rageshakeRequestId: string
|
||||
) => void {
|
||||
const { client } = useClient();
|
||||
|
||||
const sendRageshakeRequest = useCallback(
|
||||
|
@ -285,14 +311,27 @@ export function useRageshakeRequest() {
|
|||
|
||||
return sendRageshakeRequest;
|
||||
}
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
interface ModalPropsWithId extends ModalProps {
|
||||
rageshakeRequestId: string;
|
||||
}
|
||||
|
||||
export function useRageshakeRequestModal(roomId) {
|
||||
const { modalState, modalProps } = useModalTriggerState();
|
||||
const { client } = useClient();
|
||||
const [rageshakeRequestId, setRageshakeRequestId] = useState();
|
||||
export function useRageshakeRequestModal(roomId: string): {
|
||||
modalState: OverlayTriggerState;
|
||||
modalProps: ModalPropsWithId;
|
||||
} {
|
||||
const { modalState, modalProps } = useModalTriggerState() as {
|
||||
modalState: OverlayTriggerState;
|
||||
modalProps: ModalProps;
|
||||
};
|
||||
const client: MatrixClient = useClient().client;
|
||||
const [rageshakeRequestId, setRageshakeRequestId] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
const onEvent = (event) => {
|
||||
const onEvent = (event: MatrixEvent) => {
|
||||
const type = event.getType();
|
||||
|
||||
if (
|
||||
|
@ -305,10 +344,10 @@ export function useRageshakeRequestModal(roomId) {
|
|||
}
|
||||
};
|
||||
|
||||
client.on("event", onEvent);
|
||||
client.on(ClientEvent.Event, onEvent);
|
||||
|
||||
return () => {
|
||||
client.removeListener("event", onEvent);
|
||||
client.removeListener(ClientEvent.Event, onEvent);
|
||||
};
|
||||
}, [modalState.open, roomId, client, modalState]);
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
/*
|
||||
Copyright 2022 Matrix.org Foundation C.I.C.
|
||||
|
||||
|
@ -14,6 +15,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "matrix-js-sdk";
|
||||
import { MediaHandlerEvent } from "matrix-js-sdk/src/webrtc/mediaHandler";
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
|
@ -23,9 +26,27 @@ import React, {
|
|||
createContext,
|
||||
} from "react";
|
||||
|
||||
const MediaHandlerContext = createContext();
|
||||
export interface MediaHandlerContextInterface {
|
||||
audioInput: string;
|
||||
audioInputs: MediaDeviceInfo[];
|
||||
setAudioInput: (deviceId: string) => void;
|
||||
videoInput: string;
|
||||
videoInputs: MediaDeviceInfo[];
|
||||
setVideoInput: (deviceId: string) => void;
|
||||
audioOutput: string;
|
||||
audioOutputs: MediaDeviceInfo[];
|
||||
setAudioOutput: (deviceId: string) => void;
|
||||
}
|
||||
|
||||
function getMediaPreferences() {
|
||||
const MediaHandlerContext =
|
||||
createContext<MediaHandlerContextInterface>(undefined);
|
||||
|
||||
interface MediaPreferences {
|
||||
audioInput?: string;
|
||||
videoInput?: string;
|
||||
audioOutput?: string;
|
||||
}
|
||||
function getMediaPreferences(): MediaPreferences {
|
||||
const mediaPreferences = localStorage.getItem("matrix-media-preferences");
|
||||
|
||||
if (mediaPreferences) {
|
||||
|
@ -39,8 +60,8 @@ function getMediaPreferences() {
|
|||
}
|
||||
}
|
||||
|
||||
function updateMediaPreferences(newPreferences) {
|
||||
const oldPreferences = getMediaPreferences(newPreferences);
|
||||
function updateMediaPreferences(newPreferences: MediaPreferences): void {
|
||||
const oldPreferences = getMediaPreferences();
|
||||
|
||||
localStorage.setItem(
|
||||
"matrix-media-preferences",
|
||||
|
@ -50,8 +71,11 @@ function updateMediaPreferences(newPreferences) {
|
|||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaHandlerProvider({ client, children }) {
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
children: JSX.Element[];
|
||||
}
|
||||
export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
|
||||
const [
|
||||
{
|
||||
audioInput,
|
||||
|
@ -72,7 +96,9 @@ export function MediaHandlerProvider({ client, children }) {
|
|||
);
|
||||
|
||||
return {
|
||||
// @ts-ignore, ignore that audioInput is a private members of mediaHandler
|
||||
audioInput: mediaHandler.audioInput,
|
||||
// @ts-ignore, ignore that videoInput is a private members of mediaHandler
|
||||
videoInput: mediaHandler.videoInput,
|
||||
audioOutput: undefined,
|
||||
audioInputs: [],
|
||||
|
@ -84,7 +110,7 @@ export function MediaHandlerProvider({ client, children }) {
|
|||
useEffect(() => {
|
||||
const mediaHandler = client.getMediaHandler();
|
||||
|
||||
function updateDevices() {
|
||||
function updateDevices(): void {
|
||||
navigator.mediaDevices.enumerateDevices().then((devices) => {
|
||||
const mediaPreferences = getMediaPreferences();
|
||||
|
||||
|
@ -92,9 +118,10 @@ export function MediaHandlerProvider({ client, children }) {
|
|||
(device) => device.kind === "audioinput"
|
||||
);
|
||||
const audioConnected = audioInputs.some(
|
||||
// @ts-ignore
|
||||
(device) => device.deviceId === mediaHandler.audioInput
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
let audioInput = mediaHandler.audioInput;
|
||||
|
||||
if (!audioConnected && audioInputs.length > 0) {
|
||||
|
@ -105,9 +132,11 @@ export function MediaHandlerProvider({ client, children }) {
|
|||
(device) => device.kind === "videoinput"
|
||||
);
|
||||
const videoConnected = videoInputs.some(
|
||||
// @ts-ignore
|
||||
(device) => device.deviceId === mediaHandler.videoInput
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
let videoInput = mediaHandler.videoInput;
|
||||
|
||||
if (!videoConnected && videoInputs.length > 0) {
|
||||
|
@ -129,7 +158,9 @@ export function MediaHandlerProvider({ client, children }) {
|
|||
}
|
||||
|
||||
if (
|
||||
// @ts-ignore
|
||||
mediaHandler.videoInput !== videoInput ||
|
||||
// @ts-ignore
|
||||
mediaHandler.audioInput !== audioInput
|
||||
) {
|
||||
mediaHandler.setMediaInputs(audioInput, videoInput);
|
||||
|
@ -149,18 +180,21 @@ export function MediaHandlerProvider({ client, children }) {
|
|||
}
|
||||
updateDevices();
|
||||
|
||||
mediaHandler.on("local_streams_changed", updateDevices);
|
||||
mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, updateDevices);
|
||||
navigator.mediaDevices.addEventListener("devicechange", updateDevices);
|
||||
|
||||
return () => {
|
||||
mediaHandler.removeListener("local_streams_changed", updateDevices);
|
||||
mediaHandler.removeListener(
|
||||
MediaHandlerEvent.LocalStreamsChanged,
|
||||
updateDevices
|
||||
);
|
||||
navigator.mediaDevices.removeEventListener("devicechange", updateDevices);
|
||||
mediaHandler.stopAllStreams();
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
const setAudioInput = useCallback(
|
||||
(deviceId) => {
|
||||
const setAudioInput: (deviceId: string) => void = useCallback(
|
||||
(deviceId: string) => {
|
||||
updateMediaPreferences({ audioInput: deviceId });
|
||||
setState((prevState) => ({ ...prevState, audioInput: deviceId }));
|
||||
client.getMediaHandler().setAudioInput(deviceId);
|
||||
|
@ -168,7 +202,7 @@ export function MediaHandlerProvider({ client, children }) {
|
|||
[client]
|
||||
);
|
||||
|
||||
const setVideoInput = useCallback(
|
||||
const setVideoInput: (deviceId: string) => void = useCallback(
|
||||
(deviceId) => {
|
||||
updateMediaPreferences({ videoInput: deviceId });
|
||||
setState((prevState) => ({ ...prevState, videoInput: deviceId }));
|
||||
|
@ -177,35 +211,36 @@ export function MediaHandlerProvider({ client, children }) {
|
|||
[client]
|
||||
);
|
||||
|
||||
const setAudioOutput = useCallback((deviceId) => {
|
||||
const setAudioOutput: (deviceId: string) => void = useCallback((deviceId) => {
|
||||
updateMediaPreferences({ audioOutput: deviceId });
|
||||
setState((prevState) => ({ ...prevState, audioOutput: deviceId }));
|
||||
}, []);
|
||||
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
audioInput,
|
||||
audioInputs,
|
||||
setAudioInput,
|
||||
videoInput,
|
||||
videoInputs,
|
||||
setVideoInput,
|
||||
audioOutput,
|
||||
audioOutputs,
|
||||
setAudioOutput,
|
||||
}),
|
||||
[
|
||||
audioInput,
|
||||
audioInputs,
|
||||
setAudioInput,
|
||||
videoInput,
|
||||
videoInputs,
|
||||
setVideoInput,
|
||||
audioOutput,
|
||||
audioOutputs,
|
||||
setAudioOutput,
|
||||
]
|
||||
);
|
||||
const context: MediaHandlerContextInterface =
|
||||
useMemo<MediaHandlerContextInterface>(
|
||||
() => ({
|
||||
audioInput,
|
||||
audioInputs,
|
||||
setAudioInput,
|
||||
videoInput,
|
||||
videoInputs,
|
||||
setVideoInput,
|
||||
audioOutput,
|
||||
audioOutputs,
|
||||
setAudioOutput,
|
||||
}),
|
||||
[
|
||||
audioInput,
|
||||
audioInputs,
|
||||
setAudioInput,
|
||||
videoInput,
|
||||
videoInputs,
|
||||
setVideoInput,
|
||||
audioOutput,
|
||||
audioOutputs,
|
||||
setAudioOutput,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<MediaHandlerContext.Provider value={context}>
|
|
@ -58,8 +58,12 @@ export const usePTTSounds = (): PTTSounds => {
|
|||
break;
|
||||
}
|
||||
if (ref.current) {
|
||||
ref.current.currentTime = 0;
|
||||
await ref.current.play();
|
||||
try {
|
||||
ref.current.currentTime = 0;
|
||||
await ref.current.play();
|
||||
} catch (e) {
|
||||
console.log("Couldn't play sound effect", e);
|
||||
}
|
||||
} else {
|
||||
console.log("No media element found");
|
||||
}
|
||||
|
|
|
@ -16,6 +16,10 @@ limitations under the License.
|
|||
|
||||
import { useRef, useEffect, RefObject } from "react";
|
||||
import { parse as parseSdp, write as writeSdp } from "sdp-transform";
|
||||
import {
|
||||
acquireContext,
|
||||
releaseContext,
|
||||
} from "matrix-js-sdk/src/webrtc/audioContext";
|
||||
|
||||
import { useSpatialAudio } from "../settings/useSetting";
|
||||
|
||||
|
@ -68,9 +72,15 @@ export const useMediaStream = (
|
|||
audioOutputDevice &&
|
||||
mediaRef.current !== undefined
|
||||
) {
|
||||
console.log(`useMediaStream setSinkId ${audioOutputDevice}`);
|
||||
// Chrome for Android doesn't support this
|
||||
mediaRef.current.setSinkId?.(audioOutputDevice);
|
||||
if (mediaRef.current.setSinkId) {
|
||||
console.log(
|
||||
`useMediaStream setting output setSinkId ${audioOutputDevice}`
|
||||
);
|
||||
// Chrome for Android doesn't support this
|
||||
mediaRef.current.setSinkId(audioOutputDevice);
|
||||
} else {
|
||||
console.log("Can't set output - no setsinkid");
|
||||
}
|
||||
}
|
||||
}, [audioOutputDevice]);
|
||||
|
||||
|
@ -146,7 +156,7 @@ export const useAudioContext = (): [
|
|||
|
||||
useEffect(() => {
|
||||
if (audioRef.current && !context.current) {
|
||||
context.current = new AudioContext();
|
||||
context.current = acquireContext();
|
||||
|
||||
if (window.chrome) {
|
||||
// We're in Chrome, which needs a loopback hack applied to enable AEC
|
||||
|
@ -160,9 +170,11 @@ export const useAudioContext = (): [
|
|||
})();
|
||||
return () => {
|
||||
audioEl.srcObject = null;
|
||||
releaseContext();
|
||||
};
|
||||
} else {
|
||||
destination.current = context.current.destination;
|
||||
return releaseContext;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
|
Loading…
Add table
Reference in a new issue