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",
"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",

View file

@ -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 },

View file

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

View file

@ -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} />

View file

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

View file

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

View file

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

View file

@ -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);

View file

@ -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 {

View file

@ -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]

View file

@ -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,
};
}

View file

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

View file

@ -19,12 +19,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...");

View file

@ -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 {
@ -125,6 +130,13 @@ export function GroupCallView({
);
} else if (left) {
return <CallEndedView client={client} />;
} else {
if (isEmbedded) {
return (
<FullScreenView>
<h1>Loading room...</h1>
</FullScreenView>
);
} else {
return (
<LobbyView
@ -142,7 +154,9 @@ export function GroupCallView({
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
roomId={roomId}
isEmbedded={isEmbedded}
/>
);
}
}
}

View file

@ -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.

View file

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

View file

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

View file

@ -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]);

View file

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

View file

@ -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,17 +173,22 @@ export const PTTCallView: React.FC<Props> = ({
// https://github.com/vector-im/element-call/issues/328
show={false}
/>
{showControls && (
<Header className={styles.header}>
<LeftNav>
<RoomSetupHeaderInfo
roomName={roomName}
avatarUrl={avatarUrl}
onPress={onLeave}
isEmbedded={isEmbedded}
/>
</LeftNav>
<RightNav />
</Header>
)}
<div className={styles.center}>
{showControls && (
<>
<div className={styles.participants}>
<p>{`${participants.length} ${
participants.length > 1 ? "people" : "person"
@ -203,12 +211,15 @@ export const PTTCallView: React.FC<Props> = ({
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
<HangupButton onPress={onLeave} />
{!isEmbedded && <HangupButton onPress={onLeave} />}
<InviteButton onPress={() => inviteModalState.open()} />
</div>
</>
)}
<div className={styles.pttButtonContainer}>
{activeSpeakerUserId ? (
{showControls &&
(activeSpeakerUserId ? (
<div className={styles.talkingInfo}>
<h2>
{!activeSpeakerIsLocalUser && (
@ -222,7 +233,7 @@ export const PTTCallView: React.FC<Props> = ({
</div>
) : (
<div className={styles.talkingInfo} />
)}
))}
<PTTButton
enabled={!feedbackModalState.isOpen}
showTalkOverError={showTalkOverError}
@ -238,6 +249,7 @@ export const PTTCallView: React.FC<Props> = ({
enqueueNetworkWaiting={enqueueTalkingExpected}
setNetworkWaiting={setTalkingExpected}
/>
{showControls && (
<p className={styles.actionTip}>
{getPromptText(
networkWaiting,
@ -250,6 +262,7 @@ export const PTTCallView: React.FC<Props> = ({
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>

View file

@ -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>

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({
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;
}

View file

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

View file

@ -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>

View file

@ -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;
};
};

View file

@ -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]);

View file

@ -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,12 +211,13 @@ 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(
const context: MediaHandlerContextInterface =
useMemo<MediaHandlerContextInterface>(
() => ({
audioInput,
audioInputs,

View file

@ -58,8 +58,12 @@ export const usePTTSounds = (): PTTSounds => {
break;
}
if (ref.current) {
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");
}

View file

@ -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}`);
if (mediaRef.current.setSinkId) {
console.log(
`useMediaStream setting output setSinkId ${audioOutputDevice}`
);
// Chrome for Android doesn't support this
mediaRef.current.setSinkId?.(audioOutputDevice);
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;
}
}
}, []);

6846
yarn.lock

File diff suppressed because it is too large Load diff