Merge branch 'main' into matroska

This commit is contained in:
Robin Townsend 2022-07-13 15:54:06 -04:00
commit 7fab4ca1ba
34 changed files with 4059 additions and 3874 deletions

67
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View 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
View 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
View 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

View file

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

View file

@ -72,7 +72,11 @@ type ClientProviderState = Omit<
"changePassword" | "logout" | "setClient" "changePassword" | "logout" | "setClient"
> & { error?: Error }; > & { error?: Error };
export const ClientProvider: FC = ({ children }) => { interface Props {
children: JSX.Element;
}
export const ClientProvider: FC<Props> = ({ children }) => {
const history = useHistory(); const history = useHistory();
const [ const [
{ loading, isAuthenticated, isPasswordlessUser, client, userName, error }, { loading, isAuthenticated, isPasswordlessUser, client, userName, error },

View file

@ -24,7 +24,11 @@ export function Facepile({
<div <div
className={classNames(styles.facepile, styles[size], className)} className={classNames(styles.facepile, styles[size], className)}
title={participants.map((member) => member.name).join(", ")} 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} {...rest}
> >
{participants.slice(0, max).map((member, i) => { {participants.slice(0, max).map((member, i) => {

View file

@ -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 ref = useRef();
const { buttonProps } = useButton(rest, ref); const { buttonProps } = useButton(rest, ref);
if (isEmbedded) {
return (
<div ref={ref}>
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
</div>
);
}
return ( return (
<button className={styles.backButton} ref={ref} {...buttonProps}> <button className={styles.backButton} ref={ref} {...buttonProps}>
<ArrowLeftIcon width={16} height={16} /> <ArrowLeftIcon width={16} height={16} />

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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { forwardRef } from "react"; import React, { forwardRef } from "react";
import { PressEvent } from "@react-types/shared";
import classNames from "classnames"; import classNames from "classnames";
import { useButton } from "@react-aria/button";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import styles from "./Button.module.css"; import styles from "./Button.module.css";
import { ReactComponent as MicIcon } from "../icons/Mic.svg"; import { ReactComponent as MicIcon } from "../icons/Mic.svg";
import { ReactComponent as MuteMicIcon } from "../icons/MuteMic.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 SettingsIcon } from "../icons/Settings.svg";
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg"; import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg"; import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
import { useButton } from "@react-aria/button";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import { TooltipTrigger } from "../Tooltip"; import { TooltipTrigger } from "../Tooltip";
export type ButtonVariant =
| "default"
| "toolbar"
| "toolbarSecondary"
| "icon"
| "secondary"
| "copy"
| "secondaryCopy"
| "iconCopy"
| "secondaryHangup"
| "dropdown"
| "link";
export const variantToClassName = { export const variantToClassName = {
default: [styles.button], default: [styles.button],
toolbar: [styles.toolbarButton], toolbar: [styles.toolbarButton],
@ -44,11 +58,24 @@ export const variantToClassName = {
link: [styles.linkButton], link: [styles.linkButton],
}; };
export const sizeToClassName = { export type ButtonSize = "lg";
export const sizeToClassName: { lg: string[] } = {
lg: [styles.lg], lg: [styles.lg],
}; };
interface Props {
export const Button = forwardRef( 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", variant = "default",
@ -64,7 +91,7 @@ export const Button = forwardRef(
}, },
ref ref
) => { ) => {
const buttonRef = useObjectRef(ref); const buttonRef = useObjectRef<HTMLButtonElement>(ref);
const { buttonProps } = useButton( const { buttonProps } = useButton(
{ onPress, onPressStart, ...rest }, { onPress, onPressStart, ...rest },
buttonRef buttonRef
@ -75,7 +102,7 @@ export const Button = forwardRef(
let filteredButtonProps = buttonProps; let filteredButtonProps = buttonProps;
if (rest.type === "submit" && !rest.onPress) { if (rest.type === "submit" && !rest.onPress) {
const { onKeyDown, onKeyUp, ...filtered } = buttonProps; const { ...filtered } = buttonProps;
filteredButtonProps = filtered; filteredButtonProps = filtered;
} }
@ -94,14 +121,22 @@ export const Button = forwardRef(
{...mergeProps(rest, filteredButtonProps)} {...mergeProps(rest, filteredButtonProps)}
ref={buttonRef} ref={buttonRef}
> >
{children} <>
{variant === "dropdown" && <ArrowDownIcon />} {children}
{variant === "dropdown" && <ArrowDownIcon />}
</>
</button> </button>
); );
} }
); );
export function MicButton({ muted, ...rest }) { export function MicButton({
muted,
...rest
}: {
muted: boolean;
[index: string]: unknown;
}) {
return ( return (
<TooltipTrigger> <TooltipTrigger>
<Button variant="toolbar" {...rest} off={muted}> <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 ( return (
<TooltipTrigger> <TooltipTrigger>
<Button variant="toolbar" {...rest} off={muted}> <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 ( return (
<TooltipTrigger> <TooltipTrigger>
<Button variant="toolbarSecondary" {...rest} on={enabled}> <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 ( return (
<TooltipTrigger> <TooltipTrigger>
<Button <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 ( return (
<TooltipTrigger> <TooltipTrigger>
<Button variant="toolbar" {...rest}> <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 ( return (
<TooltipTrigger> <TooltipTrigger>
<Button variant="toolbar" {...rest}> <Button variant="toolbar" {...rest}>

View file

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

View file

@ -17,9 +17,28 @@ limitations under the License.
import React from "react"; import React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import classNames from "classnames"; 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 ( return (
<Link <Link
className={classNames( className={classNames(

View file

@ -36,6 +36,14 @@ initRageshake();
console.info(`matrix-video-chat ${import.meta.env.VITE_APP_VERSION || "dev"}`); console.info(`matrix-video-chat ${import.meta.env.VITE_APP_VERSION || "dev"}`);
if (!window.isSecureContext) {
throw new Error(
"This app cannot run in an insecure context. To fix this, access the app " +
"via a local loopback address, or serve it over HTTPS.\n" +
"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts"
);
}
if (import.meta.env.VITE_CUSTOM_THEME) { if (import.meta.env.VITE_CUSTOM_THEME) {
const style = document.documentElement.style; const style = document.documentElement.style;
style.setProperty("--accent", import.meta.env.VITE_THEME_ACCENT as string); style.setProperty("--accent", import.meta.env.VITE_THEME_ACCENT as string);

View file

@ -16,6 +16,7 @@ import {
} from "matrix-js-sdk/src/webrtc/groupCall"; } from "matrix-js-sdk/src/webrtc/groupCall";
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync"; import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
import { WidgetApi } from "matrix-widget-api"; import { WidgetApi } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/src/logger";
import IndexedDBWorker from "./IndexedDBWorker?worker"; import IndexedDBWorker from "./IndexedDBWorker?worker";
@ -138,6 +139,19 @@ export async function initClient(
storeOpts.cryptoStore = new MemoryCryptoStore(); storeOpts.cryptoStore = new MemoryCryptoStore();
} }
// XXX: we read from the URL search params in RoomPage too:
// it would be much better to read them in one place and pass
// the values around, but we initialise the matrix client in
// many different places so we'd have to pass it into all of
// them.
const params = new URLSearchParams(window.location.search);
// disable e2e only if enableE2e=false is given
const enableE2e = params.get("enableE2e") !== "false";
if (!enableE2e) {
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
}
const client = createClient({ const client = createClient({
...storeOpts, ...storeOpts,
...clientOptions, ...clientOptions,
@ -145,6 +159,7 @@ export async function initClient(
// Use a relatively low timeout for API calls: this is a realtime app // 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. // so we don't want API calls taking ages, we'd rather they just fail.
localTimeoutMs: 5000, localTimeoutMs: 5000,
useE2eForGroupCall: enableE2e,
}); });
try { try {

View file

@ -15,6 +15,8 @@ limitations under the License.
*/ */
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { MatrixClient } from "matrix-js-sdk";
import { Button } from "../button"; import { Button } from "../button";
import { useProfile } from "./useProfile"; import { useProfile } from "./useProfile";
import { FieldRow, InputField, ErrorMessage } from "../input/Input"; import { FieldRow, InputField, ErrorMessage } from "../input/Input";
@ -22,7 +24,12 @@ import { Modal, ModalContent } from "../Modal";
import { AvatarInputField } from "../input/AvatarInputField"; import { AvatarInputField } from "../input/AvatarInputField";
import styles from "./ProfileModal.module.css"; import styles from "./ProfileModal.module.css";
export function ProfileModal({ client, ...rest }) { interface Props {
client: MatrixClient;
onClose: () => {};
[rest: string]: unknown;
}
export function ProfileModal({ client, ...rest }: Props) {
const { onClose } = rest; const { onClose } = rest;
const { const {
success, success,
@ -50,13 +57,20 @@ export function ProfileModal({ client, ...rest }) {
(e) => { (e) => {
e.preventDefault(); e.preventDefault();
const data = new FormData(e.target); const data = new FormData(e.target);
const displayName = data.get("displayName"); const displayNameDataEntry = data.get("displayName");
const avatar = data.get("avatar"); const avatar: File | string = data.get("avatar");
const avatarSize =
typeof avatar == "string" ? avatar.length : avatar.size;
const displayName =
typeof displayNameDataEntry == "string"
? displayNameDataEntry
: displayNameDataEntry.name;
saveProfile({ saveProfile({
displayName, displayName,
avatar: avatar && avatar.size > 0 ? avatar : undefined, avatar: avatar && avatarSize > 0 ? avatar : undefined,
removeAvatar: removeAvatar && (!avatar || avatar.size === 0), removeAvatar: removeAvatar && (!avatar || avatarSize === 0),
}); });
}, },
[saveProfile, removeAvatar] [saveProfile, removeAvatar]

View file

@ -14,11 +14,33 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { User, UserEvent } from "matrix-js-sdk/src/models/user";
import { FileType } from "matrix-js-sdk/src/http-api";
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
export function useProfile(client) { interface ProfileLoadState {
success?: boolean;
loading?: boolean;
displayName: string;
avatarUrl: string;
error?: Error;
}
type ProfileSaveCallback = ({
displayName,
avatar,
removeAvatar,
}: {
displayName: string;
avatar: FileType;
removeAvatar: boolean;
}) => Promise<void>;
export function useProfile(client: MatrixClient) {
const [{ loading, displayName, avatarUrl, error, success }, setState] = const [{ loading, displayName, avatarUrl, error, success }, setState] =
useState(() => { useState<ProfileLoadState>(() => {
const user = client?.getUser(client.getUserId()); const user = client?.getUser(client.getUserId());
return { return {
@ -31,7 +53,10 @@ export function useProfile(client) {
}); });
useEffect(() => { useEffect(() => {
const onChangeUser = (_event, { displayName, avatarUrl }) => { const onChangeUser = (
_event: MatrixEvent,
{ displayName, avatarUrl }: User
) => {
setState({ setState({
success: false, success: false,
loading: false, loading: false,
@ -41,24 +66,24 @@ export function useProfile(client) {
}); });
}; };
let user; let user: User;
if (client) { if (client) {
const userId = client.getUserId(); const userId = client.getUserId();
user = client.getUser(userId); user = client.getUser(userId);
user.on("User.displayName", onChangeUser); user.on(UserEvent.DisplayName, onChangeUser);
user.on("User.avatarUrl", onChangeUser); user.on(UserEvent.AvatarUrl, onChangeUser);
} }
return () => { return () => {
if (user) { if (user) {
user.removeListener("User.displayName", onChangeUser); user.removeListener(UserEvent.DisplayName, onChangeUser);
user.removeListener("User.avatarUrl", onChangeUser); user.removeListener(UserEvent.AvatarUrl, onChangeUser);
} }
}; };
}, [client]); }, [client]);
const saveProfile = useCallback( const saveProfile = useCallback<ProfileSaveCallback>(
async ({ displayName, avatar, removeAvatar }) => { async ({ displayName, avatar, removeAvatar }) => {
if (client) { if (client) {
setState((prev) => ({ setState((prev) => ({
@ -71,7 +96,7 @@ export function useProfile(client) {
try { try {
await client.setDisplayName(displayName); await client.setDisplayName(displayName);
let mxcAvatarUrl; let mxcAvatarUrl: string;
if (removeAvatar) { if (removeAvatar) {
await client.setAvatarUrl(""); await client.setAvatarUrl("");
@ -87,11 +112,11 @@ export function useProfile(client) {
loading: false, loading: false,
success: true, success: true,
})); }));
} catch (error) { } catch (error: unknown) {
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
loading: false, loading: false,
error, error: error instanceof Error ? error : Error(error as string),
success: false, success: false,
})); }));
} }
@ -102,5 +127,12 @@ export function useProfile(client) {
[client] [client]
); );
return { loading, error, displayName, avatarUrl, saveProfile, success }; return {
loading,
error,
displayName,
avatarUrl,
saveProfile,
success,
};
} }

View file

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

View file

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

View file

@ -30,6 +30,7 @@ import { useLocationNavigation } from "../useLocationNavigation";
export function GroupCallView({ export function GroupCallView({
client, client,
isPasswordlessUser, isPasswordlessUser,
isEmbedded,
roomId, roomId,
groupCall, groupCall,
}) { }) {
@ -60,7 +61,10 @@ export function GroupCallView({
useEffect(() => { useEffect(() => {
window.groupCall = groupCall; 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); useSentryGroupCallHandler(groupCall);
@ -92,6 +96,7 @@ export function GroupCallView({
participants={participants} participants={participants}
userMediaFeeds={userMediaFeeds} userMediaFeeds={userMediaFeeds}
onLeave={onLeave} onLeave={onLeave}
isEmbedded={isEmbedded}
/> />
); );
} else { } else {
@ -126,23 +131,32 @@ export function GroupCallView({
} else if (left) { } else if (left) {
return <CallEndedView client={client} />; return <CallEndedView client={client} />;
} else { } else {
return ( if (isEmbedded) {
<LobbyView return (
client={client} <FullScreenView>
groupCall={groupCall} <h1>Loading room...</h1>
hasLocalParticipant={hasLocalParticipant} </FullScreenView>
roomName={groupCall.room.name} );
avatarUrl={avatarUrl} } else {
state={state} return (
onInitLocalCallFeed={initLocalCallFeed} <LobbyView
localCallFeed={localCallFeed} client={client}
onEnter={enter} groupCall={groupCall}
microphoneMuted={microphoneMuted} hasLocalParticipant={hasLocalParticipant}
localVideoMuted={localVideoMuted} roomName={groupCall.room.name}
toggleLocalVideoMuted={toggleLocalVideoMuted} avatarUrl={avatarUrl}
toggleMicrophoneMuted={toggleMicrophoneMuted} state={state}
roomId={roomId} onInitLocalCallFeed={initLocalCallFeed}
/> localCallFeed={localCallFeed}
); onEnter={enter}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
roomId={roomId}
isEmbedded={isEmbedded}
/>
);
}
} }
} }

View file

@ -44,7 +44,7 @@ import { useShowInspector } from "../settings/useSetting";
import { useModalTriggerState } from "../Modal"; import { useModalTriggerState } from "../Modal";
import { useAudioContext } from "../video-grid/useMediaStream"; import { useAudioContext } from "../video-grid/useMediaStream";
const canScreenshare = "getDisplayMedia" in navigator.mediaDevices; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams // There is currently a bug in Safari our our code with cloning and sending MediaStreams
// or with getUsermedia and getDisplaymedia being used within the same session. // or with getUsermedia and getDisplaymedia being used within the same session.
// For now we can disable screensharing in Safari. // For now we can disable screensharing in Safari.

View file

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

View file

@ -1,17 +1,28 @@
.pttButton { .pttButton {
width: 100vw; width: 100vw;
height: 100vh; aspect-ratio: 1;
max-height: 232px; max-height: min(232px, calc(100vh - 16px));
max-width: 232px; max-width: min(232px, calc(100vw - 16px));
border-radius: 116px; border-radius: 116px;
color: var(--primary-content); color: var(--primary-content);
border: 6px solid var(--accent); border: 6px solid var(--accent);
background-color: #21262c; background-color: #21262c;
position: relative; position: relative;
padding: 0; padding: 0;
margin: 4px;
cursor: pointer; 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 { .talking {
background-color: var(--accent); background-color: var(--accent);
cursor: unset; cursor: unset;

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useCallback, useState, createRef } from "react"; import React, { useCallback, useState, useRef } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { useSpring, animated } from "@react-spring/web"; import { useSpring, animated } from "@react-spring/web";
@ -54,16 +54,20 @@ export const PTTButton: React.FC<Props> = ({
enqueueNetworkWaiting, enqueueNetworkWaiting,
setNetworkWaiting, setNetworkWaiting,
}) => { }) => {
const buttonRef = createRef<HTMLButtonElement>(); const buttonRef = useRef<HTMLButtonElement>();
const [activeTouchId, setActiveTouchId] = useState<number | null>(null); const [activeTouchId, setActiveTouchId] = useState<number | null>(null);
const [buttonHeld, setButtonHeld] = useState(false);
const hold = useCallback(() => { const hold = useCallback(() => {
// This update is delayed so the user only sees it if latency is significant // This update is delayed so the user only sees it if latency is significant
if (buttonHeld) return;
setButtonHeld(true);
enqueueNetworkWaiting(true, 100); enqueueNetworkWaiting(true, 100);
startTalking(); startTalking();
}, [enqueueNetworkWaiting, startTalking]); }, [enqueueNetworkWaiting, startTalking, buttonHeld]);
const unhold = useCallback(() => { const unhold = useCallback(() => {
setButtonHeld(false);
setNetworkWaiting(false); setNetworkWaiting(false);
stopTalking(); stopTalking();
}, [setNetworkWaiting, stopTalking]); }, [setNetworkWaiting, stopTalking]);

View file

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

View file

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

View file

@ -29,9 +29,13 @@ export function RoomPage() {
const { roomId: maybeRoomId } = useParams(); const { roomId: maybeRoomId } = useParams();
const { hash, search } = useLocation(); const { hash, search } = useLocation();
const [viaServers] = useMemo(() => { const [viaServers, isEmbedded, isPtt] = useMemo(() => {
const params = new URLSearchParams(search); const params = new URLSearchParams(search);
return [params.getAll("via")]; return [
params.getAll("via"),
params.has("embed"),
params.get("ptt") === "true",
];
}, [search]); }, [search]);
const roomId = (maybeRoomId || hash || "").toLowerCase(); const roomId = (maybeRoomId || hash || "").toLowerCase();
@ -49,13 +53,19 @@ export function RoomPage() {
return ( return (
<MediaHandlerProvider client={client}> <MediaHandlerProvider client={client}>
<GroupCallLoader client={client} roomId={roomId} viaServers={viaServers}> <GroupCallLoader
client={client}
roomId={roomId}
viaServers={viaServers}
createPtt={isPtt}
>
{(groupCall) => ( {(groupCall) => (
<GroupCallView <GroupCallView
client={client} client={client}
roomId={roomId} roomId={roomId}
groupCall={groupCall} groupCall={groupCall}
isPasswordlessUser={isPasswordlessUser} isPasswordlessUser={isPasswordlessUser}
isEmbedded={isEmbedded}
/> />
)} )}
</GroupCallLoader> </GroupCallLoader>

View file

@ -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({ const [state, setState] = useState({
loading: true, loading: true,
error: undefined, error: undefined,
@ -80,7 +86,7 @@ export function useLoadGroupCall(client, roomId, viaServers, createIfNotFound) {
isLocalRoomId(roomId) isLocalRoomId(roomId)
) { ) {
const roomName = roomNameFromRoomId(roomId); const roomName = roomNameFromRoomId(roomId);
await createRoom(client, roomName); await createRoom(client, roomName, createPtt);
const groupCall = await fetchGroupCall( const groupCall = await fetchGroupCall(
client, client,
roomId, roomId,
@ -103,7 +109,7 @@ export function useLoadGroupCall(client, roomId, viaServers, createIfNotFound) {
.catch((error) => .catch((error) =>
setState((prevState) => ({ ...prevState, loading: false, error })) setState((prevState) => ({ ...prevState, loading: false, error }))
); );
}, [client, roomId, state.reloadId, createIfNotFound, viaServers]); }, [client, roomId, state.reloadId, createIfNotFound, viaServers, createPtt]);
return state; return state;
} }

View file

@ -130,7 +130,7 @@ export const usePTT = (
const onMuteStateChanged = useCallback(() => { const onMuteStateChanged = useCallback(() => {
const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall); const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall);
let blocked = false; let blocked = transmitBlocked;
if (activeSpeakerUserId === null && activeSpeakerFeed !== null) { if (activeSpeakerUserId === null && activeSpeakerFeed !== null) {
if (activeSpeakerFeed.userId === client.getUserId()) { if (activeSpeakerFeed.userId === client.getUserId()) {
playClip(PTTClipID.START_TALKING_LOCAL); playClip(PTTClipID.START_TALKING_LOCAL);
@ -141,8 +141,8 @@ export const usePTT = (
playClip(PTTClipID.END_TALKING); playClip(PTTClipID.END_TALKING);
} else if ( } else if (
pttButtonHeld && 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 // 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 // (this is the easier way of cutting other speakers off if an
@ -167,6 +167,7 @@ export const usePTT = (
client, client,
userMediaFeeds, userMediaFeeds,
setMicMuteWrapper, setMicMuteWrapper,
transmitBlocked,
]); ]);
useEffect(() => { useEffect(() => {

View file

@ -15,6 +15,8 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { Item } from "@react-stately/collections";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import styles from "./SettingsModal.module.css"; import styles from "./SettingsModal.module.css";
import { TabContainer, TabItem } from "../tabs/Tabs"; import { TabContainer, TabItem } from "../tabs/Tabs";
@ -22,7 +24,6 @@ import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
import { ReactComponent as VideoIcon } from "../icons/Video.svg"; import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg"; import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
import { SelectInput } from "../input/SelectInput"; import { SelectInput } from "../input/SelectInput";
import { Item } from "@react-stately/collections";
import { useMediaHandler } from "./useMediaHandler"; import { useMediaHandler } from "./useMediaHandler";
import { useSpatialAudio, useShowInspector } from "./useSetting"; import { useSpatialAudio, useShowInspector } from "./useSetting";
import { FieldRow, InputField } from "../input/Input"; import { FieldRow, InputField } from "../input/Input";
@ -30,7 +31,13 @@ import { Button } from "../button";
import { useDownloadDebugLog } from "./submit-rageshake"; import { useDownloadDebugLog } from "./submit-rageshake";
import { Body } from "../typography/Typography"; import { Body } from "../typography/Typography";
export const SettingsModal = (props) => { interface Props {
setShowInspector: boolean;
showInspector: boolean;
[rest: string]: unknown;
}
export const SettingsModal = (props: Props) => {
const { const {
audioInput, audioInput,
audioInputs, audioInputs,
@ -42,6 +49,7 @@ export const SettingsModal = (props) => {
audioOutputs, audioOutputs,
setAudioOutput, setAudioOutput,
} = useMediaHandler(); } = useMediaHandler();
const [spatialAudio, setSpatialAudio] = useSpatialAudio(); const [spatialAudio, setSpatialAudio] = useSpatialAudio();
const [showInspector, setShowInspector] = useShowInspector(); const [showInspector, setShowInspector] = useShowInspector();
@ -69,8 +77,12 @@ export const SettingsModal = (props) => {
selectedKey={audioInput} selectedKey={audioInput}
onSelectionChange={setAudioInput} onSelectionChange={setAudioInput}
> >
{audioInputs.map(({ deviceId, label }) => ( {audioInputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>{label}</Item> <Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: `Microphone ${index + 1}`}
</Item>
))} ))}
</SelectInput> </SelectInput>
{audioOutputs.length > 0 && ( {audioOutputs.length > 0 && (
@ -79,8 +91,12 @@ export const SettingsModal = (props) => {
selectedKey={audioOutput} selectedKey={audioOutput}
onSelectionChange={setAudioOutput} onSelectionChange={setAudioOutput}
> >
{audioOutputs.map(({ deviceId, label }) => ( {audioOutputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>{label}</Item> <Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: `Speaker ${index + 1}`}
</Item>
))} ))}
</SelectInput> </SelectInput>
)} )}
@ -91,7 +107,9 @@ export const SettingsModal = (props) => {
type="checkbox" type="checkbox"
checked={spatialAudio} 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.)" 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> </FieldRow>
</TabItem> </TabItem>
@ -108,8 +126,12 @@ export const SettingsModal = (props) => {
selectedKey={videoInput} selectedKey={videoInput}
onSelectionChange={setVideoInput} onSelectionChange={setVideoInput}
> >
{videoInputs.map(({ deviceId, label }) => ( {videoInputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>{label}</Item> <Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: `Camera ${index + 1}`}
</Item>
))} ))}
</SelectInput> </SelectInput>
</TabItem> </TabItem>
@ -133,7 +155,9 @@ export const SettingsModal = (props) => {
label="Show Call Inspector" label="Show Call Inspector"
type="checkbox" type="checkbox"
checked={showInspector} checked={showInspector}
onChange={(e) => setShowInspector(e.target.checked)} onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setShowInspector(e.target.checked)
}
/> />
</FieldRow> </FieldRow>
<FieldRow> <FieldRow>

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* /*
Copyright 2017 OpenMarket Ltd Copyright 2017 OpenMarket Ltd
Copyright 2018 New Vector Ltd Copyright 2018 New Vector Ltd
@ -37,19 +38,33 @@ limitations under the License.
// actually timestamps. We then purge the remaining logs. We also do this // actually timestamps. We then purge the remaining logs. We also do this
// purge on startup to prevent logs from accumulating. // purge on startup to prevent logs from accumulating.
// the frequency with which we flush to indexeddb
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { randomString } from "matrix-js-sdk/src/randomstring";
// the frequency with which we flush to indexeddb
const FLUSH_RATE_MS = 30 * 1000; const FLUSH_RATE_MS = 30 * 1000;
// the length of log data we keep in indexeddb (and include in the reports) // the length of log data we keep in indexeddb (and include in the reports)
const MAX_LOG_SIZE = 1024 * 1024 * 5; // 5 MB const MAX_LOG_SIZE = 1024 * 1024 * 5; // 5 MB
// A class which monkey-patches the global console and stores log lines. type LogFunction = (
export class ConsoleLogger { ...args: (Error | DOMException | object | string)[]
logs = ""; ) => void;
type LogFunctionName = "log" | "info" | "warn" | "error";
monkeyPatch(consoleObj) { // A class which monkey-patches the global console and stores log lines.
interface LogEntry {
id: string;
lines: string;
index?: number;
}
export class ConsoleLogger {
private logs = "";
private originalFunctions: { [key in LogFunctionName]?: LogFunction } = {};
public monkeyPatch(consoleObj: Console): void {
// Monkey-patch console logging // Monkey-patch console logging
const consoleFunctionsToLevels = { const consoleFunctionsToLevels = {
log: "I", log: "I",
@ -60,6 +75,7 @@ export class ConsoleLogger {
Object.keys(consoleFunctionsToLevels).forEach((fnName) => { Object.keys(consoleFunctionsToLevels).forEach((fnName) => {
const level = consoleFunctionsToLevels[fnName]; const level = consoleFunctionsToLevels[fnName];
const originalFn = consoleObj[fnName].bind(consoleObj); const originalFn = consoleObj[fnName].bind(consoleObj);
this.originalFunctions[fnName] = originalFn;
consoleObj[fnName] = (...args) => { consoleObj[fnName] = (...args) => {
this.log(level, ...args); this.log(level, ...args);
originalFn(...args); originalFn(...args);
@ -67,7 +83,17 @@ export class ConsoleLogger {
}); });
} }
log(level, ...args) { public bypassRageshake(
fnName: LogFunctionName,
...args: (Error | DOMException | object | string)[]
): void {
this.originalFunctions[fnName](...args);
}
public log(
level: string,
...args: (Error | DOMException | object | string)[]
): void {
// We don't know what locale the user may be running so use ISO strings // We don't know what locale the user may be running so use ISO strings
const ts = new Date().toISOString(); const ts = new Date().toISOString();
@ -78,21 +104,7 @@ export class ConsoleLogger {
} else if (arg instanceof Error) { } else if (arg instanceof Error) {
return arg.message + (arg.stack ? `\n${arg.stack}` : ""); return arg.message + (arg.stack ? `\n${arg.stack}` : "");
} else if (typeof arg === "object") { } else if (typeof arg === "object") {
try { return JSON.stringify(arg, getCircularReplacer());
return JSON.stringify(arg);
} catch (e) {
// In development, it can be useful to log complex cyclic
// objects to the console for inspection. This is fine for
// the console, but default `stringify` can't handle that.
// We workaround this by using a special replacer function
// to only log values of the root object and avoid cycles.
return JSON.stringify(arg, (key, value) => {
if (key && typeof value === "object") {
return "<object>";
}
return value;
});
}
} else { } else {
return arg; return arg;
} }
@ -116,7 +128,7 @@ export class ConsoleLogger {
* @param {boolean} keepLogs True to not delete logs after flushing. * @param {boolean} keepLogs True to not delete logs after flushing.
* @return {string} \n delimited log lines to flush. * @return {string} \n delimited log lines to flush.
*/ */
flush(keepLogs) { public flush(keepLogs?: boolean): string {
// The ConsoleLogger doesn't care how these end up on disk, it just // The ConsoleLogger doesn't care how these end up on disk, it just
// flushes them to the caller. // flushes them to the caller.
if (keepLogs) { if (keepLogs) {
@ -130,24 +142,23 @@ export class ConsoleLogger {
// A class which stores log lines in an IndexedDB instance. // A class which stores log lines in an IndexedDB instance.
export class IndexedDBLogStore { export class IndexedDBLogStore {
index = 0; private index = 0;
db = null; private db: IDBDatabase = null;
flushPromise = null; private flushPromise: Promise<void> = null;
flushAgainPromise = null; private flushAgainPromise: Promise<void> = null;
private id: string;
constructor(indexedDB, logger) { constructor(private indexedDB: IDBFactory, private logger: ConsoleLogger) {
this.indexedDB = indexedDB; this.id = "instance-" + randomString(16);
this.logger = logger;
this.id = "instance-" + Math.random() + Date.now();
} }
/** /**
* @return {Promise} Resolves when the store is ready. * @return {Promise} Resolves when the store is ready.
*/ */
connect() { public connect(): Promise<void> {
const req = this.indexedDB.open("logs"); const req = this.indexedDB.open("logs");
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
req.onsuccess = (event) => { req.onsuccess = (event: Event) => {
// @ts-ignore // @ts-ignore
this.db = event.target.result; this.db = event.target.result;
// Periodically flush logs to local storage / indexeddb // Periodically flush logs to local storage / indexeddb
@ -206,7 +217,7 @@ export class IndexedDBLogStore {
* *
* @return {Promise} Resolved when the logs have been flushed. * @return {Promise} Resolved when the logs have been flushed.
*/ */
flush() { public flush(): Promise<void> {
// check if a flush() operation is ongoing // check if a flush() operation is ongoing
if (this.flushPromise) { if (this.flushPromise) {
if (this.flushAgainPromise) { if (this.flushAgainPromise) {
@ -225,7 +236,7 @@ export class IndexedDBLogStore {
} }
// there is no flush promise or there was but it has finished, so do // there is no flush promise or there was but it has finished, so do
// a brand new one, destroying the chain which may have been built up. // a brand new one, destroying the chain which may have been built up.
this.flushPromise = new Promise((resolve, reject) => { this.flushPromise = new Promise<void>((resolve, reject) => {
if (!this.db) { if (!this.db) {
// not connected yet or user rejected access for us to r/w to the db. // not connected yet or user rejected access for us to r/w to the db.
reject(new Error("No connected database")); reject(new Error("No connected database"));
@ -243,6 +254,7 @@ export class IndexedDBLogStore {
}; };
txn.onerror = (event) => { txn.onerror = (event) => {
logger.error("Failed to flush logs : ", event); logger.error("Failed to flush logs : ", event);
// @ts-ignore
reject(new Error("Failed to write logs: " + event.target.errorCode)); reject(new Error("Failed to write logs: " + event.target.errorCode));
}; };
objStore.add(this.generateLogEntry(lines)); objStore.add(this.generateLogEntry(lines));
@ -264,12 +276,12 @@ export class IndexedDBLogStore {
* log ID). The objects have said log ID in an "id" field and "lines" which * log ID). The objects have said log ID in an "id" field and "lines" which
* is a big string with all the new-line delimited logs. * is a big string with all the new-line delimited logs.
*/ */
async consume() { public async consume(): Promise<LogEntry[]> {
const db = this.db; const db = this.db;
// Returns: a string representing the concatenated logs for this ID. // Returns: a string representing the concatenated logs for this ID.
// Stops adding log fragments when the size exceeds maxSize // Stops adding log fragments when the size exceeds maxSize
function fetchLogs(id, maxSize) { function fetchLogs(id: string, maxSize: number): Promise<string> {
const objectStore = db const objectStore = db
.transaction("logs", "readonly") .transaction("logs", "readonly")
.objectStore("logs"); .objectStore("logs");
@ -280,9 +292,11 @@ export class IndexedDBLogStore {
.openCursor(IDBKeyRange.only(id), "prev"); .openCursor(IDBKeyRange.only(id), "prev");
let lines = ""; let lines = "";
query.onerror = (event) => { query.onerror = (event) => {
// @ts-ignore
reject(new Error("Query failed: " + event.target.errorCode)); reject(new Error("Query failed: " + event.target.errorCode));
}; };
query.onsuccess = (event) => { query.onsuccess = (event) => {
// @ts-ignore
const cursor = event.target.result; const cursor = event.target.result;
if (!cursor) { if (!cursor) {
resolve(lines); resolve(lines);
@ -299,12 +313,12 @@ export class IndexedDBLogStore {
} }
// Returns: A sorted array of log IDs. (newest first) // Returns: A sorted array of log IDs. (newest first)
function fetchLogIds() { function fetchLogIds(): Promise<string[]> {
// To gather all the log IDs, query for all records in logslastmod. // To gather all the log IDs, query for all records in logslastmod.
const o = db const o = db
.transaction("logslastmod", "readonly") .transaction("logslastmod", "readonly")
.objectStore("logslastmod"); .objectStore("logslastmod");
return selectQuery(o, undefined, (cursor) => { return selectQuery<{ ts: number; id: string }>(o, undefined, (cursor) => {
return { return {
id: cursor.value.id, id: cursor.value.id,
ts: cursor.value.ts, ts: cursor.value.ts,
@ -319,13 +333,14 @@ export class IndexedDBLogStore {
}); });
} }
function deleteLogs(id) { function deleteLogs(id: number): Promise<void> {
return new Promise((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const txn = db.transaction(["logs", "logslastmod"], "readwrite"); const txn = db.transaction(["logs", "logslastmod"], "readwrite");
const o = txn.objectStore("logs"); const o = txn.objectStore("logs");
// only load the key path, not the data which may be huge // only load the key path, not the data which may be huge
const query = o.index("id").openKeyCursor(IDBKeyRange.only(id)); const query = o.index("id").openKeyCursor(IDBKeyRange.only(id));
query.onsuccess = (event) => { query.onsuccess = (event) => {
// @ts-ignore
const cursor = event.target.result; const cursor = event.target.result;
if (!cursor) { if (!cursor) {
return; return;
@ -340,6 +355,7 @@ export class IndexedDBLogStore {
reject( reject(
new Error( new Error(
"Failed to delete logs for " + "Failed to delete logs for " +
// @ts-ignore
`'${id}' : ${event.target.errorCode}` `'${id}' : ${event.target.errorCode}`
) )
); );
@ -352,7 +368,7 @@ export class IndexedDBLogStore {
const allLogIds = await fetchLogIds(); const allLogIds = await fetchLogIds();
let removeLogIds = []; let removeLogIds = [];
const logs = []; const logs: LogEntry[] = [];
let size = 0; let size = 0;
for (let i = 0; i < allLogIds.length; i++) { for (let i = 0; i < allLogIds.length; i++) {
const lines = await fetchLogs(allLogIds[i], MAX_LOG_SIZE - size); const lines = await fetchLogs(allLogIds[i], MAX_LOG_SIZE - size);
@ -390,7 +406,7 @@ export class IndexedDBLogStore {
return logs; return logs;
} }
generateLogEntry(lines) { private generateLogEntry(lines: string): LogEntry {
return { return {
id: this.id, id: this.id,
lines: lines, lines: lines,
@ -398,7 +414,7 @@ export class IndexedDBLogStore {
}; };
} }
generateLastModifiedTime() { private generateLastModifiedTime(): { id: string; ts: number } {
return { return {
id: this.id, id: this.id,
ts: Date.now(), ts: Date.now(),
@ -416,7 +432,11 @@ export class IndexedDBLogStore {
* @return {Promise<T[]>} Resolves to an array of whatever you returned from * @return {Promise<T[]>} Resolves to an array of whatever you returned from
* resultMapper. * resultMapper.
*/ */
function selectQuery(store, keyRange, resultMapper) { function selectQuery<T>(
store: IDBObjectStore,
keyRange: IDBKeyRange,
resultMapper: (cursor: IDBCursorWithValue) => T
): Promise<T[]> {
const query = store.openCursor(keyRange); const query = store.openCursor(keyRange);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const results = []; const results = [];
@ -437,6 +457,16 @@ function selectQuery(store, keyRange, resultMapper) {
}; };
}); });
} }
declare global {
// eslint-disable-next-line no-var, camelcase
var mx_rage_store: IndexedDBLogStore;
// eslint-disable-next-line no-var, camelcase
var mx_rage_logger: ConsoleLogger;
// eslint-disable-next-line no-var, camelcase
var mx_rage_initPromise: Promise<void>;
// eslint-disable-next-line no-var, camelcase
var mx_rage_initStoragePromise: Promise<void>;
}
/** /**
* Configure rage shaking support for sending bug reports. * Configure rage shaking support for sending bug reports.
@ -445,7 +475,7 @@ function selectQuery(store, keyRange, resultMapper) {
* be set up immediately for the logs. * be set up immediately for the logs.
* @return {Promise} Resolves when set up. * @return {Promise} Resolves when set up.
*/ */
export function init(setUpPersistence = true) { export function init(setUpPersistence = true): Promise<void> {
if (global.mx_rage_initPromise) { if (global.mx_rage_initPromise) {
return global.mx_rage_initPromise; return global.mx_rage_initPromise;
} }
@ -465,7 +495,7 @@ export function init(setUpPersistence = true) {
* then this no-ops. * then this no-ops.
* @return {Promise} Resolves when complete. * @return {Promise} Resolves when complete.
*/ */
export function tryInitStorage() { export function tryInitStorage(): Promise<void> {
if (global.mx_rage_initStoragePromise) { if (global.mx_rage_initStoragePromise) {
return global.mx_rage_initStoragePromise; return global.mx_rage_initStoragePromise;
} }
@ -491,7 +521,7 @@ export function tryInitStorage() {
return global.mx_rage_initStoragePromise; return global.mx_rage_initStoragePromise;
} }
export function flush() { export function flush(): Promise<void> {
if (!global.mx_rage_store) { if (!global.mx_rage_store) {
return; return;
} }
@ -502,7 +532,7 @@ export function flush() {
* Clean up old logs. * Clean up old logs.
* @return {Promise} Resolves if cleaned logs. * @return {Promise} Resolves if cleaned logs.
*/ */
export async function cleanup() { export async function cleanup(): Promise<void> {
if (!global.mx_rage_store) { if (!global.mx_rage_store) {
return; return;
} }
@ -512,9 +542,9 @@ export async function cleanup() {
/** /**
* Get a recent snapshot of the logs, ready for attaching to a bug report * Get a recent snapshot of the logs, ready for attaching to a bug report
* *
* @return {Array<{lines: string, id, string}>} list of log data * @return {LogEntry[]} list of log data
*/ */
export async function getLogsForReport() { export async function getLogsForReport(): Promise<LogEntry[]> {
if (!global.mx_rage_logger) { if (!global.mx_rage_logger) {
throw new Error("No console logger, did you forget to call init()?"); throw new Error("No console logger, did you forget to call init()?");
} }
@ -523,7 +553,7 @@ export async function getLogsForReport() {
if (global.mx_rage_store) { if (global.mx_rage_store) {
// flush most recent logs // flush most recent logs
await global.mx_rage_store.flush(); await global.mx_rage_store.flush();
return await global.mx_rage_store.consume(); return global.mx_rage_store.consume();
} else { } else {
return [ return [
{ {
@ -533,3 +563,24 @@ export async function getLogsForReport() {
]; ];
} }
} }
type StringifyReplacer = (
this: unknown,
key: string,
value: unknown
) => unknown;
// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value#circular_references
// Injects `<$ cycle-trimmed $>` wherever it cuts a cyclical object relationship
const getCircularReplacer = (): StringifyReplacer => {
const seen = new WeakSet();
return (key: string, value: unknown): unknown => {
if (typeof value === "object" && value !== null) {
if (seen.has(value)) {
return "<$ cycle-trimmed $>";
}
seen.add(value);
}
return value;
};
};

View file

@ -15,14 +15,31 @@ limitations under the License.
*/ */
import { useCallback, useContext, useEffect, useState } from "react"; import { useCallback, useContext, useEffect, useState } from "react";
import { getLogsForReport } from "./rageshake";
import pako from "pako"; import pako from "pako";
import { MatrixEvent } from "matrix-js-sdk";
import { OverlayTriggerState } from "@react-stately/overlays";
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
import { getLogsForReport } from "./rageshake";
import { useClient } from "../ClientContext"; import { useClient } from "../ClientContext";
import { InspectorContext } from "../room/GroupCallInspector"; import { InspectorContext } from "../room/GroupCallInspector";
import { useModalTriggerState } from "../Modal"; import { useModalTriggerState } from "../Modal";
export function useSubmitRageshake() { interface RageShakeSubmitOptions {
const { client } = useClient(); description: string;
roomId: string;
label: string;
sendLogs: boolean;
rageshakeRequestId: string;
}
export function useSubmitRageshake(): {
submitRageshake: (opts: RageShakeSubmitOptions) => Promise<void>;
sending: boolean;
sent: boolean;
error: Error;
} {
const client: MatrixClient = useClient().client;
const [{ json }] = useContext(InspectorContext); const [{ json }] = useContext(InspectorContext);
const [{ sending, sent, error }, setState] = useState({ const [{ sending, sent, error }, setState] = useState({
@ -57,9 +74,12 @@ export function useSubmitRageshake() {
opts.description || "User did not supply any additional text." opts.description || "User did not supply any additional text."
); );
body.append("app", "matrix-video-chat"); body.append("app", "matrix-video-chat");
body.append("version", import.meta.env.VITE_APP_VERSION || "dev"); body.append(
"version",
(import.meta.env.VITE_APP_VERSION as string) || "dev"
);
body.append("user_agent", userAgent); body.append("user_agent", userAgent);
body.append("installed_pwa", false); body.append("installed_pwa", "false");
body.append("touch_input", touchInput); body.append("touch_input", touchInput);
if (client) { if (client) {
@ -181,7 +201,11 @@ export function useSubmitRageshake() {
if (navigator.storage && navigator.storage.estimate) { if (navigator.storage && navigator.storage.estimate) {
try { try {
const estimate = await navigator.storage.estimate(); const estimate: {
quota?: number;
usage?: number;
usageDetails?: { [x: string]: unknown };
} = await navigator.storage.estimate();
body.append("storageManager_quota", String(estimate.quota)); body.append("storageManager_quota", String(estimate.quota));
body.append("storageManager_usage", String(estimate.usage)); body.append("storageManager_usage", String(estimate.usage));
if (estimate.usageDetails) { if (estimate.usageDetails) {
@ -201,7 +225,6 @@ export function useSubmitRageshake() {
for (const entry of logs) { for (const entry of logs) {
// encode as UTF-8 // encode as UTF-8
let buf = new TextEncoder().encode(entry.lines); let buf = new TextEncoder().encode(entry.lines);
// compress // compress
buf = pako.gzip(buf); buf = pako.gzip(buf);
@ -225,7 +248,7 @@ export function useSubmitRageshake() {
} }
await fetch( await fetch(
import.meta.env.VITE_RAGESHAKE_SUBMIT_URL || (import.meta.env.VITE_RAGESHAKE_SUBMIT_URL as string) ||
"https://element.io/bugreports/submit", "https://element.io/bugreports/submit",
{ {
method: "POST", method: "POST",
@ -250,7 +273,7 @@ export function useSubmitRageshake() {
}; };
} }
export function useDownloadDebugLog() { export function useDownloadDebugLog(): () => void {
const [{ json }] = useContext(InspectorContext); const [{ json }] = useContext(InspectorContext);
const downloadDebugLog = useCallback(() => { const downloadDebugLog = useCallback(() => {
@ -271,7 +294,10 @@ export function useDownloadDebugLog() {
return downloadDebugLog; return downloadDebugLog;
} }
export function useRageshakeRequest() { export function useRageshakeRequest(): (
roomId: string,
rageshakeRequestId: string
) => void {
const { client } = useClient(); const { client } = useClient();
const sendRageshakeRequest = useCallback( const sendRageshakeRequest = useCallback(
@ -285,14 +311,27 @@ export function useRageshakeRequest() {
return sendRageshakeRequest; return sendRageshakeRequest;
} }
interface ModalProps {
isOpen: boolean;
onClose: () => void;
}
interface ModalPropsWithId extends ModalProps {
rageshakeRequestId: string;
}
export function useRageshakeRequestModal(roomId) { export function useRageshakeRequestModal(roomId: string): {
const { modalState, modalProps } = useModalTriggerState(); modalState: OverlayTriggerState;
const { client } = useClient(); modalProps: ModalPropsWithId;
const [rageshakeRequestId, setRageshakeRequestId] = useState(); } {
const { modalState, modalProps } = useModalTriggerState() as {
modalState: OverlayTriggerState;
modalProps: ModalProps;
};
const client: MatrixClient = useClient().client;
const [rageshakeRequestId, setRageshakeRequestId] = useState<string>();
useEffect(() => { useEffect(() => {
const onEvent = (event) => { const onEvent = (event: MatrixEvent) => {
const type = event.getType(); const type = event.getType();
if ( if (
@ -305,10 +344,10 @@ export function useRageshakeRequestModal(roomId) {
} }
}; };
client.on("event", onEvent); client.on(ClientEvent.Event, onEvent);
return () => { return () => {
client.removeListener("event", onEvent); client.removeListener(ClientEvent.Event, onEvent);
}; };
}, [modalState.open, roomId, client, modalState]); }, [modalState.open, roomId, client, modalState]);

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* /*
Copyright 2022 Matrix.org Foundation C.I.C. Copyright 2022 Matrix.org Foundation C.I.C.
@ -14,6 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { MatrixClient } from "matrix-js-sdk";
import { MediaHandlerEvent } from "matrix-js-sdk/src/webrtc/mediaHandler";
import React, { import React, {
useState, useState,
useEffect, useEffect,
@ -23,9 +26,27 @@ import React, {
createContext, createContext,
} from "react"; } from "react";
const MediaHandlerContext = createContext(); export interface MediaHandlerContextInterface {
audioInput: string;
audioInputs: MediaDeviceInfo[];
setAudioInput: (deviceId: string) => void;
videoInput: string;
videoInputs: MediaDeviceInfo[];
setVideoInput: (deviceId: string) => void;
audioOutput: string;
audioOutputs: MediaDeviceInfo[];
setAudioOutput: (deviceId: string) => void;
}
function getMediaPreferences() { const MediaHandlerContext =
createContext<MediaHandlerContextInterface>(undefined);
interface MediaPreferences {
audioInput?: string;
videoInput?: string;
audioOutput?: string;
}
function getMediaPreferences(): MediaPreferences {
const mediaPreferences = localStorage.getItem("matrix-media-preferences"); const mediaPreferences = localStorage.getItem("matrix-media-preferences");
if (mediaPreferences) { if (mediaPreferences) {
@ -39,8 +60,8 @@ function getMediaPreferences() {
} }
} }
function updateMediaPreferences(newPreferences) { function updateMediaPreferences(newPreferences: MediaPreferences): void {
const oldPreferences = getMediaPreferences(newPreferences); const oldPreferences = getMediaPreferences();
localStorage.setItem( localStorage.setItem(
"matrix-media-preferences", "matrix-media-preferences",
@ -50,8 +71,11 @@ function updateMediaPreferences(newPreferences) {
}) })
); );
} }
interface Props {
export function MediaHandlerProvider({ client, children }) { client: MatrixClient;
children: JSX.Element[];
}
export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
const [ const [
{ {
audioInput, audioInput,
@ -72,7 +96,9 @@ export function MediaHandlerProvider({ client, children }) {
); );
return { return {
// @ts-ignore, ignore that audioInput is a private members of mediaHandler
audioInput: mediaHandler.audioInput, audioInput: mediaHandler.audioInput,
// @ts-ignore, ignore that videoInput is a private members of mediaHandler
videoInput: mediaHandler.videoInput, videoInput: mediaHandler.videoInput,
audioOutput: undefined, audioOutput: undefined,
audioInputs: [], audioInputs: [],
@ -84,7 +110,7 @@ export function MediaHandlerProvider({ client, children }) {
useEffect(() => { useEffect(() => {
const mediaHandler = client.getMediaHandler(); const mediaHandler = client.getMediaHandler();
function updateDevices() { function updateDevices(): void {
navigator.mediaDevices.enumerateDevices().then((devices) => { navigator.mediaDevices.enumerateDevices().then((devices) => {
const mediaPreferences = getMediaPreferences(); const mediaPreferences = getMediaPreferences();
@ -92,9 +118,10 @@ export function MediaHandlerProvider({ client, children }) {
(device) => device.kind === "audioinput" (device) => device.kind === "audioinput"
); );
const audioConnected = audioInputs.some( const audioConnected = audioInputs.some(
// @ts-ignore
(device) => device.deviceId === mediaHandler.audioInput (device) => device.deviceId === mediaHandler.audioInput
); );
// @ts-ignore
let audioInput = mediaHandler.audioInput; let audioInput = mediaHandler.audioInput;
if (!audioConnected && audioInputs.length > 0) { if (!audioConnected && audioInputs.length > 0) {
@ -105,9 +132,11 @@ export function MediaHandlerProvider({ client, children }) {
(device) => device.kind === "videoinput" (device) => device.kind === "videoinput"
); );
const videoConnected = videoInputs.some( const videoConnected = videoInputs.some(
// @ts-ignore
(device) => device.deviceId === mediaHandler.videoInput (device) => device.deviceId === mediaHandler.videoInput
); );
// @ts-ignore
let videoInput = mediaHandler.videoInput; let videoInput = mediaHandler.videoInput;
if (!videoConnected && videoInputs.length > 0) { if (!videoConnected && videoInputs.length > 0) {
@ -129,7 +158,9 @@ export function MediaHandlerProvider({ client, children }) {
} }
if ( if (
// @ts-ignore
mediaHandler.videoInput !== videoInput || mediaHandler.videoInput !== videoInput ||
// @ts-ignore
mediaHandler.audioInput !== audioInput mediaHandler.audioInput !== audioInput
) { ) {
mediaHandler.setMediaInputs(audioInput, videoInput); mediaHandler.setMediaInputs(audioInput, videoInput);
@ -149,18 +180,21 @@ export function MediaHandlerProvider({ client, children }) {
} }
updateDevices(); updateDevices();
mediaHandler.on("local_streams_changed", updateDevices); mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, updateDevices);
navigator.mediaDevices.addEventListener("devicechange", updateDevices); navigator.mediaDevices.addEventListener("devicechange", updateDevices);
return () => { return () => {
mediaHandler.removeListener("local_streams_changed", updateDevices); mediaHandler.removeListener(
MediaHandlerEvent.LocalStreamsChanged,
updateDevices
);
navigator.mediaDevices.removeEventListener("devicechange", updateDevices); navigator.mediaDevices.removeEventListener("devicechange", updateDevices);
mediaHandler.stopAllStreams(); mediaHandler.stopAllStreams();
}; };
}, [client]); }, [client]);
const setAudioInput = useCallback( const setAudioInput: (deviceId: string) => void = useCallback(
(deviceId) => { (deviceId: string) => {
updateMediaPreferences({ audioInput: deviceId }); updateMediaPreferences({ audioInput: deviceId });
setState((prevState) => ({ ...prevState, audioInput: deviceId })); setState((prevState) => ({ ...prevState, audioInput: deviceId }));
client.getMediaHandler().setAudioInput(deviceId); client.getMediaHandler().setAudioInput(deviceId);
@ -168,7 +202,7 @@ export function MediaHandlerProvider({ client, children }) {
[client] [client]
); );
const setVideoInput = useCallback( const setVideoInput: (deviceId: string) => void = useCallback(
(deviceId) => { (deviceId) => {
updateMediaPreferences({ videoInput: deviceId }); updateMediaPreferences({ videoInput: deviceId });
setState((prevState) => ({ ...prevState, videoInput: deviceId })); setState((prevState) => ({ ...prevState, videoInput: deviceId }));
@ -177,35 +211,36 @@ export function MediaHandlerProvider({ client, children }) {
[client] [client]
); );
const setAudioOutput = useCallback((deviceId) => { const setAudioOutput: (deviceId: string) => void = useCallback((deviceId) => {
updateMediaPreferences({ audioOutput: deviceId }); updateMediaPreferences({ audioOutput: deviceId });
setState((prevState) => ({ ...prevState, audioOutput: deviceId })); setState((prevState) => ({ ...prevState, audioOutput: deviceId }));
}, []); }, []);
const context = useMemo( const context: MediaHandlerContextInterface =
() => ({ useMemo<MediaHandlerContextInterface>(
audioInput, () => ({
audioInputs, audioInput,
setAudioInput, audioInputs,
videoInput, setAudioInput,
videoInputs, videoInput,
setVideoInput, videoInputs,
audioOutput, setVideoInput,
audioOutputs, audioOutput,
setAudioOutput, audioOutputs,
}), setAudioOutput,
[ }),
audioInput, [
audioInputs, audioInput,
setAudioInput, audioInputs,
videoInput, setAudioInput,
videoInputs, videoInput,
setVideoInput, videoInputs,
audioOutput, setVideoInput,
audioOutputs, audioOutput,
setAudioOutput, audioOutputs,
] setAudioOutput,
); ]
);
return ( return (
<MediaHandlerContext.Provider value={context}> <MediaHandlerContext.Provider value={context}>

View file

@ -58,8 +58,12 @@ export const usePTTSounds = (): PTTSounds => {
break; break;
} }
if (ref.current) { if (ref.current) {
ref.current.currentTime = 0; try {
await ref.current.play(); ref.current.currentTime = 0;
await ref.current.play();
} catch (e) {
console.log("Couldn't play sound effect", e);
}
} else { } else {
console.log("No media element found"); console.log("No media element found");
} }

View file

@ -16,6 +16,10 @@ limitations under the License.
import { useRef, useEffect, RefObject } from "react"; import { useRef, useEffect, RefObject } from "react";
import { parse as parseSdp, write as writeSdp } from "sdp-transform"; 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"; import { useSpatialAudio } from "../settings/useSetting";
@ -68,9 +72,15 @@ export const useMediaStream = (
audioOutputDevice && audioOutputDevice &&
mediaRef.current !== undefined mediaRef.current !== undefined
) { ) {
console.log(`useMediaStream setSinkId ${audioOutputDevice}`); if (mediaRef.current.setSinkId) {
// Chrome for Android doesn't support this console.log(
mediaRef.current.setSinkId?.(audioOutputDevice); `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]); }, [audioOutputDevice]);
@ -146,7 +156,7 @@ export const useAudioContext = (): [
useEffect(() => { useEffect(() => {
if (audioRef.current && !context.current) { if (audioRef.current && !context.current) {
context.current = new AudioContext(); context.current = acquireContext();
if (window.chrome) { if (window.chrome) {
// We're in Chrome, which needs a loopback hack applied to enable AEC // We're in Chrome, which needs a loopback hack applied to enable AEC
@ -160,9 +170,11 @@ export const useAudioContext = (): [
})(); })();
return () => { return () => {
audioEl.srcObject = null; audioEl.srcObject = null;
releaseContext();
}; };
} else { } else {
destination.current = context.current.destination; destination.current = context.current.destination;
return releaseContext;
} }
} }
}, []); }, []);

6846
yarn.lock

File diff suppressed because it is too large Load diff