Merge branch 'main' into matroska
This commit is contained in:
		
				commit
				
					
						7fab4ca1ba
					
				
			
		
					 34 changed files with 4059 additions and 3874 deletions
				
			
		
							
								
								
									
										67
									
								
								.github/ISSUE_TEMPLATE/bug.yml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								.github/ISSUE_TEMPLATE/bug.yml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,67 @@
 | 
			
		|||
name: Bug report
 | 
			
		||||
description: Create a report to help us improve
 | 
			
		||||
labels: [T-Defect]
 | 
			
		||||
body:
 | 
			
		||||
  - type: markdown
 | 
			
		||||
    attributes:
 | 
			
		||||
      value: |
 | 
			
		||||
        Thanks for taking the time to fill out this bug report!
 | 
			
		||||
 | 
			
		||||
        Please report security issues by email to security@matrix.org
 | 
			
		||||
  - type: textarea
 | 
			
		||||
    id: reproduction-steps
 | 
			
		||||
    attributes:
 | 
			
		||||
      label: Steps to reproduce
 | 
			
		||||
      description: Please attach screenshots, videos or logs if you can.
 | 
			
		||||
      placeholder: Tell us what you see!
 | 
			
		||||
      value: |
 | 
			
		||||
        1. Where are you starting? What can you see?
 | 
			
		||||
        2. What do you click?
 | 
			
		||||
        3. More steps…
 | 
			
		||||
    validations:
 | 
			
		||||
      required: true
 | 
			
		||||
  - type: textarea
 | 
			
		||||
    id: result
 | 
			
		||||
    attributes:
 | 
			
		||||
      label: Outcome
 | 
			
		||||
      placeholder: Tell us what went wrong
 | 
			
		||||
      value: |
 | 
			
		||||
        #### What did you expect?
 | 
			
		||||
 | 
			
		||||
        #### What happened instead?
 | 
			
		||||
    validations:
 | 
			
		||||
      required: true
 | 
			
		||||
  - type: input
 | 
			
		||||
    id: os
 | 
			
		||||
    attributes:
 | 
			
		||||
      label: Operating system
 | 
			
		||||
      placeholder: Windows, macOS, Ubuntu, Android…
 | 
			
		||||
    validations:
 | 
			
		||||
      required: false
 | 
			
		||||
  - type: input
 | 
			
		||||
    id: browser
 | 
			
		||||
    attributes:
 | 
			
		||||
      label: Browser information
 | 
			
		||||
      description: Which browser are you using? Which version? 
 | 
			
		||||
      placeholder: e.g. Chromium Version 92.0.4515.131
 | 
			
		||||
    validations:
 | 
			
		||||
      required: false
 | 
			
		||||
  - type: input
 | 
			
		||||
    id: webapp-url
 | 
			
		||||
    attributes:
 | 
			
		||||
      label: URL for webapp
 | 
			
		||||
      description: Which URL are you using to access the webapp? If a private server, tell us what version of Element Call you are using.
 | 
			
		||||
      placeholder: e.g. call.element.io
 | 
			
		||||
    validations:
 | 
			
		||||
      required: false
 | 
			
		||||
  - type: dropdown
 | 
			
		||||
    id: rageshake
 | 
			
		||||
    attributes:
 | 
			
		||||
      label: Will you send logs?
 | 
			
		||||
      description:  |
 | 
			
		||||
        To send them, press the 'Submit Feedback' button and check 'Include Debug Logs'. Please link to this issue in the description field.
 | 
			
		||||
      options:
 | 
			
		||||
        - 'Yes'
 | 
			
		||||
        - 'No'
 | 
			
		||||
    validations:
 | 
			
		||||
      required: true
 | 
			
		||||
							
								
								
									
										5
									
								
								.github/ISSUE_TEMPLATE/config.yml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.github/ISSUE_TEMPLATE/config.yml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
blank_issues_enabled: false
 | 
			
		||||
contact_links:
 | 
			
		||||
  - name: Questions & support
 | 
			
		||||
    url: https://matrix.to/#/#webrtc:matrix.org
 | 
			
		||||
    about: Please ask and answer questions here.
 | 
			
		||||
							
								
								
									
										36
									
								
								.github/ISSUE_TEMPLATE/enhancement.yml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								.github/ISSUE_TEMPLATE/enhancement.yml
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,36 @@
 | 
			
		|||
name: Enhancement request
 | 
			
		||||
description: Do you have a suggestion or feature request?
 | 
			
		||||
labels: [T-Enhancement]
 | 
			
		||||
body:
 | 
			
		||||
  - type: markdown
 | 
			
		||||
    attributes:
 | 
			
		||||
      value: |
 | 
			
		||||
        Thank you for taking the time to propose a new feature or make a suggestion.
 | 
			
		||||
  - type: textarea
 | 
			
		||||
    id: usecase
 | 
			
		||||
    attributes:
 | 
			
		||||
      label: Your use case
 | 
			
		||||
      description: What would you like to be able to do? Please feel welcome to include screenshots or mock ups.
 | 
			
		||||
      placeholder: Tell us what you would like to do!
 | 
			
		||||
      value: |
 | 
			
		||||
        #### What would you like to do?
 | 
			
		||||
 | 
			
		||||
        #### Why would you like to do it?
 | 
			
		||||
 | 
			
		||||
        #### How would you like to achieve it?
 | 
			
		||||
    validations:
 | 
			
		||||
      required: true
 | 
			
		||||
  - type: textarea
 | 
			
		||||
    id: alternative
 | 
			
		||||
    attributes:
 | 
			
		||||
      label: Have you considered any alternatives?
 | 
			
		||||
      placeholder: A clear and concise description of any alternative solutions or features you've considered.
 | 
			
		||||
    validations:
 | 
			
		||||
      required: false
 | 
			
		||||
  - type: textarea
 | 
			
		||||
    id: additional-context
 | 
			
		||||
    attributes:
 | 
			
		||||
      label: Additional context
 | 
			
		||||
      placeholder: Is there anything else you'd like to add?
 | 
			
		||||
    validations:
 | 
			
		||||
      required: false
 | 
			
		||||
| 
						 | 
				
			
			@ -38,12 +38,12 @@
 | 
			
		|||
    "classnames": "^2.3.1",
 | 
			
		||||
    "color-hash": "^2.0.1",
 | 
			
		||||
    "events": "^3.3.0",
 | 
			
		||||
    "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#ebcb26f1b3b9e2d709615fde03f9ce6ac77871f1",
 | 
			
		||||
    "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#984dd26a138411ef73903ff4e635f2752e0829f2",
 | 
			
		||||
    "matrix-widget-api": "^0.1.0-beta.18",
 | 
			
		||||
    "mermaid": "^8.13.8",
 | 
			
		||||
    "normalize.css": "^8.0.1",
 | 
			
		||||
    "pako": "^2.0.4",
 | 
			
		||||
    "postcss-preset-env": "^6.7.0",
 | 
			
		||||
    "postcss-preset-env": "^7",
 | 
			
		||||
    "re-resizable": "^6.9.0",
 | 
			
		||||
    "react": "^17.0.0",
 | 
			
		||||
    "react-dom": "^17.0.0",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -72,7 +72,11 @@ type ClientProviderState = Omit<
 | 
			
		|||
  "changePassword" | "logout" | "setClient"
 | 
			
		||||
> & { error?: Error };
 | 
			
		||||
 | 
			
		||||
export const ClientProvider: FC = ({ children }) => {
 | 
			
		||||
interface Props {
 | 
			
		||||
  children: JSX.Element;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ClientProvider: FC<Props> = ({ children }) => {
 | 
			
		||||
  const history = useHistory();
 | 
			
		||||
  const [
 | 
			
		||||
    { loading, isAuthenticated, isPasswordlessUser, client, userName, error },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -24,7 +24,11 @@ export function Facepile({
 | 
			
		|||
    <div
 | 
			
		||||
      className={classNames(styles.facepile, styles[size], className)}
 | 
			
		||||
      title={participants.map((member) => member.name).join(", ")}
 | 
			
		||||
      style={{ width: participants.length * (_size - _overlap) + _overlap }}
 | 
			
		||||
      style={{
 | 
			
		||||
        width:
 | 
			
		||||
          Math.min(participants.length, max + 1) * (_size - _overlap) +
 | 
			
		||||
          _overlap,
 | 
			
		||||
      }}
 | 
			
		||||
      {...rest}
 | 
			
		||||
    >
 | 
			
		||||
      {participants.slice(0, max).map((member, i) => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -77,9 +77,23 @@ export function RoomHeaderInfo({ roomName, avatarUrl }) {
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function RoomSetupHeaderInfo({ roomName, avatarUrl, ...rest }) {
 | 
			
		||||
export function RoomSetupHeaderInfo({
 | 
			
		||||
  roomName,
 | 
			
		||||
  avatarUrl,
 | 
			
		||||
  isEmbedded,
 | 
			
		||||
  ...rest
 | 
			
		||||
}) {
 | 
			
		||||
  const ref = useRef();
 | 
			
		||||
  const { buttonProps } = useButton(rest, ref);
 | 
			
		||||
 | 
			
		||||
  if (isEmbedded) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div ref={ref}>
 | 
			
		||||
        <RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <button className={styles.backButton} ref={ref} {...buttonProps}>
 | 
			
		||||
      <ArrowLeftIcon width={16} height={16} />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -13,9 +13,12 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		|||
See the License for the specific language governing permissions and
 | 
			
		||||
limitations under the License.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
import React, { forwardRef } from "react";
 | 
			
		||||
import { PressEvent } from "@react-types/shared";
 | 
			
		||||
import classNames from "classnames";
 | 
			
		||||
import { useButton } from "@react-aria/button";
 | 
			
		||||
import { mergeProps, useObjectRef } from "@react-aria/utils";
 | 
			
		||||
 | 
			
		||||
import styles from "./Button.module.css";
 | 
			
		||||
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
 | 
			
		||||
import { ReactComponent as MuteMicIcon } from "../icons/MuteMic.svg";
 | 
			
		||||
| 
						 | 
				
			
			@ -26,10 +29,21 @@ import { ReactComponent as ScreenshareIcon } from "../icons/Screenshare.svg";
 | 
			
		|||
import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
 | 
			
		||||
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
 | 
			
		||||
import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
 | 
			
		||||
import { useButton } from "@react-aria/button";
 | 
			
		||||
import { mergeProps, useObjectRef } from "@react-aria/utils";
 | 
			
		||||
import { TooltipTrigger } from "../Tooltip";
 | 
			
		||||
 | 
			
		||||
export type ButtonVariant =
 | 
			
		||||
  | "default"
 | 
			
		||||
  | "toolbar"
 | 
			
		||||
  | "toolbarSecondary"
 | 
			
		||||
  | "icon"
 | 
			
		||||
  | "secondary"
 | 
			
		||||
  | "copy"
 | 
			
		||||
  | "secondaryCopy"
 | 
			
		||||
  | "iconCopy"
 | 
			
		||||
  | "secondaryHangup"
 | 
			
		||||
  | "dropdown"
 | 
			
		||||
  | "link";
 | 
			
		||||
 | 
			
		||||
export const variantToClassName = {
 | 
			
		||||
  default: [styles.button],
 | 
			
		||||
  toolbar: [styles.toolbarButton],
 | 
			
		||||
| 
						 | 
				
			
			@ -44,11 +58,24 @@ export const variantToClassName = {
 | 
			
		|||
  link: [styles.linkButton],
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const sizeToClassName = {
 | 
			
		||||
export type ButtonSize = "lg";
 | 
			
		||||
 | 
			
		||||
export const sizeToClassName: { lg: string[] } = {
 | 
			
		||||
  lg: [styles.lg],
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Button = forwardRef(
 | 
			
		||||
interface Props {
 | 
			
		||||
  variant: ButtonVariant;
 | 
			
		||||
  size: ButtonSize;
 | 
			
		||||
  on: () => void;
 | 
			
		||||
  off: () => void;
 | 
			
		||||
  iconStyle: string;
 | 
			
		||||
  className: string;
 | 
			
		||||
  children: Element[];
 | 
			
		||||
  onPress: (e: PressEvent) => void;
 | 
			
		||||
  onPressStart: (e: PressEvent) => void;
 | 
			
		||||
  [index: string]: unknown;
 | 
			
		||||
}
 | 
			
		||||
export const Button = forwardRef<HTMLButtonElement, Props>(
 | 
			
		||||
  (
 | 
			
		||||
    {
 | 
			
		||||
      variant = "default",
 | 
			
		||||
| 
						 | 
				
			
			@ -64,7 +91,7 @@ export const Button = forwardRef(
 | 
			
		|||
    },
 | 
			
		||||
    ref
 | 
			
		||||
  ) => {
 | 
			
		||||
    const buttonRef = useObjectRef(ref);
 | 
			
		||||
    const buttonRef = useObjectRef<HTMLButtonElement>(ref);
 | 
			
		||||
    const { buttonProps } = useButton(
 | 
			
		||||
      { onPress, onPressStart, ...rest },
 | 
			
		||||
      buttonRef
 | 
			
		||||
| 
						 | 
				
			
			@ -75,7 +102,7 @@ export const Button = forwardRef(
 | 
			
		|||
    let filteredButtonProps = buttonProps;
 | 
			
		||||
 | 
			
		||||
    if (rest.type === "submit" && !rest.onPress) {
 | 
			
		||||
      const { onKeyDown, onKeyUp, ...filtered } = buttonProps;
 | 
			
		||||
      const { ...filtered } = buttonProps;
 | 
			
		||||
      filteredButtonProps = filtered;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -94,14 +121,22 @@ export const Button = forwardRef(
 | 
			
		|||
        {...mergeProps(rest, filteredButtonProps)}
 | 
			
		||||
        ref={buttonRef}
 | 
			
		||||
      >
 | 
			
		||||
        {children}
 | 
			
		||||
        {variant === "dropdown" && <ArrowDownIcon />}
 | 
			
		||||
        <>
 | 
			
		||||
          {children}
 | 
			
		||||
          {variant === "dropdown" && <ArrowDownIcon />}
 | 
			
		||||
        </>
 | 
			
		||||
      </button>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export function MicButton({ muted, ...rest }) {
 | 
			
		||||
export function MicButton({
 | 
			
		||||
  muted,
 | 
			
		||||
  ...rest
 | 
			
		||||
}: {
 | 
			
		||||
  muted: boolean;
 | 
			
		||||
  [index: string]: unknown;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <TooltipTrigger>
 | 
			
		||||
      <Button variant="toolbar" {...rest} off={muted}>
 | 
			
		||||
| 
						 | 
				
			
			@ -112,7 +147,13 @@ export function MicButton({ muted, ...rest }) {
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function VideoButton({ muted, ...rest }) {
 | 
			
		||||
export function VideoButton({
 | 
			
		||||
  muted,
 | 
			
		||||
  ...rest
 | 
			
		||||
}: {
 | 
			
		||||
  muted: boolean;
 | 
			
		||||
  [index: string]: unknown;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <TooltipTrigger>
 | 
			
		||||
      <Button variant="toolbar" {...rest} off={muted}>
 | 
			
		||||
| 
						 | 
				
			
			@ -123,7 +164,15 @@ export function VideoButton({ muted, ...rest }) {
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function ScreenshareButton({ enabled, className, ...rest }) {
 | 
			
		||||
export function ScreenshareButton({
 | 
			
		||||
  enabled,
 | 
			
		||||
  className,
 | 
			
		||||
  ...rest
 | 
			
		||||
}: {
 | 
			
		||||
  enabled: boolean;
 | 
			
		||||
  className?: string;
 | 
			
		||||
  [index: string]: unknown;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <TooltipTrigger>
 | 
			
		||||
      <Button variant="toolbarSecondary" {...rest} on={enabled}>
 | 
			
		||||
| 
						 | 
				
			
			@ -134,7 +183,13 @@ export function ScreenshareButton({ enabled, className, ...rest }) {
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function HangupButton({ className, ...rest }) {
 | 
			
		||||
export function HangupButton({
 | 
			
		||||
  className,
 | 
			
		||||
  ...rest
 | 
			
		||||
}: {
 | 
			
		||||
  className?: string;
 | 
			
		||||
  [index: string]: unknown;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <TooltipTrigger>
 | 
			
		||||
      <Button
 | 
			
		||||
| 
						 | 
				
			
			@ -149,7 +204,13 @@ export function HangupButton({ className, ...rest }) {
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function SettingsButton({ className, ...rest }) {
 | 
			
		||||
export function SettingsButton({
 | 
			
		||||
  className,
 | 
			
		||||
  ...rest
 | 
			
		||||
}: {
 | 
			
		||||
  className?: string;
 | 
			
		||||
  [index: string]: unknown;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <TooltipTrigger>
 | 
			
		||||
      <Button variant="toolbar" {...rest}>
 | 
			
		||||
| 
						 | 
				
			
			@ -160,7 +221,13 @@ export function SettingsButton({ className, ...rest }) {
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function InviteButton({ className, ...rest }) {
 | 
			
		||||
export function InviteButton({
 | 
			
		||||
  className,
 | 
			
		||||
  ...rest
 | 
			
		||||
}: {
 | 
			
		||||
  className?: string;
 | 
			
		||||
  [index: string]: unknown;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <TooltipTrigger>
 | 
			
		||||
      <Button variant="toolbar" {...rest}>
 | 
			
		||||
| 
						 | 
				
			
			@ -16,10 +16,18 @@ limitations under the License.
 | 
			
		|||
 | 
			
		||||
import React from "react";
 | 
			
		||||
import useClipboard from "react-use-clipboard";
 | 
			
		||||
 | 
			
		||||
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
 | 
			
		||||
import { ReactComponent as CopyIcon } from "../icons/Copy.svg";
 | 
			
		||||
import { Button } from "./Button";
 | 
			
		||||
import { Button, ButtonVariant } from "./Button";
 | 
			
		||||
 | 
			
		||||
interface Props {
 | 
			
		||||
  value: string;
 | 
			
		||||
  children: JSX.Element;
 | 
			
		||||
  className: string;
 | 
			
		||||
  variant: ButtonVariant;
 | 
			
		||||
  copiedMessage: string;
 | 
			
		||||
}
 | 
			
		||||
export function CopyButton({
 | 
			
		||||
  value,
 | 
			
		||||
  children,
 | 
			
		||||
| 
						 | 
				
			
			@ -27,7 +35,7 @@ export function CopyButton({
 | 
			
		|||
  variant,
 | 
			
		||||
  copiedMessage,
 | 
			
		||||
  ...rest
 | 
			
		||||
}) {
 | 
			
		||||
}: Props) {
 | 
			
		||||
  const [isCopied, setCopied] = useClipboard(value, { successDuration: 3000 });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
| 
						 | 
				
			
			@ -17,9 +17,28 @@ limitations under the License.
 | 
			
		|||
import React from "react";
 | 
			
		||||
import { Link } from "react-router-dom";
 | 
			
		||||
import classNames from "classnames";
 | 
			
		||||
import { variantToClassName, sizeToClassName } from "./Button";
 | 
			
		||||
 | 
			
		||||
export function LinkButton({ className, variant, size, children, ...rest }) {
 | 
			
		||||
import {
 | 
			
		||||
  variantToClassName,
 | 
			
		||||
  sizeToClassName,
 | 
			
		||||
  ButtonVariant,
 | 
			
		||||
  ButtonSize,
 | 
			
		||||
} from "./Button";
 | 
			
		||||
interface Props {
 | 
			
		||||
  className: string;
 | 
			
		||||
  variant: ButtonVariant;
 | 
			
		||||
  size: ButtonSize;
 | 
			
		||||
  children: JSX.Element;
 | 
			
		||||
  [index: string]: unknown;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function LinkButton({
 | 
			
		||||
  className,
 | 
			
		||||
  variant,
 | 
			
		||||
  size,
 | 
			
		||||
  children,
 | 
			
		||||
  ...rest
 | 
			
		||||
}: Props) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Link
 | 
			
		||||
      className={classNames(
 | 
			
		||||
| 
						 | 
				
			
			@ -36,6 +36,14 @@ initRageshake();
 | 
			
		|||
 | 
			
		||||
console.info(`matrix-video-chat ${import.meta.env.VITE_APP_VERSION || "dev"}`);
 | 
			
		||||
 | 
			
		||||
if (!window.isSecureContext) {
 | 
			
		||||
  throw new Error(
 | 
			
		||||
    "This app cannot run in an insecure context. To fix this, access the app " +
 | 
			
		||||
      "via a local loopback address, or serve it over HTTPS.\n" +
 | 
			
		||||
      "https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts"
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
if (import.meta.env.VITE_CUSTOM_THEME) {
 | 
			
		||||
  const style = document.documentElement.style;
 | 
			
		||||
  style.setProperty("--accent", import.meta.env.VITE_THEME_ACCENT as string);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,6 +16,7 @@ import {
 | 
			
		|||
} from "matrix-js-sdk/src/webrtc/groupCall";
 | 
			
		||||
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
 | 
			
		||||
import { WidgetApi } from "matrix-widget-api";
 | 
			
		||||
import { logger } from "matrix-js-sdk/src/logger";
 | 
			
		||||
 | 
			
		||||
import IndexedDBWorker from "./IndexedDBWorker?worker";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -138,6 +139,19 @@ export async function initClient(
 | 
			
		|||
    storeOpts.cryptoStore = new MemoryCryptoStore();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // XXX: we read from the URL search params in RoomPage too:
 | 
			
		||||
  // it would be much better to read them in one place and pass
 | 
			
		||||
  // the values around, but we initialise the matrix client in
 | 
			
		||||
  // many different places so we'd have to pass it into all of
 | 
			
		||||
  // them.
 | 
			
		||||
  const params = new URLSearchParams(window.location.search);
 | 
			
		||||
  // disable e2e only if enableE2e=false is given
 | 
			
		||||
  const enableE2e = params.get("enableE2e") !== "false";
 | 
			
		||||
 | 
			
		||||
  if (!enableE2e) {
 | 
			
		||||
    logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const client = createClient({
 | 
			
		||||
    ...storeOpts,
 | 
			
		||||
    ...clientOptions,
 | 
			
		||||
| 
						 | 
				
			
			@ -145,6 +159,7 @@ export async function initClient(
 | 
			
		|||
    // Use a relatively low timeout for API calls: this is a realtime app
 | 
			
		||||
    // so we don't want API calls taking ages, we'd rather they just fail.
 | 
			
		||||
    localTimeoutMs: 5000,
 | 
			
		||||
    useE2eForGroupCall: enableE2e,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,6 +15,8 @@ limitations under the License.
 | 
			
		|||
*/
 | 
			
		||||
 | 
			
		||||
import React, { useCallback, useEffect, useState } from "react";
 | 
			
		||||
import { MatrixClient } from "matrix-js-sdk";
 | 
			
		||||
 | 
			
		||||
import { Button } from "../button";
 | 
			
		||||
import { useProfile } from "./useProfile";
 | 
			
		||||
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
 | 
			
		||||
| 
						 | 
				
			
			@ -22,7 +24,12 @@ import { Modal, ModalContent } from "../Modal";
 | 
			
		|||
import { AvatarInputField } from "../input/AvatarInputField";
 | 
			
		||||
import styles from "./ProfileModal.module.css";
 | 
			
		||||
 | 
			
		||||
export function ProfileModal({ client, ...rest }) {
 | 
			
		||||
interface Props {
 | 
			
		||||
  client: MatrixClient;
 | 
			
		||||
  onClose: () => {};
 | 
			
		||||
  [rest: string]: unknown;
 | 
			
		||||
}
 | 
			
		||||
export function ProfileModal({ client, ...rest }: Props) {
 | 
			
		||||
  const { onClose } = rest;
 | 
			
		||||
  const {
 | 
			
		||||
    success,
 | 
			
		||||
| 
						 | 
				
			
			@ -50,13 +57,20 @@ export function ProfileModal({ client, ...rest }) {
 | 
			
		|||
    (e) => {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      const data = new FormData(e.target);
 | 
			
		||||
      const displayName = data.get("displayName");
 | 
			
		||||
      const avatar = data.get("avatar");
 | 
			
		||||
      const displayNameDataEntry = data.get("displayName");
 | 
			
		||||
      const avatar: File | string = data.get("avatar");
 | 
			
		||||
 | 
			
		||||
      const avatarSize =
 | 
			
		||||
        typeof avatar == "string" ? avatar.length : avatar.size;
 | 
			
		||||
      const displayName =
 | 
			
		||||
        typeof displayNameDataEntry == "string"
 | 
			
		||||
          ? displayNameDataEntry
 | 
			
		||||
          : displayNameDataEntry.name;
 | 
			
		||||
 | 
			
		||||
      saveProfile({
 | 
			
		||||
        displayName,
 | 
			
		||||
        avatar: avatar && avatar.size > 0 ? avatar : undefined,
 | 
			
		||||
        removeAvatar: removeAvatar && (!avatar || avatar.size === 0),
 | 
			
		||||
        avatar: avatar && avatarSize > 0 ? avatar : undefined,
 | 
			
		||||
        removeAvatar: removeAvatar && (!avatar || avatarSize === 0),
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    [saveProfile, removeAvatar]
 | 
			
		||||
| 
						 | 
				
			
			@ -14,11 +14,33 @@ See the License for the specific language governing permissions and
 | 
			
		|||
limitations under the License.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
import { MatrixClient } from "matrix-js-sdk/src/client";
 | 
			
		||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
 | 
			
		||||
import { User, UserEvent } from "matrix-js-sdk/src/models/user";
 | 
			
		||||
import { FileType } from "matrix-js-sdk/src/http-api";
 | 
			
		||||
import { useState, useCallback, useEffect } from "react";
 | 
			
		||||
 | 
			
		||||
export function useProfile(client) {
 | 
			
		||||
interface ProfileLoadState {
 | 
			
		||||
  success?: boolean;
 | 
			
		||||
  loading?: boolean;
 | 
			
		||||
  displayName: string;
 | 
			
		||||
  avatarUrl: string;
 | 
			
		||||
  error?: Error;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ProfileSaveCallback = ({
 | 
			
		||||
  displayName,
 | 
			
		||||
  avatar,
 | 
			
		||||
  removeAvatar,
 | 
			
		||||
}: {
 | 
			
		||||
  displayName: string;
 | 
			
		||||
  avatar: FileType;
 | 
			
		||||
  removeAvatar: boolean;
 | 
			
		||||
}) => Promise<void>;
 | 
			
		||||
 | 
			
		||||
export function useProfile(client: MatrixClient) {
 | 
			
		||||
  const [{ loading, displayName, avatarUrl, error, success }, setState] =
 | 
			
		||||
    useState(() => {
 | 
			
		||||
    useState<ProfileLoadState>(() => {
 | 
			
		||||
      const user = client?.getUser(client.getUserId());
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
| 
						 | 
				
			
			@ -31,7 +53,10 @@ export function useProfile(client) {
 | 
			
		|||
    });
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const onChangeUser = (_event, { displayName, avatarUrl }) => {
 | 
			
		||||
    const onChangeUser = (
 | 
			
		||||
      _event: MatrixEvent,
 | 
			
		||||
      { displayName, avatarUrl }: User
 | 
			
		||||
    ) => {
 | 
			
		||||
      setState({
 | 
			
		||||
        success: false,
 | 
			
		||||
        loading: false,
 | 
			
		||||
| 
						 | 
				
			
			@ -41,24 +66,24 @@ export function useProfile(client) {
 | 
			
		|||
      });
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    let user;
 | 
			
		||||
    let user: User;
 | 
			
		||||
 | 
			
		||||
    if (client) {
 | 
			
		||||
      const userId = client.getUserId();
 | 
			
		||||
      user = client.getUser(userId);
 | 
			
		||||
      user.on("User.displayName", onChangeUser);
 | 
			
		||||
      user.on("User.avatarUrl", onChangeUser);
 | 
			
		||||
      user.on(UserEvent.DisplayName, onChangeUser);
 | 
			
		||||
      user.on(UserEvent.AvatarUrl, onChangeUser);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      if (user) {
 | 
			
		||||
        user.removeListener("User.displayName", onChangeUser);
 | 
			
		||||
        user.removeListener("User.avatarUrl", onChangeUser);
 | 
			
		||||
        user.removeListener(UserEvent.DisplayName, onChangeUser);
 | 
			
		||||
        user.removeListener(UserEvent.AvatarUrl, onChangeUser);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
  }, [client]);
 | 
			
		||||
 | 
			
		||||
  const saveProfile = useCallback(
 | 
			
		||||
  const saveProfile = useCallback<ProfileSaveCallback>(
 | 
			
		||||
    async ({ displayName, avatar, removeAvatar }) => {
 | 
			
		||||
      if (client) {
 | 
			
		||||
        setState((prev) => ({
 | 
			
		||||
| 
						 | 
				
			
			@ -71,7 +96,7 @@ export function useProfile(client) {
 | 
			
		|||
        try {
 | 
			
		||||
          await client.setDisplayName(displayName);
 | 
			
		||||
 | 
			
		||||
          let mxcAvatarUrl;
 | 
			
		||||
          let mxcAvatarUrl: string;
 | 
			
		||||
 | 
			
		||||
          if (removeAvatar) {
 | 
			
		||||
            await client.setAvatarUrl("");
 | 
			
		||||
| 
						 | 
				
			
			@ -87,11 +112,11 @@ export function useProfile(client) {
 | 
			
		|||
            loading: false,
 | 
			
		||||
            success: true,
 | 
			
		||||
          }));
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
        } catch (error: unknown) {
 | 
			
		||||
          setState((prev) => ({
 | 
			
		||||
            ...prev,
 | 
			
		||||
            loading: false,
 | 
			
		||||
            error,
 | 
			
		||||
            error: error instanceof Error ? error : Error(error as string),
 | 
			
		||||
            success: false,
 | 
			
		||||
          }));
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			@ -102,5 +127,12 @@ export function useProfile(client) {
 | 
			
		|||
    [client]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return { loading, error, displayName, avatarUrl, saveProfile, success };
 | 
			
		||||
  return {
 | 
			
		||||
    loading,
 | 
			
		||||
    error,
 | 
			
		||||
    displayName,
 | 
			
		||||
    avatarUrl,
 | 
			
		||||
    saveProfile,
 | 
			
		||||
    success,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -53,8 +53,12 @@ export function AudioPreview({
 | 
			
		|||
              onSelectionChange={setAudioInput}
 | 
			
		||||
              className={styles.inputField}
 | 
			
		||||
            >
 | 
			
		||||
              {audioInputs.map(({ deviceId, label }) => (
 | 
			
		||||
                <Item key={deviceId}>{label}</Item>
 | 
			
		||||
              {audioInputs.map(({ deviceId, label }, index) => (
 | 
			
		||||
                <Item key={deviceId}>
 | 
			
		||||
                  {!!label && label.trim().length > 0
 | 
			
		||||
                    ? label
 | 
			
		||||
                    : `Microphone ${index + 1}`}
 | 
			
		||||
                </Item>
 | 
			
		||||
              ))}
 | 
			
		||||
            </SelectInput>
 | 
			
		||||
            {audioOutputs.length > 0 && (
 | 
			
		||||
| 
						 | 
				
			
			@ -64,8 +68,12 @@ export function AudioPreview({
 | 
			
		|||
                onSelectionChange={setAudioOutput}
 | 
			
		||||
                className={styles.inputField}
 | 
			
		||||
              >
 | 
			
		||||
                {audioOutputs.map(({ deviceId, label }) => (
 | 
			
		||||
                  <Item key={deviceId}>{label}</Item>
 | 
			
		||||
                {audioOutputs.map(({ deviceId, label }, index) => (
 | 
			
		||||
                  <Item key={deviceId}>
 | 
			
		||||
                    {!!label && label.trim().length > 0
 | 
			
		||||
                      ? label
 | 
			
		||||
                      : `Speaker ${index + 1}`}
 | 
			
		||||
                  </Item>
 | 
			
		||||
                ))}
 | 
			
		||||
              </SelectInput>
 | 
			
		||||
            )}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,12 +19,19 @@ import { useLoadGroupCall } from "./useLoadGroupCall";
 | 
			
		|||
import { ErrorView, FullScreenView } from "../FullScreenView";
 | 
			
		||||
import { usePageTitle } from "../usePageTitle";
 | 
			
		||||
 | 
			
		||||
export function GroupCallLoader({ client, roomId, viaServers, children }) {
 | 
			
		||||
export function GroupCallLoader({
 | 
			
		||||
  client,
 | 
			
		||||
  roomId,
 | 
			
		||||
  viaServers,
 | 
			
		||||
  createPtt,
 | 
			
		||||
  children,
 | 
			
		||||
}) {
 | 
			
		||||
  const { loading, error, groupCall } = useLoadGroupCall(
 | 
			
		||||
    client,
 | 
			
		||||
    roomId,
 | 
			
		||||
    viaServers,
 | 
			
		||||
    true
 | 
			
		||||
    true,
 | 
			
		||||
    createPtt
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  usePageTitle(groupCall ? groupCall.room.name : "Loading...");
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -30,6 +30,7 @@ import { useLocationNavigation } from "../useLocationNavigation";
 | 
			
		|||
export function GroupCallView({
 | 
			
		||||
  client,
 | 
			
		||||
  isPasswordlessUser,
 | 
			
		||||
  isEmbedded,
 | 
			
		||||
  roomId,
 | 
			
		||||
  groupCall,
 | 
			
		||||
}) {
 | 
			
		||||
| 
						 | 
				
			
			@ -60,7 +61,10 @@ export function GroupCallView({
 | 
			
		|||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    window.groupCall = groupCall;
 | 
			
		||||
  }, [groupCall]);
 | 
			
		||||
 | 
			
		||||
    // In embedded mode, bypass the lobby and just enter the call straight away
 | 
			
		||||
    if (isEmbedded) groupCall.enter();
 | 
			
		||||
  }, [groupCall, isEmbedded]);
 | 
			
		||||
 | 
			
		||||
  useSentryGroupCallHandler(groupCall);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -92,6 +96,7 @@ export function GroupCallView({
 | 
			
		|||
          participants={participants}
 | 
			
		||||
          userMediaFeeds={userMediaFeeds}
 | 
			
		||||
          onLeave={onLeave}
 | 
			
		||||
          isEmbedded={isEmbedded}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    } else {
 | 
			
		||||
| 
						 | 
				
			
			@ -126,23 +131,32 @@ export function GroupCallView({
 | 
			
		|||
  } else if (left) {
 | 
			
		||||
    return <CallEndedView client={client} />;
 | 
			
		||||
  } else {
 | 
			
		||||
    return (
 | 
			
		||||
      <LobbyView
 | 
			
		||||
        client={client}
 | 
			
		||||
        groupCall={groupCall}
 | 
			
		||||
        hasLocalParticipant={hasLocalParticipant}
 | 
			
		||||
        roomName={groupCall.room.name}
 | 
			
		||||
        avatarUrl={avatarUrl}
 | 
			
		||||
        state={state}
 | 
			
		||||
        onInitLocalCallFeed={initLocalCallFeed}
 | 
			
		||||
        localCallFeed={localCallFeed}
 | 
			
		||||
        onEnter={enter}
 | 
			
		||||
        microphoneMuted={microphoneMuted}
 | 
			
		||||
        localVideoMuted={localVideoMuted}
 | 
			
		||||
        toggleLocalVideoMuted={toggleLocalVideoMuted}
 | 
			
		||||
        toggleMicrophoneMuted={toggleMicrophoneMuted}
 | 
			
		||||
        roomId={roomId}
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
    if (isEmbedded) {
 | 
			
		||||
      return (
 | 
			
		||||
        <FullScreenView>
 | 
			
		||||
          <h1>Loading room...</h1>
 | 
			
		||||
        </FullScreenView>
 | 
			
		||||
      );
 | 
			
		||||
    } else {
 | 
			
		||||
      return (
 | 
			
		||||
        <LobbyView
 | 
			
		||||
          client={client}
 | 
			
		||||
          groupCall={groupCall}
 | 
			
		||||
          hasLocalParticipant={hasLocalParticipant}
 | 
			
		||||
          roomName={groupCall.room.name}
 | 
			
		||||
          avatarUrl={avatarUrl}
 | 
			
		||||
          state={state}
 | 
			
		||||
          onInitLocalCallFeed={initLocalCallFeed}
 | 
			
		||||
          localCallFeed={localCallFeed}
 | 
			
		||||
          onEnter={enter}
 | 
			
		||||
          microphoneMuted={microphoneMuted}
 | 
			
		||||
          localVideoMuted={localVideoMuted}
 | 
			
		||||
          toggleLocalVideoMuted={toggleLocalVideoMuted}
 | 
			
		||||
          toggleMicrophoneMuted={toggleMicrophoneMuted}
 | 
			
		||||
          roomId={roomId}
 | 
			
		||||
          isEmbedded={isEmbedded}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -44,7 +44,7 @@ import { useShowInspector } from "../settings/useSetting";
 | 
			
		|||
import { useModalTriggerState } from "../Modal";
 | 
			
		||||
import { useAudioContext } from "../video-grid/useMediaStream";
 | 
			
		||||
 | 
			
		||||
const canScreenshare = "getDisplayMedia" in navigator.mediaDevices;
 | 
			
		||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
 | 
			
		||||
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
 | 
			
		||||
// or with getUsermedia and getDisplaymedia being used within the same session.
 | 
			
		||||
// For now we can disable screensharing in Safari.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -42,6 +42,7 @@ export function LobbyView({
 | 
			
		|||
  toggleLocalVideoMuted,
 | 
			
		||||
  toggleMicrophoneMuted,
 | 
			
		||||
  roomId,
 | 
			
		||||
  isEmbedded,
 | 
			
		||||
}) {
 | 
			
		||||
  const { stream } = useCallFeed(localCallFeed);
 | 
			
		||||
  const {
 | 
			
		||||
| 
						 | 
				
			
			@ -122,11 +123,13 @@ export function LobbyView({
 | 
			
		|||
            Copy call link and join later
 | 
			
		||||
          </CopyButton>
 | 
			
		||||
        </div>
 | 
			
		||||
        <Body className={styles.joinRoomFooter}>
 | 
			
		||||
          <Link color="primary" to="/">
 | 
			
		||||
            Take me Home
 | 
			
		||||
          </Link>
 | 
			
		||||
        </Body>
 | 
			
		||||
        {!isEmbedded && (
 | 
			
		||||
          <Body className={styles.joinRoomFooter}>
 | 
			
		||||
            <Link color="primary" to="/">
 | 
			
		||||
              Take me Home
 | 
			
		||||
            </Link>
 | 
			
		||||
          </Body>
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,17 +1,28 @@
 | 
			
		|||
.pttButton {
 | 
			
		||||
  width: 100vw;
 | 
			
		||||
  height: 100vh;
 | 
			
		||||
  max-height: 232px;
 | 
			
		||||
  max-width: 232px;
 | 
			
		||||
  aspect-ratio: 1;
 | 
			
		||||
  max-height: min(232px, calc(100vh - 16px));
 | 
			
		||||
  max-width: min(232px, calc(100vw - 16px));
 | 
			
		||||
  border-radius: 116px;
 | 
			
		||||
  color: var(--primary-content);
 | 
			
		||||
  border: 6px solid var(--accent);
 | 
			
		||||
  background-color: #21262c;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  margin: 4px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.micIcon {
 | 
			
		||||
  max-height: 50%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar {
 | 
			
		||||
  /* Remove explicit size to allow avatar to scale with the button */
 | 
			
		||||
  width: 100% !important;
 | 
			
		||||
  height: 100% !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.talking {
 | 
			
		||||
  background-color: var(--accent);
 | 
			
		||||
  cursor: unset;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 | 
			
		|||
limitations under the License.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
import React, { useCallback, useState, createRef } from "react";
 | 
			
		||||
import React, { useCallback, useState, useRef } from "react";
 | 
			
		||||
import classNames from "classnames";
 | 
			
		||||
import { useSpring, animated } from "@react-spring/web";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -54,16 +54,20 @@ export const PTTButton: React.FC<Props> = ({
 | 
			
		|||
  enqueueNetworkWaiting,
 | 
			
		||||
  setNetworkWaiting,
 | 
			
		||||
}) => {
 | 
			
		||||
  const buttonRef = createRef<HTMLButtonElement>();
 | 
			
		||||
  const buttonRef = useRef<HTMLButtonElement>();
 | 
			
		||||
 | 
			
		||||
  const [activeTouchId, setActiveTouchId] = useState<number | null>(null);
 | 
			
		||||
  const [buttonHeld, setButtonHeld] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const hold = useCallback(() => {
 | 
			
		||||
    // This update is delayed so the user only sees it if latency is significant
 | 
			
		||||
    if (buttonHeld) return;
 | 
			
		||||
    setButtonHeld(true);
 | 
			
		||||
    enqueueNetworkWaiting(true, 100);
 | 
			
		||||
    startTalking();
 | 
			
		||||
  }, [enqueueNetworkWaiting, startTalking]);
 | 
			
		||||
  }, [enqueueNetworkWaiting, startTalking, buttonHeld]);
 | 
			
		||||
  const unhold = useCallback(() => {
 | 
			
		||||
    setButtonHeld(false);
 | 
			
		||||
    setNetworkWaiting(false);
 | 
			
		||||
    stopTalking();
 | 
			
		||||
  }, [setNetworkWaiting, stopTalking]);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -28,6 +28,7 @@
 | 
			
		|||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  margin: 20px;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.participants > p {
 | 
			
		||||
| 
						 | 
				
			
			@ -41,6 +42,7 @@
 | 
			
		|||
 | 
			
		||||
.talkingInfo {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-shrink: 0;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  margin-bottom: 20px;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -93,6 +93,7 @@ interface Props {
 | 
			
		|||
  participants: RoomMember[];
 | 
			
		||||
  userMediaFeeds: CallFeed[];
 | 
			
		||||
  onLeave: () => void;
 | 
			
		||||
  isEmbedded: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const PTTCallView: React.FC<Props> = ({
 | 
			
		||||
| 
						 | 
				
			
			@ -104,6 +105,7 @@ export const PTTCallView: React.FC<Props> = ({
 | 
			
		|||
  participants,
 | 
			
		||||
  userMediaFeeds,
 | 
			
		||||
  onLeave,
 | 
			
		||||
  isEmbedded,
 | 
			
		||||
}) => {
 | 
			
		||||
  const { modalState: inviteModalState, modalProps: inviteModalProps } =
 | 
			
		||||
    useModalTriggerState();
 | 
			
		||||
| 
						 | 
				
			
			@ -111,6 +113,7 @@ export const PTTCallView: React.FC<Props> = ({
 | 
			
		|||
    useModalTriggerState();
 | 
			
		||||
  const [containerRef, bounds] = useMeasure({ polyfill: ResizeObserver });
 | 
			
		||||
  const facepileSize = bounds.width < 800 ? "sm" : "md";
 | 
			
		||||
  const showControls = bounds.height > 500;
 | 
			
		||||
  const pttButtonSize = 232;
 | 
			
		||||
 | 
			
		||||
  const { audioOutput } = useMediaHandler();
 | 
			
		||||
| 
						 | 
				
			
			@ -170,59 +173,67 @@ export const PTTCallView: React.FC<Props> = ({
 | 
			
		|||
        // https://github.com/vector-im/element-call/issues/328
 | 
			
		||||
        show={false}
 | 
			
		||||
      />
 | 
			
		||||
      <Header className={styles.header}>
 | 
			
		||||
        <LeftNav>
 | 
			
		||||
          <RoomSetupHeaderInfo
 | 
			
		||||
            roomName={roomName}
 | 
			
		||||
            avatarUrl={avatarUrl}
 | 
			
		||||
            onPress={onLeave}
 | 
			
		||||
          />
 | 
			
		||||
        </LeftNav>
 | 
			
		||||
        <RightNav />
 | 
			
		||||
      </Header>
 | 
			
		||||
      {showControls && (
 | 
			
		||||
        <Header className={styles.header}>
 | 
			
		||||
          <LeftNav>
 | 
			
		||||
            <RoomSetupHeaderInfo
 | 
			
		||||
              roomName={roomName}
 | 
			
		||||
              avatarUrl={avatarUrl}
 | 
			
		||||
              onPress={onLeave}
 | 
			
		||||
              isEmbedded={isEmbedded}
 | 
			
		||||
            />
 | 
			
		||||
          </LeftNav>
 | 
			
		||||
          <RightNav />
 | 
			
		||||
        </Header>
 | 
			
		||||
      )}
 | 
			
		||||
      <div className={styles.center}>
 | 
			
		||||
        <div className={styles.participants}>
 | 
			
		||||
          <p>{`${participants.length} ${
 | 
			
		||||
            participants.length > 1 ? "people" : "person"
 | 
			
		||||
          } connected`}</p>
 | 
			
		||||
          <Facepile
 | 
			
		||||
            size={facepileSize}
 | 
			
		||||
            max={8}
 | 
			
		||||
            className={styles.facepile}
 | 
			
		||||
            client={client}
 | 
			
		||||
            participants={participants}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className={styles.footer}>
 | 
			
		||||
          <OverflowMenu
 | 
			
		||||
            inCall
 | 
			
		||||
            roomId={roomId}
 | 
			
		||||
            client={client}
 | 
			
		||||
            groupCall={groupCall}
 | 
			
		||||
            showInvite={false}
 | 
			
		||||
            feedbackModalState={feedbackModalState}
 | 
			
		||||
            feedbackModalProps={feedbackModalProps}
 | 
			
		||||
          />
 | 
			
		||||
          <HangupButton onPress={onLeave} />
 | 
			
		||||
          <InviteButton onPress={() => inviteModalState.open()} />
 | 
			
		||||
        </div>
 | 
			
		||||
        {showControls && (
 | 
			
		||||
          <>
 | 
			
		||||
            <div className={styles.participants}>
 | 
			
		||||
              <p>{`${participants.length} ${
 | 
			
		||||
                participants.length > 1 ? "people" : "person"
 | 
			
		||||
              } connected`}</p>
 | 
			
		||||
              <Facepile
 | 
			
		||||
                size={facepileSize}
 | 
			
		||||
                max={8}
 | 
			
		||||
                className={styles.facepile}
 | 
			
		||||
                client={client}
 | 
			
		||||
                participants={participants}
 | 
			
		||||
              />
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className={styles.footer}>
 | 
			
		||||
              <OverflowMenu
 | 
			
		||||
                inCall
 | 
			
		||||
                roomId={roomId}
 | 
			
		||||
                client={client}
 | 
			
		||||
                groupCall={groupCall}
 | 
			
		||||
                showInvite={false}
 | 
			
		||||
                feedbackModalState={feedbackModalState}
 | 
			
		||||
                feedbackModalProps={feedbackModalProps}
 | 
			
		||||
              />
 | 
			
		||||
              {!isEmbedded && <HangupButton onPress={onLeave} />}
 | 
			
		||||
              <InviteButton onPress={() => inviteModalState.open()} />
 | 
			
		||||
            </div>
 | 
			
		||||
          </>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        <div className={styles.pttButtonContainer}>
 | 
			
		||||
          {activeSpeakerUserId ? (
 | 
			
		||||
            <div className={styles.talkingInfo}>
 | 
			
		||||
              <h2>
 | 
			
		||||
                {!activeSpeakerIsLocalUser && (
 | 
			
		||||
                  <AudioIcon className={styles.speakerIcon} />
 | 
			
		||||
                )}
 | 
			
		||||
                {activeSpeakerIsLocalUser
 | 
			
		||||
                  ? "Talking..."
 | 
			
		||||
                  : `${activeSpeakerDisplayName} is talking...`}
 | 
			
		||||
              </h2>
 | 
			
		||||
              <Timer value={activeSpeakerUserId} />
 | 
			
		||||
            </div>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <div className={styles.talkingInfo} />
 | 
			
		||||
          )}
 | 
			
		||||
          {showControls &&
 | 
			
		||||
            (activeSpeakerUserId ? (
 | 
			
		||||
              <div className={styles.talkingInfo}>
 | 
			
		||||
                <h2>
 | 
			
		||||
                  {!activeSpeakerIsLocalUser && (
 | 
			
		||||
                    <AudioIcon className={styles.speakerIcon} />
 | 
			
		||||
                  )}
 | 
			
		||||
                  {activeSpeakerIsLocalUser
 | 
			
		||||
                    ? "Talking..."
 | 
			
		||||
                    : `${activeSpeakerDisplayName} is talking...`}
 | 
			
		||||
                </h2>
 | 
			
		||||
                <Timer value={activeSpeakerUserId} />
 | 
			
		||||
              </div>
 | 
			
		||||
            ) : (
 | 
			
		||||
              <div className={styles.talkingInfo} />
 | 
			
		||||
            ))}
 | 
			
		||||
          <PTTButton
 | 
			
		||||
            enabled={!feedbackModalState.isOpen}
 | 
			
		||||
            showTalkOverError={showTalkOverError}
 | 
			
		||||
| 
						 | 
				
			
			@ -238,18 +249,20 @@ export const PTTCallView: React.FC<Props> = ({
 | 
			
		|||
            enqueueNetworkWaiting={enqueueTalkingExpected}
 | 
			
		||||
            setNetworkWaiting={setTalkingExpected}
 | 
			
		||||
          />
 | 
			
		||||
          <p className={styles.actionTip}>
 | 
			
		||||
            {getPromptText(
 | 
			
		||||
              networkWaiting,
 | 
			
		||||
              showTalkOverError,
 | 
			
		||||
              pttButtonHeld,
 | 
			
		||||
              activeSpeakerIsLocalUser,
 | 
			
		||||
              talkOverEnabled,
 | 
			
		||||
              activeSpeakerUserId,
 | 
			
		||||
              activeSpeakerDisplayName,
 | 
			
		||||
              connected
 | 
			
		||||
            )}
 | 
			
		||||
          </p>
 | 
			
		||||
          {showControls && (
 | 
			
		||||
            <p className={styles.actionTip}>
 | 
			
		||||
              {getPromptText(
 | 
			
		||||
                networkWaiting,
 | 
			
		||||
                showTalkOverError,
 | 
			
		||||
                pttButtonHeld,
 | 
			
		||||
                activeSpeakerIsLocalUser,
 | 
			
		||||
                talkOverEnabled,
 | 
			
		||||
                activeSpeakerUserId,
 | 
			
		||||
                activeSpeakerDisplayName,
 | 
			
		||||
                connected
 | 
			
		||||
              )}
 | 
			
		||||
            </p>
 | 
			
		||||
          )}
 | 
			
		||||
          {userMediaFeeds.map((callFeed) => (
 | 
			
		||||
            <PTTFeed
 | 
			
		||||
              key={callFeed.userId}
 | 
			
		||||
| 
						 | 
				
			
			@ -257,7 +270,7 @@ export const PTTCallView: React.FC<Props> = ({
 | 
			
		|||
              audioOutputDevice={audioOutput}
 | 
			
		||||
            />
 | 
			
		||||
          ))}
 | 
			
		||||
          {isAdmin && (
 | 
			
		||||
          {isAdmin && showControls && (
 | 
			
		||||
            <Toggle
 | 
			
		||||
              isSelected={talkOverEnabled}
 | 
			
		||||
              onChange={setTalkOverEnabled}
 | 
			
		||||
| 
						 | 
				
			
			@ -268,7 +281,7 @@ export const PTTCallView: React.FC<Props> = ({
 | 
			
		|||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {inviteModalState.isOpen && (
 | 
			
		||||
      {inviteModalState.isOpen && showControls && (
 | 
			
		||||
        <InviteModal roomId={roomId} {...inviteModalProps} />
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -29,9 +29,13 @@ export function RoomPage() {
 | 
			
		|||
 | 
			
		||||
  const { roomId: maybeRoomId } = useParams();
 | 
			
		||||
  const { hash, search } = useLocation();
 | 
			
		||||
  const [viaServers] = useMemo(() => {
 | 
			
		||||
  const [viaServers, isEmbedded, isPtt] = useMemo(() => {
 | 
			
		||||
    const params = new URLSearchParams(search);
 | 
			
		||||
    return [params.getAll("via")];
 | 
			
		||||
    return [
 | 
			
		||||
      params.getAll("via"),
 | 
			
		||||
      params.has("embed"),
 | 
			
		||||
      params.get("ptt") === "true",
 | 
			
		||||
    ];
 | 
			
		||||
  }, [search]);
 | 
			
		||||
  const roomId = (maybeRoomId || hash || "").toLowerCase();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -49,13 +53,19 @@ export function RoomPage() {
 | 
			
		|||
 | 
			
		||||
  return (
 | 
			
		||||
    <MediaHandlerProvider client={client}>
 | 
			
		||||
      <GroupCallLoader client={client} roomId={roomId} viaServers={viaServers}>
 | 
			
		||||
      <GroupCallLoader
 | 
			
		||||
        client={client}
 | 
			
		||||
        roomId={roomId}
 | 
			
		||||
        viaServers={viaServers}
 | 
			
		||||
        createPtt={isPtt}
 | 
			
		||||
      >
 | 
			
		||||
        {(groupCall) => (
 | 
			
		||||
          <GroupCallView
 | 
			
		||||
            client={client}
 | 
			
		||||
            roomId={roomId}
 | 
			
		||||
            groupCall={groupCall}
 | 
			
		||||
            isPasswordlessUser={isPasswordlessUser}
 | 
			
		||||
            isEmbedded={isEmbedded}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
      </GroupCallLoader>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -54,7 +54,13 @@ async function fetchGroupCall(
 | 
			
		|||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useLoadGroupCall(client, roomId, viaServers, createIfNotFound) {
 | 
			
		||||
export function useLoadGroupCall(
 | 
			
		||||
  client,
 | 
			
		||||
  roomId,
 | 
			
		||||
  viaServers,
 | 
			
		||||
  createIfNotFound,
 | 
			
		||||
  createPtt
 | 
			
		||||
) {
 | 
			
		||||
  const [state, setState] = useState({
 | 
			
		||||
    loading: true,
 | 
			
		||||
    error: undefined,
 | 
			
		||||
| 
						 | 
				
			
			@ -80,7 +86,7 @@ export function useLoadGroupCall(client, roomId, viaServers, createIfNotFound) {
 | 
			
		|||
          isLocalRoomId(roomId)
 | 
			
		||||
        ) {
 | 
			
		||||
          const roomName = roomNameFromRoomId(roomId);
 | 
			
		||||
          await createRoom(client, roomName);
 | 
			
		||||
          await createRoom(client, roomName, createPtt);
 | 
			
		||||
          const groupCall = await fetchGroupCall(
 | 
			
		||||
            client,
 | 
			
		||||
            roomId,
 | 
			
		||||
| 
						 | 
				
			
			@ -103,7 +109,7 @@ export function useLoadGroupCall(client, roomId, viaServers, createIfNotFound) {
 | 
			
		|||
      .catch((error) =>
 | 
			
		||||
        setState((prevState) => ({ ...prevState, loading: false, error }))
 | 
			
		||||
      );
 | 
			
		||||
  }, [client, roomId, state.reloadId, createIfNotFound, viaServers]);
 | 
			
		||||
  }, [client, roomId, state.reloadId, createIfNotFound, viaServers, createPtt]);
 | 
			
		||||
 | 
			
		||||
  return state;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -130,7 +130,7 @@ export const usePTT = (
 | 
			
		|||
  const onMuteStateChanged = useCallback(() => {
 | 
			
		||||
    const activeSpeakerFeed = getActiveSpeakerFeed(userMediaFeeds, groupCall);
 | 
			
		||||
 | 
			
		||||
    let blocked = false;
 | 
			
		||||
    let blocked = transmitBlocked;
 | 
			
		||||
    if (activeSpeakerUserId === null && activeSpeakerFeed !== null) {
 | 
			
		||||
      if (activeSpeakerFeed.userId === client.getUserId()) {
 | 
			
		||||
        playClip(PTTClipID.START_TALKING_LOCAL);
 | 
			
		||||
| 
						 | 
				
			
			@ -141,8 +141,8 @@ export const usePTT = (
 | 
			
		|||
      playClip(PTTClipID.END_TALKING);
 | 
			
		||||
    } else if (
 | 
			
		||||
      pttButtonHeld &&
 | 
			
		||||
      activeSpeakerUserId === client.getUserId() &&
 | 
			
		||||
      activeSpeakerFeed?.userId !== client.getUserId()
 | 
			
		||||
      activeSpeakerFeed?.userId !== client.getUserId() &&
 | 
			
		||||
      !transmitBlocked
 | 
			
		||||
    ) {
 | 
			
		||||
      // We were talking but we've been cut off: mute our own mic
 | 
			
		||||
      // (this is the easier way of cutting other speakers off if an
 | 
			
		||||
| 
						 | 
				
			
			@ -167,6 +167,7 @@ export const usePTT = (
 | 
			
		|||
    client,
 | 
			
		||||
    userMediaFeeds,
 | 
			
		||||
    setMicMuteWrapper,
 | 
			
		||||
    transmitBlocked,
 | 
			
		||||
  ]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,6 +15,8 @@ limitations under the License.
 | 
			
		|||
*/
 | 
			
		||||
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { Item } from "@react-stately/collections";
 | 
			
		||||
 | 
			
		||||
import { Modal } from "../Modal";
 | 
			
		||||
import styles from "./SettingsModal.module.css";
 | 
			
		||||
import { TabContainer, TabItem } from "../tabs/Tabs";
 | 
			
		||||
| 
						 | 
				
			
			@ -22,7 +24,6 @@ import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
 | 
			
		|||
import { ReactComponent as VideoIcon } from "../icons/Video.svg";
 | 
			
		||||
import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
 | 
			
		||||
import { SelectInput } from "../input/SelectInput";
 | 
			
		||||
import { Item } from "@react-stately/collections";
 | 
			
		||||
import { useMediaHandler } from "./useMediaHandler";
 | 
			
		||||
import { useSpatialAudio, useShowInspector } from "./useSetting";
 | 
			
		||||
import { FieldRow, InputField } from "../input/Input";
 | 
			
		||||
| 
						 | 
				
			
			@ -30,7 +31,13 @@ import { Button } from "../button";
 | 
			
		|||
import { useDownloadDebugLog } from "./submit-rageshake";
 | 
			
		||||
import { Body } from "../typography/Typography";
 | 
			
		||||
 | 
			
		||||
export const SettingsModal = (props) => {
 | 
			
		||||
interface Props {
 | 
			
		||||
  setShowInspector: boolean;
 | 
			
		||||
  showInspector: boolean;
 | 
			
		||||
  [rest: string]: unknown;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const SettingsModal = (props: Props) => {
 | 
			
		||||
  const {
 | 
			
		||||
    audioInput,
 | 
			
		||||
    audioInputs,
 | 
			
		||||
| 
						 | 
				
			
			@ -42,6 +49,7 @@ export const SettingsModal = (props) => {
 | 
			
		|||
    audioOutputs,
 | 
			
		||||
    setAudioOutput,
 | 
			
		||||
  } = useMediaHandler();
 | 
			
		||||
 | 
			
		||||
  const [spatialAudio, setSpatialAudio] = useSpatialAudio();
 | 
			
		||||
  const [showInspector, setShowInspector] = useShowInspector();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -69,8 +77,12 @@ export const SettingsModal = (props) => {
 | 
			
		|||
            selectedKey={audioInput}
 | 
			
		||||
            onSelectionChange={setAudioInput}
 | 
			
		||||
          >
 | 
			
		||||
            {audioInputs.map(({ deviceId, label }) => (
 | 
			
		||||
              <Item key={deviceId}>{label}</Item>
 | 
			
		||||
            {audioInputs.map(({ deviceId, label }, index) => (
 | 
			
		||||
              <Item key={deviceId}>
 | 
			
		||||
                {!!label && label.trim().length > 0
 | 
			
		||||
                  ? label
 | 
			
		||||
                  : `Microphone ${index + 1}`}
 | 
			
		||||
              </Item>
 | 
			
		||||
            ))}
 | 
			
		||||
          </SelectInput>
 | 
			
		||||
          {audioOutputs.length > 0 && (
 | 
			
		||||
| 
						 | 
				
			
			@ -79,8 +91,12 @@ export const SettingsModal = (props) => {
 | 
			
		|||
              selectedKey={audioOutput}
 | 
			
		||||
              onSelectionChange={setAudioOutput}
 | 
			
		||||
            >
 | 
			
		||||
              {audioOutputs.map(({ deviceId, label }) => (
 | 
			
		||||
                <Item key={deviceId}>{label}</Item>
 | 
			
		||||
              {audioOutputs.map(({ deviceId, label }, index) => (
 | 
			
		||||
                <Item key={deviceId}>
 | 
			
		||||
                  {!!label && label.trim().length > 0
 | 
			
		||||
                    ? label
 | 
			
		||||
                    : `Speaker ${index + 1}`}
 | 
			
		||||
                </Item>
 | 
			
		||||
              ))}
 | 
			
		||||
            </SelectInput>
 | 
			
		||||
          )}
 | 
			
		||||
| 
						 | 
				
			
			@ -91,7 +107,9 @@ export const SettingsModal = (props) => {
 | 
			
		|||
              type="checkbox"
 | 
			
		||||
              checked={spatialAudio}
 | 
			
		||||
              description="This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)"
 | 
			
		||||
              onChange={(e) => setSpatialAudio(e.target.checked)}
 | 
			
		||||
              onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
 | 
			
		||||
                setSpatialAudio(event.target.checked)
 | 
			
		||||
              }
 | 
			
		||||
            />
 | 
			
		||||
          </FieldRow>
 | 
			
		||||
        </TabItem>
 | 
			
		||||
| 
						 | 
				
			
			@ -108,8 +126,12 @@ export const SettingsModal = (props) => {
 | 
			
		|||
            selectedKey={videoInput}
 | 
			
		||||
            onSelectionChange={setVideoInput}
 | 
			
		||||
          >
 | 
			
		||||
            {videoInputs.map(({ deviceId, label }) => (
 | 
			
		||||
              <Item key={deviceId}>{label}</Item>
 | 
			
		||||
            {videoInputs.map(({ deviceId, label }, index) => (
 | 
			
		||||
              <Item key={deviceId}>
 | 
			
		||||
                {!!label && label.trim().length > 0
 | 
			
		||||
                  ? label
 | 
			
		||||
                  : `Camera ${index + 1}`}
 | 
			
		||||
              </Item>
 | 
			
		||||
            ))}
 | 
			
		||||
          </SelectInput>
 | 
			
		||||
        </TabItem>
 | 
			
		||||
| 
						 | 
				
			
			@ -133,7 +155,9 @@ export const SettingsModal = (props) => {
 | 
			
		|||
              label="Show Call Inspector"
 | 
			
		||||
              type="checkbox"
 | 
			
		||||
              checked={showInspector}
 | 
			
		||||
              onChange={(e) => setShowInspector(e.target.checked)}
 | 
			
		||||
              onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
 | 
			
		||||
                setShowInspector(e.target.checked)
 | 
			
		||||
              }
 | 
			
		||||
            />
 | 
			
		||||
          </FieldRow>
 | 
			
		||||
          <FieldRow>
 | 
			
		||||
| 
						 | 
				
			
			@ -1,3 +1,4 @@
 | 
			
		|||
/* eslint-disable @typescript-eslint/ban-ts-comment */
 | 
			
		||||
/*
 | 
			
		||||
Copyright 2017 OpenMarket Ltd
 | 
			
		||||
Copyright 2018 New Vector Ltd
 | 
			
		||||
| 
						 | 
				
			
			@ -37,19 +38,33 @@ limitations under the License.
 | 
			
		|||
//    actually timestamps. We then purge the remaining logs. We also do this
 | 
			
		||||
//    purge on startup to prevent logs from accumulating.
 | 
			
		||||
 | 
			
		||||
// the frequency with which we flush to indexeddb
 | 
			
		||||
import { logger } from "matrix-js-sdk/src/logger";
 | 
			
		||||
import { randomString } from "matrix-js-sdk/src/randomstring";
 | 
			
		||||
 | 
			
		||||
// the frequency with which we flush to indexeddb
 | 
			
		||||
const FLUSH_RATE_MS = 30 * 1000;
 | 
			
		||||
 | 
			
		||||
// the length of log data we keep in indexeddb (and include in the reports)
 | 
			
		||||
const MAX_LOG_SIZE = 1024 * 1024 * 5; // 5 MB
 | 
			
		||||
 | 
			
		||||
// A class which monkey-patches the global console and stores log lines.
 | 
			
		||||
export class ConsoleLogger {
 | 
			
		||||
  logs = "";
 | 
			
		||||
type LogFunction = (
 | 
			
		||||
  ...args: (Error | DOMException | object | string)[]
 | 
			
		||||
) => void;
 | 
			
		||||
type LogFunctionName = "log" | "info" | "warn" | "error";
 | 
			
		||||
 | 
			
		||||
  monkeyPatch(consoleObj) {
 | 
			
		||||
// A class which monkey-patches the global console and stores log lines.
 | 
			
		||||
 | 
			
		||||
interface LogEntry {
 | 
			
		||||
  id: string;
 | 
			
		||||
  lines: string;
 | 
			
		||||
  index?: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class ConsoleLogger {
 | 
			
		||||
  private logs = "";
 | 
			
		||||
  private originalFunctions: { [key in LogFunctionName]?: LogFunction } = {};
 | 
			
		||||
 | 
			
		||||
  public monkeyPatch(consoleObj: Console): void {
 | 
			
		||||
    // Monkey-patch console logging
 | 
			
		||||
    const consoleFunctionsToLevels = {
 | 
			
		||||
      log: "I",
 | 
			
		||||
| 
						 | 
				
			
			@ -60,6 +75,7 @@ export class ConsoleLogger {
 | 
			
		|||
    Object.keys(consoleFunctionsToLevels).forEach((fnName) => {
 | 
			
		||||
      const level = consoleFunctionsToLevels[fnName];
 | 
			
		||||
      const originalFn = consoleObj[fnName].bind(consoleObj);
 | 
			
		||||
      this.originalFunctions[fnName] = originalFn;
 | 
			
		||||
      consoleObj[fnName] = (...args) => {
 | 
			
		||||
        this.log(level, ...args);
 | 
			
		||||
        originalFn(...args);
 | 
			
		||||
| 
						 | 
				
			
			@ -67,7 +83,17 @@ export class ConsoleLogger {
 | 
			
		|||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  log(level, ...args) {
 | 
			
		||||
  public bypassRageshake(
 | 
			
		||||
    fnName: LogFunctionName,
 | 
			
		||||
    ...args: (Error | DOMException | object | string)[]
 | 
			
		||||
  ): void {
 | 
			
		||||
    this.originalFunctions[fnName](...args);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public log(
 | 
			
		||||
    level: string,
 | 
			
		||||
    ...args: (Error | DOMException | object | string)[]
 | 
			
		||||
  ): void {
 | 
			
		||||
    // We don't know what locale the user may be running so use ISO strings
 | 
			
		||||
    const ts = new Date().toISOString();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -78,21 +104,7 @@ export class ConsoleLogger {
 | 
			
		|||
      } else if (arg instanceof Error) {
 | 
			
		||||
        return arg.message + (arg.stack ? `\n${arg.stack}` : "");
 | 
			
		||||
      } else if (typeof arg === "object") {
 | 
			
		||||
        try {
 | 
			
		||||
          return JSON.stringify(arg);
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
          // In development, it can be useful to log complex cyclic
 | 
			
		||||
          // objects to the console for inspection. This is fine for
 | 
			
		||||
          // the console, but default `stringify` can't handle that.
 | 
			
		||||
          // We workaround this by using a special replacer function
 | 
			
		||||
          // to only log values of the root object and avoid cycles.
 | 
			
		||||
          return JSON.stringify(arg, (key, value) => {
 | 
			
		||||
            if (key && typeof value === "object") {
 | 
			
		||||
              return "<object>";
 | 
			
		||||
            }
 | 
			
		||||
            return value;
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
        return JSON.stringify(arg, getCircularReplacer());
 | 
			
		||||
      } else {
 | 
			
		||||
        return arg;
 | 
			
		||||
      }
 | 
			
		||||
| 
						 | 
				
			
			@ -116,7 +128,7 @@ export class ConsoleLogger {
 | 
			
		|||
   * @param {boolean} keepLogs True to not delete logs after flushing.
 | 
			
		||||
   * @return {string} \n delimited log lines to flush.
 | 
			
		||||
   */
 | 
			
		||||
  flush(keepLogs) {
 | 
			
		||||
  public flush(keepLogs?: boolean): string {
 | 
			
		||||
    // The ConsoleLogger doesn't care how these end up on disk, it just
 | 
			
		||||
    // flushes them to the caller.
 | 
			
		||||
    if (keepLogs) {
 | 
			
		||||
| 
						 | 
				
			
			@ -130,24 +142,23 @@ export class ConsoleLogger {
 | 
			
		|||
 | 
			
		||||
// A class which stores log lines in an IndexedDB instance.
 | 
			
		||||
export class IndexedDBLogStore {
 | 
			
		||||
  index = 0;
 | 
			
		||||
  db = null;
 | 
			
		||||
  flushPromise = null;
 | 
			
		||||
  flushAgainPromise = null;
 | 
			
		||||
  private index = 0;
 | 
			
		||||
  private db: IDBDatabase = null;
 | 
			
		||||
  private flushPromise: Promise<void> = null;
 | 
			
		||||
  private flushAgainPromise: Promise<void> = null;
 | 
			
		||||
  private id: string;
 | 
			
		||||
 | 
			
		||||
  constructor(indexedDB, logger) {
 | 
			
		||||
    this.indexedDB = indexedDB;
 | 
			
		||||
    this.logger = logger;
 | 
			
		||||
    this.id = "instance-" + Math.random() + Date.now();
 | 
			
		||||
  constructor(private indexedDB: IDBFactory, private logger: ConsoleLogger) {
 | 
			
		||||
    this.id = "instance-" + randomString(16);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * @return {Promise} Resolves when the store is ready.
 | 
			
		||||
   */
 | 
			
		||||
  connect() {
 | 
			
		||||
  public connect(): Promise<void> {
 | 
			
		||||
    const req = this.indexedDB.open("logs");
 | 
			
		||||
    return new Promise((resolve, reject) => {
 | 
			
		||||
      req.onsuccess = (event) => {
 | 
			
		||||
      req.onsuccess = (event: Event) => {
 | 
			
		||||
        // @ts-ignore
 | 
			
		||||
        this.db = event.target.result;
 | 
			
		||||
        // Periodically flush logs to local storage / indexeddb
 | 
			
		||||
| 
						 | 
				
			
			@ -206,7 +217,7 @@ export class IndexedDBLogStore {
 | 
			
		|||
   *
 | 
			
		||||
   * @return {Promise} Resolved when the logs have been flushed.
 | 
			
		||||
   */
 | 
			
		||||
  flush() {
 | 
			
		||||
  public flush(): Promise<void> {
 | 
			
		||||
    // check if a flush() operation is ongoing
 | 
			
		||||
    if (this.flushPromise) {
 | 
			
		||||
      if (this.flushAgainPromise) {
 | 
			
		||||
| 
						 | 
				
			
			@ -225,7 +236,7 @@ export class IndexedDBLogStore {
 | 
			
		|||
    }
 | 
			
		||||
    // there is no flush promise or there was but it has finished, so do
 | 
			
		||||
    // a brand new one, destroying the chain which may have been built up.
 | 
			
		||||
    this.flushPromise = new Promise((resolve, reject) => {
 | 
			
		||||
    this.flushPromise = new Promise<void>((resolve, reject) => {
 | 
			
		||||
      if (!this.db) {
 | 
			
		||||
        // not connected yet or user rejected access for us to r/w to the db.
 | 
			
		||||
        reject(new Error("No connected database"));
 | 
			
		||||
| 
						 | 
				
			
			@ -243,6 +254,7 @@ export class IndexedDBLogStore {
 | 
			
		|||
      };
 | 
			
		||||
      txn.onerror = (event) => {
 | 
			
		||||
        logger.error("Failed to flush logs : ", event);
 | 
			
		||||
        // @ts-ignore
 | 
			
		||||
        reject(new Error("Failed to write logs: " + event.target.errorCode));
 | 
			
		||||
      };
 | 
			
		||||
      objStore.add(this.generateLogEntry(lines));
 | 
			
		||||
| 
						 | 
				
			
			@ -264,12 +276,12 @@ export class IndexedDBLogStore {
 | 
			
		|||
   * log ID). The objects have said log ID in an "id" field and "lines" which
 | 
			
		||||
   * is a big string with all the new-line delimited logs.
 | 
			
		||||
   */
 | 
			
		||||
  async consume() {
 | 
			
		||||
  public async consume(): Promise<LogEntry[]> {
 | 
			
		||||
    const db = this.db;
 | 
			
		||||
 | 
			
		||||
    // Returns: a string representing the concatenated logs for this ID.
 | 
			
		||||
    // Stops adding log fragments when the size exceeds maxSize
 | 
			
		||||
    function fetchLogs(id, maxSize) {
 | 
			
		||||
    function fetchLogs(id: string, maxSize: number): Promise<string> {
 | 
			
		||||
      const objectStore = db
 | 
			
		||||
        .transaction("logs", "readonly")
 | 
			
		||||
        .objectStore("logs");
 | 
			
		||||
| 
						 | 
				
			
			@ -280,9 +292,11 @@ export class IndexedDBLogStore {
 | 
			
		|||
          .openCursor(IDBKeyRange.only(id), "prev");
 | 
			
		||||
        let lines = "";
 | 
			
		||||
        query.onerror = (event) => {
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          reject(new Error("Query failed: " + event.target.errorCode));
 | 
			
		||||
        };
 | 
			
		||||
        query.onsuccess = (event) => {
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          const cursor = event.target.result;
 | 
			
		||||
          if (!cursor) {
 | 
			
		||||
            resolve(lines);
 | 
			
		||||
| 
						 | 
				
			
			@ -299,12 +313,12 @@ export class IndexedDBLogStore {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    // Returns: A sorted array of log IDs. (newest first)
 | 
			
		||||
    function fetchLogIds() {
 | 
			
		||||
    function fetchLogIds(): Promise<string[]> {
 | 
			
		||||
      // To gather all the log IDs, query for all records in logslastmod.
 | 
			
		||||
      const o = db
 | 
			
		||||
        .transaction("logslastmod", "readonly")
 | 
			
		||||
        .objectStore("logslastmod");
 | 
			
		||||
      return selectQuery(o, undefined, (cursor) => {
 | 
			
		||||
      return selectQuery<{ ts: number; id: string }>(o, undefined, (cursor) => {
 | 
			
		||||
        return {
 | 
			
		||||
          id: cursor.value.id,
 | 
			
		||||
          ts: cursor.value.ts,
 | 
			
		||||
| 
						 | 
				
			
			@ -319,13 +333,14 @@ export class IndexedDBLogStore {
 | 
			
		|||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function deleteLogs(id) {
 | 
			
		||||
      return new Promise((resolve, reject) => {
 | 
			
		||||
    function deleteLogs(id: number): Promise<void> {
 | 
			
		||||
      return new Promise<void>((resolve, reject) => {
 | 
			
		||||
        const txn = db.transaction(["logs", "logslastmod"], "readwrite");
 | 
			
		||||
        const o = txn.objectStore("logs");
 | 
			
		||||
        // only load the key path, not the data which may be huge
 | 
			
		||||
        const query = o.index("id").openKeyCursor(IDBKeyRange.only(id));
 | 
			
		||||
        query.onsuccess = (event) => {
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          const cursor = event.target.result;
 | 
			
		||||
          if (!cursor) {
 | 
			
		||||
            return;
 | 
			
		||||
| 
						 | 
				
			
			@ -340,6 +355,7 @@ export class IndexedDBLogStore {
 | 
			
		|||
          reject(
 | 
			
		||||
            new Error(
 | 
			
		||||
              "Failed to delete logs for " +
 | 
			
		||||
                // @ts-ignore
 | 
			
		||||
                `'${id}' : ${event.target.errorCode}`
 | 
			
		||||
            )
 | 
			
		||||
          );
 | 
			
		||||
| 
						 | 
				
			
			@ -352,7 +368,7 @@ export class IndexedDBLogStore {
 | 
			
		|||
 | 
			
		||||
    const allLogIds = await fetchLogIds();
 | 
			
		||||
    let removeLogIds = [];
 | 
			
		||||
    const logs = [];
 | 
			
		||||
    const logs: LogEntry[] = [];
 | 
			
		||||
    let size = 0;
 | 
			
		||||
    for (let i = 0; i < allLogIds.length; i++) {
 | 
			
		||||
      const lines = await fetchLogs(allLogIds[i], MAX_LOG_SIZE - size);
 | 
			
		||||
| 
						 | 
				
			
			@ -390,7 +406,7 @@ export class IndexedDBLogStore {
 | 
			
		|||
    return logs;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  generateLogEntry(lines) {
 | 
			
		||||
  private generateLogEntry(lines: string): LogEntry {
 | 
			
		||||
    return {
 | 
			
		||||
      id: this.id,
 | 
			
		||||
      lines: lines,
 | 
			
		||||
| 
						 | 
				
			
			@ -398,7 +414,7 @@ export class IndexedDBLogStore {
 | 
			
		|||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  generateLastModifiedTime() {
 | 
			
		||||
  private generateLastModifiedTime(): { id: string; ts: number } {
 | 
			
		||||
    return {
 | 
			
		||||
      id: this.id,
 | 
			
		||||
      ts: Date.now(),
 | 
			
		||||
| 
						 | 
				
			
			@ -416,7 +432,11 @@ export class IndexedDBLogStore {
 | 
			
		|||
 * @return {Promise<T[]>} Resolves to an array of whatever you returned from
 | 
			
		||||
 * resultMapper.
 | 
			
		||||
 */
 | 
			
		||||
function selectQuery(store, keyRange, resultMapper) {
 | 
			
		||||
function selectQuery<T>(
 | 
			
		||||
  store: IDBObjectStore,
 | 
			
		||||
  keyRange: IDBKeyRange,
 | 
			
		||||
  resultMapper: (cursor: IDBCursorWithValue) => T
 | 
			
		||||
): Promise<T[]> {
 | 
			
		||||
  const query = store.openCursor(keyRange);
 | 
			
		||||
  return new Promise((resolve, reject) => {
 | 
			
		||||
    const results = [];
 | 
			
		||||
| 
						 | 
				
			
			@ -437,6 +457,16 @@ function selectQuery(store, keyRange, resultMapper) {
 | 
			
		|||
    };
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
declare global {
 | 
			
		||||
  // eslint-disable-next-line no-var, camelcase
 | 
			
		||||
  var mx_rage_store: IndexedDBLogStore;
 | 
			
		||||
  // eslint-disable-next-line no-var, camelcase
 | 
			
		||||
  var mx_rage_logger: ConsoleLogger;
 | 
			
		||||
  // eslint-disable-next-line no-var, camelcase
 | 
			
		||||
  var mx_rage_initPromise: Promise<void>;
 | 
			
		||||
  // eslint-disable-next-line no-var, camelcase
 | 
			
		||||
  var mx_rage_initStoragePromise: Promise<void>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Configure rage shaking support for sending bug reports.
 | 
			
		||||
| 
						 | 
				
			
			@ -445,7 +475,7 @@ function selectQuery(store, keyRange, resultMapper) {
 | 
			
		|||
 * be set up immediately for the logs.
 | 
			
		||||
 * @return {Promise} Resolves when set up.
 | 
			
		||||
 */
 | 
			
		||||
export function init(setUpPersistence = true) {
 | 
			
		||||
export function init(setUpPersistence = true): Promise<void> {
 | 
			
		||||
  if (global.mx_rage_initPromise) {
 | 
			
		||||
    return global.mx_rage_initPromise;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -465,7 +495,7 @@ export function init(setUpPersistence = true) {
 | 
			
		|||
 * then this no-ops.
 | 
			
		||||
 * @return {Promise} Resolves when complete.
 | 
			
		||||
 */
 | 
			
		||||
export function tryInitStorage() {
 | 
			
		||||
export function tryInitStorage(): Promise<void> {
 | 
			
		||||
  if (global.mx_rage_initStoragePromise) {
 | 
			
		||||
    return global.mx_rage_initStoragePromise;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -491,7 +521,7 @@ export function tryInitStorage() {
 | 
			
		|||
  return global.mx_rage_initStoragePromise;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function flush() {
 | 
			
		||||
export function flush(): Promise<void> {
 | 
			
		||||
  if (!global.mx_rage_store) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -502,7 +532,7 @@ export function flush() {
 | 
			
		|||
 * Clean up old logs.
 | 
			
		||||
 * @return {Promise} Resolves if cleaned logs.
 | 
			
		||||
 */
 | 
			
		||||
export async function cleanup() {
 | 
			
		||||
export async function cleanup(): Promise<void> {
 | 
			
		||||
  if (!global.mx_rage_store) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -512,9 +542,9 @@ export async function cleanup() {
 | 
			
		|||
/**
 | 
			
		||||
 * Get a recent snapshot of the logs, ready for attaching to a bug report
 | 
			
		||||
 *
 | 
			
		||||
 * @return {Array<{lines: string, id, string}>}  list of log data
 | 
			
		||||
 * @return {LogEntry[]}  list of log data
 | 
			
		||||
 */
 | 
			
		||||
export async function getLogsForReport() {
 | 
			
		||||
export async function getLogsForReport(): Promise<LogEntry[]> {
 | 
			
		||||
  if (!global.mx_rage_logger) {
 | 
			
		||||
    throw new Error("No console logger, did you forget to call init()?");
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -523,7 +553,7 @@ export async function getLogsForReport() {
 | 
			
		|||
  if (global.mx_rage_store) {
 | 
			
		||||
    // flush most recent logs
 | 
			
		||||
    await global.mx_rage_store.flush();
 | 
			
		||||
    return await global.mx_rage_store.consume();
 | 
			
		||||
    return global.mx_rage_store.consume();
 | 
			
		||||
  } else {
 | 
			
		||||
    return [
 | 
			
		||||
      {
 | 
			
		||||
| 
						 | 
				
			
			@ -533,3 +563,24 @@ export async function getLogsForReport() {
 | 
			
		|||
    ];
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type StringifyReplacer = (
 | 
			
		||||
  this: unknown,
 | 
			
		||||
  key: string,
 | 
			
		||||
  value: unknown
 | 
			
		||||
) => unknown;
 | 
			
		||||
 | 
			
		||||
// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value#circular_references
 | 
			
		||||
// Injects `<$ cycle-trimmed $>` wherever it cuts a cyclical object relationship
 | 
			
		||||
const getCircularReplacer = (): StringifyReplacer => {
 | 
			
		||||
  const seen = new WeakSet();
 | 
			
		||||
  return (key: string, value: unknown): unknown => {
 | 
			
		||||
    if (typeof value === "object" && value !== null) {
 | 
			
		||||
      if (seen.has(value)) {
 | 
			
		||||
        return "<$ cycle-trimmed $>";
 | 
			
		||||
      }
 | 
			
		||||
      seen.add(value);
 | 
			
		||||
    }
 | 
			
		||||
    return value;
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -15,14 +15,31 @@ limitations under the License.
 | 
			
		|||
*/
 | 
			
		||||
 | 
			
		||||
import { useCallback, useContext, useEffect, useState } from "react";
 | 
			
		||||
import { getLogsForReport } from "./rageshake";
 | 
			
		||||
import pako from "pako";
 | 
			
		||||
import { MatrixEvent } from "matrix-js-sdk";
 | 
			
		||||
import { OverlayTriggerState } from "@react-stately/overlays";
 | 
			
		||||
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
 | 
			
		||||
 | 
			
		||||
import { getLogsForReport } from "./rageshake";
 | 
			
		||||
import { useClient } from "../ClientContext";
 | 
			
		||||
import { InspectorContext } from "../room/GroupCallInspector";
 | 
			
		||||
import { useModalTriggerState } from "../Modal";
 | 
			
		||||
 | 
			
		||||
export function useSubmitRageshake() {
 | 
			
		||||
  const { client } = useClient();
 | 
			
		||||
interface RageShakeSubmitOptions {
 | 
			
		||||
  description: string;
 | 
			
		||||
  roomId: string;
 | 
			
		||||
  label: string;
 | 
			
		||||
  sendLogs: boolean;
 | 
			
		||||
  rageshakeRequestId: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useSubmitRageshake(): {
 | 
			
		||||
  submitRageshake: (opts: RageShakeSubmitOptions) => Promise<void>;
 | 
			
		||||
  sending: boolean;
 | 
			
		||||
  sent: boolean;
 | 
			
		||||
  error: Error;
 | 
			
		||||
} {
 | 
			
		||||
  const client: MatrixClient = useClient().client;
 | 
			
		||||
  const [{ json }] = useContext(InspectorContext);
 | 
			
		||||
 | 
			
		||||
  const [{ sending, sent, error }, setState] = useState({
 | 
			
		||||
| 
						 | 
				
			
			@ -57,9 +74,12 @@ export function useSubmitRageshake() {
 | 
			
		|||
          opts.description || "User did not supply any additional text."
 | 
			
		||||
        );
 | 
			
		||||
        body.append("app", "matrix-video-chat");
 | 
			
		||||
        body.append("version", import.meta.env.VITE_APP_VERSION || "dev");
 | 
			
		||||
        body.append(
 | 
			
		||||
          "version",
 | 
			
		||||
          (import.meta.env.VITE_APP_VERSION as string) || "dev"
 | 
			
		||||
        );
 | 
			
		||||
        body.append("user_agent", userAgent);
 | 
			
		||||
        body.append("installed_pwa", false);
 | 
			
		||||
        body.append("installed_pwa", "false");
 | 
			
		||||
        body.append("touch_input", touchInput);
 | 
			
		||||
 | 
			
		||||
        if (client) {
 | 
			
		||||
| 
						 | 
				
			
			@ -181,7 +201,11 @@ export function useSubmitRageshake() {
 | 
			
		|||
 | 
			
		||||
        if (navigator.storage && navigator.storage.estimate) {
 | 
			
		||||
          try {
 | 
			
		||||
            const estimate = await navigator.storage.estimate();
 | 
			
		||||
            const estimate: {
 | 
			
		||||
              quota?: number;
 | 
			
		||||
              usage?: number;
 | 
			
		||||
              usageDetails?: { [x: string]: unknown };
 | 
			
		||||
            } = await navigator.storage.estimate();
 | 
			
		||||
            body.append("storageManager_quota", String(estimate.quota));
 | 
			
		||||
            body.append("storageManager_usage", String(estimate.usage));
 | 
			
		||||
            if (estimate.usageDetails) {
 | 
			
		||||
| 
						 | 
				
			
			@ -201,7 +225,6 @@ export function useSubmitRageshake() {
 | 
			
		|||
          for (const entry of logs) {
 | 
			
		||||
            // encode as UTF-8
 | 
			
		||||
            let buf = new TextEncoder().encode(entry.lines);
 | 
			
		||||
 | 
			
		||||
            // compress
 | 
			
		||||
            buf = pako.gzip(buf);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -225,7 +248,7 @@ export function useSubmitRageshake() {
 | 
			
		|||
        }
 | 
			
		||||
 | 
			
		||||
        await fetch(
 | 
			
		||||
          import.meta.env.VITE_RAGESHAKE_SUBMIT_URL ||
 | 
			
		||||
          (import.meta.env.VITE_RAGESHAKE_SUBMIT_URL as string) ||
 | 
			
		||||
            "https://element.io/bugreports/submit",
 | 
			
		||||
          {
 | 
			
		||||
            method: "POST",
 | 
			
		||||
| 
						 | 
				
			
			@ -250,7 +273,7 @@ export function useSubmitRageshake() {
 | 
			
		|||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useDownloadDebugLog() {
 | 
			
		||||
export function useDownloadDebugLog(): () => void {
 | 
			
		||||
  const [{ json }] = useContext(InspectorContext);
 | 
			
		||||
 | 
			
		||||
  const downloadDebugLog = useCallback(() => {
 | 
			
		||||
| 
						 | 
				
			
			@ -271,7 +294,10 @@ export function useDownloadDebugLog() {
 | 
			
		|||
  return downloadDebugLog;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useRageshakeRequest() {
 | 
			
		||||
export function useRageshakeRequest(): (
 | 
			
		||||
  roomId: string,
 | 
			
		||||
  rageshakeRequestId: string
 | 
			
		||||
) => void {
 | 
			
		||||
  const { client } = useClient();
 | 
			
		||||
 | 
			
		||||
  const sendRageshakeRequest = useCallback(
 | 
			
		||||
| 
						 | 
				
			
			@ -285,14 +311,27 @@ export function useRageshakeRequest() {
 | 
			
		|||
 | 
			
		||||
  return sendRageshakeRequest;
 | 
			
		||||
}
 | 
			
		||||
interface ModalProps {
 | 
			
		||||
  isOpen: boolean;
 | 
			
		||||
  onClose: () => void;
 | 
			
		||||
}
 | 
			
		||||
interface ModalPropsWithId extends ModalProps {
 | 
			
		||||
  rageshakeRequestId: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useRageshakeRequestModal(roomId) {
 | 
			
		||||
  const { modalState, modalProps } = useModalTriggerState();
 | 
			
		||||
  const { client } = useClient();
 | 
			
		||||
  const [rageshakeRequestId, setRageshakeRequestId] = useState();
 | 
			
		||||
export function useRageshakeRequestModal(roomId: string): {
 | 
			
		||||
  modalState: OverlayTriggerState;
 | 
			
		||||
  modalProps: ModalPropsWithId;
 | 
			
		||||
} {
 | 
			
		||||
  const { modalState, modalProps } = useModalTriggerState() as {
 | 
			
		||||
    modalState: OverlayTriggerState;
 | 
			
		||||
    modalProps: ModalProps;
 | 
			
		||||
  };
 | 
			
		||||
  const client: MatrixClient = useClient().client;
 | 
			
		||||
  const [rageshakeRequestId, setRageshakeRequestId] = useState<string>();
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const onEvent = (event) => {
 | 
			
		||||
    const onEvent = (event: MatrixEvent) => {
 | 
			
		||||
      const type = event.getType();
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
| 
						 | 
				
			
			@ -305,10 +344,10 @@ export function useRageshakeRequestModal(roomId) {
 | 
			
		|||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    client.on("event", onEvent);
 | 
			
		||||
    client.on(ClientEvent.Event, onEvent);
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      client.removeListener("event", onEvent);
 | 
			
		||||
      client.removeListener(ClientEvent.Event, onEvent);
 | 
			
		||||
    };
 | 
			
		||||
  }, [modalState.open, roomId, client, modalState]);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -1,3 +1,4 @@
 | 
			
		|||
/* eslint-disable @typescript-eslint/ban-ts-comment */
 | 
			
		||||
/*
 | 
			
		||||
Copyright 2022 Matrix.org Foundation C.I.C.
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -14,6 +15,8 @@ See the License for the specific language governing permissions and
 | 
			
		|||
limitations under the License.
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
import { MatrixClient } from "matrix-js-sdk";
 | 
			
		||||
import { MediaHandlerEvent } from "matrix-js-sdk/src/webrtc/mediaHandler";
 | 
			
		||||
import React, {
 | 
			
		||||
  useState,
 | 
			
		||||
  useEffect,
 | 
			
		||||
| 
						 | 
				
			
			@ -23,9 +26,27 @@ import React, {
 | 
			
		|||
  createContext,
 | 
			
		||||
} from "react";
 | 
			
		||||
 | 
			
		||||
const MediaHandlerContext = createContext();
 | 
			
		||||
export interface MediaHandlerContextInterface {
 | 
			
		||||
  audioInput: string;
 | 
			
		||||
  audioInputs: MediaDeviceInfo[];
 | 
			
		||||
  setAudioInput: (deviceId: string) => void;
 | 
			
		||||
  videoInput: string;
 | 
			
		||||
  videoInputs: MediaDeviceInfo[];
 | 
			
		||||
  setVideoInput: (deviceId: string) => void;
 | 
			
		||||
  audioOutput: string;
 | 
			
		||||
  audioOutputs: MediaDeviceInfo[];
 | 
			
		||||
  setAudioOutput: (deviceId: string) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getMediaPreferences() {
 | 
			
		||||
const MediaHandlerContext =
 | 
			
		||||
  createContext<MediaHandlerContextInterface>(undefined);
 | 
			
		||||
 | 
			
		||||
interface MediaPreferences {
 | 
			
		||||
  audioInput?: string;
 | 
			
		||||
  videoInput?: string;
 | 
			
		||||
  audioOutput?: string;
 | 
			
		||||
}
 | 
			
		||||
function getMediaPreferences(): MediaPreferences {
 | 
			
		||||
  const mediaPreferences = localStorage.getItem("matrix-media-preferences");
 | 
			
		||||
 | 
			
		||||
  if (mediaPreferences) {
 | 
			
		||||
| 
						 | 
				
			
			@ -39,8 +60,8 @@ function getMediaPreferences() {
 | 
			
		|||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function updateMediaPreferences(newPreferences) {
 | 
			
		||||
  const oldPreferences = getMediaPreferences(newPreferences);
 | 
			
		||||
function updateMediaPreferences(newPreferences: MediaPreferences): void {
 | 
			
		||||
  const oldPreferences = getMediaPreferences();
 | 
			
		||||
 | 
			
		||||
  localStorage.setItem(
 | 
			
		||||
    "matrix-media-preferences",
 | 
			
		||||
| 
						 | 
				
			
			@ -50,8 +71,11 @@ function updateMediaPreferences(newPreferences) {
 | 
			
		|||
    })
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function MediaHandlerProvider({ client, children }) {
 | 
			
		||||
interface Props {
 | 
			
		||||
  client: MatrixClient;
 | 
			
		||||
  children: JSX.Element[];
 | 
			
		||||
}
 | 
			
		||||
export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
 | 
			
		||||
  const [
 | 
			
		||||
    {
 | 
			
		||||
      audioInput,
 | 
			
		||||
| 
						 | 
				
			
			@ -72,7 +96,9 @@ export function MediaHandlerProvider({ client, children }) {
 | 
			
		|||
    );
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      // @ts-ignore, ignore that audioInput is a private members of mediaHandler
 | 
			
		||||
      audioInput: mediaHandler.audioInput,
 | 
			
		||||
      // @ts-ignore, ignore that videoInput is a private members of mediaHandler
 | 
			
		||||
      videoInput: mediaHandler.videoInput,
 | 
			
		||||
      audioOutput: undefined,
 | 
			
		||||
      audioInputs: [],
 | 
			
		||||
| 
						 | 
				
			
			@ -84,7 +110,7 @@ export function MediaHandlerProvider({ client, children }) {
 | 
			
		|||
  useEffect(() => {
 | 
			
		||||
    const mediaHandler = client.getMediaHandler();
 | 
			
		||||
 | 
			
		||||
    function updateDevices() {
 | 
			
		||||
    function updateDevices(): void {
 | 
			
		||||
      navigator.mediaDevices.enumerateDevices().then((devices) => {
 | 
			
		||||
        const mediaPreferences = getMediaPreferences();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -92,9 +118,10 @@ export function MediaHandlerProvider({ client, children }) {
 | 
			
		|||
          (device) => device.kind === "audioinput"
 | 
			
		||||
        );
 | 
			
		||||
        const audioConnected = audioInputs.some(
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          (device) => device.deviceId === mediaHandler.audioInput
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        // @ts-ignore
 | 
			
		||||
        let audioInput = mediaHandler.audioInput;
 | 
			
		||||
 | 
			
		||||
        if (!audioConnected && audioInputs.length > 0) {
 | 
			
		||||
| 
						 | 
				
			
			@ -105,9 +132,11 @@ export function MediaHandlerProvider({ client, children }) {
 | 
			
		|||
          (device) => device.kind === "videoinput"
 | 
			
		||||
        );
 | 
			
		||||
        const videoConnected = videoInputs.some(
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          (device) => device.deviceId === mediaHandler.videoInput
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        // @ts-ignore
 | 
			
		||||
        let videoInput = mediaHandler.videoInput;
 | 
			
		||||
 | 
			
		||||
        if (!videoConnected && videoInputs.length > 0) {
 | 
			
		||||
| 
						 | 
				
			
			@ -129,7 +158,9 @@ export function MediaHandlerProvider({ client, children }) {
 | 
			
		|||
        }
 | 
			
		||||
 | 
			
		||||
        if (
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          mediaHandler.videoInput !== videoInput ||
 | 
			
		||||
          // @ts-ignore
 | 
			
		||||
          mediaHandler.audioInput !== audioInput
 | 
			
		||||
        ) {
 | 
			
		||||
          mediaHandler.setMediaInputs(audioInput, videoInput);
 | 
			
		||||
| 
						 | 
				
			
			@ -149,18 +180,21 @@ export function MediaHandlerProvider({ client, children }) {
 | 
			
		|||
    }
 | 
			
		||||
    updateDevices();
 | 
			
		||||
 | 
			
		||||
    mediaHandler.on("local_streams_changed", updateDevices);
 | 
			
		||||
    mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, updateDevices);
 | 
			
		||||
    navigator.mediaDevices.addEventListener("devicechange", updateDevices);
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      mediaHandler.removeListener("local_streams_changed", updateDevices);
 | 
			
		||||
      mediaHandler.removeListener(
 | 
			
		||||
        MediaHandlerEvent.LocalStreamsChanged,
 | 
			
		||||
        updateDevices
 | 
			
		||||
      );
 | 
			
		||||
      navigator.mediaDevices.removeEventListener("devicechange", updateDevices);
 | 
			
		||||
      mediaHandler.stopAllStreams();
 | 
			
		||||
    };
 | 
			
		||||
  }, [client]);
 | 
			
		||||
 | 
			
		||||
  const setAudioInput = useCallback(
 | 
			
		||||
    (deviceId) => {
 | 
			
		||||
  const setAudioInput: (deviceId: string) => void = useCallback(
 | 
			
		||||
    (deviceId: string) => {
 | 
			
		||||
      updateMediaPreferences({ audioInput: deviceId });
 | 
			
		||||
      setState((prevState) => ({ ...prevState, audioInput: deviceId }));
 | 
			
		||||
      client.getMediaHandler().setAudioInput(deviceId);
 | 
			
		||||
| 
						 | 
				
			
			@ -168,7 +202,7 @@ export function MediaHandlerProvider({ client, children }) {
 | 
			
		|||
    [client]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const setVideoInput = useCallback(
 | 
			
		||||
  const setVideoInput: (deviceId: string) => void = useCallback(
 | 
			
		||||
    (deviceId) => {
 | 
			
		||||
      updateMediaPreferences({ videoInput: deviceId });
 | 
			
		||||
      setState((prevState) => ({ ...prevState, videoInput: deviceId }));
 | 
			
		||||
| 
						 | 
				
			
			@ -177,35 +211,36 @@ export function MediaHandlerProvider({ client, children }) {
 | 
			
		|||
    [client]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const setAudioOutput = useCallback((deviceId) => {
 | 
			
		||||
  const setAudioOutput: (deviceId: string) => void = useCallback((deviceId) => {
 | 
			
		||||
    updateMediaPreferences({ audioOutput: deviceId });
 | 
			
		||||
    setState((prevState) => ({ ...prevState, audioOutput: deviceId }));
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const context = useMemo(
 | 
			
		||||
    () => ({
 | 
			
		||||
      audioInput,
 | 
			
		||||
      audioInputs,
 | 
			
		||||
      setAudioInput,
 | 
			
		||||
      videoInput,
 | 
			
		||||
      videoInputs,
 | 
			
		||||
      setVideoInput,
 | 
			
		||||
      audioOutput,
 | 
			
		||||
      audioOutputs,
 | 
			
		||||
      setAudioOutput,
 | 
			
		||||
    }),
 | 
			
		||||
    [
 | 
			
		||||
      audioInput,
 | 
			
		||||
      audioInputs,
 | 
			
		||||
      setAudioInput,
 | 
			
		||||
      videoInput,
 | 
			
		||||
      videoInputs,
 | 
			
		||||
      setVideoInput,
 | 
			
		||||
      audioOutput,
 | 
			
		||||
      audioOutputs,
 | 
			
		||||
      setAudioOutput,
 | 
			
		||||
    ]
 | 
			
		||||
  );
 | 
			
		||||
  const context: MediaHandlerContextInterface =
 | 
			
		||||
    useMemo<MediaHandlerContextInterface>(
 | 
			
		||||
      () => ({
 | 
			
		||||
        audioInput,
 | 
			
		||||
        audioInputs,
 | 
			
		||||
        setAudioInput,
 | 
			
		||||
        videoInput,
 | 
			
		||||
        videoInputs,
 | 
			
		||||
        setVideoInput,
 | 
			
		||||
        audioOutput,
 | 
			
		||||
        audioOutputs,
 | 
			
		||||
        setAudioOutput,
 | 
			
		||||
      }),
 | 
			
		||||
      [
 | 
			
		||||
        audioInput,
 | 
			
		||||
        audioInputs,
 | 
			
		||||
        setAudioInput,
 | 
			
		||||
        videoInput,
 | 
			
		||||
        videoInputs,
 | 
			
		||||
        setVideoInput,
 | 
			
		||||
        audioOutput,
 | 
			
		||||
        audioOutputs,
 | 
			
		||||
        setAudioOutput,
 | 
			
		||||
      ]
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <MediaHandlerContext.Provider value={context}>
 | 
			
		||||
| 
						 | 
				
			
			@ -58,8 +58,12 @@ export const usePTTSounds = (): PTTSounds => {
 | 
			
		|||
          break;
 | 
			
		||||
      }
 | 
			
		||||
      if (ref.current) {
 | 
			
		||||
        ref.current.currentTime = 0;
 | 
			
		||||
        await ref.current.play();
 | 
			
		||||
        try {
 | 
			
		||||
          ref.current.currentTime = 0;
 | 
			
		||||
          await ref.current.play();
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
          console.log("Couldn't play sound effect", e);
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        console.log("No media element found");
 | 
			
		||||
      }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,6 +16,10 @@ limitations under the License.
 | 
			
		|||
 | 
			
		||||
import { useRef, useEffect, RefObject } from "react";
 | 
			
		||||
import { parse as parseSdp, write as writeSdp } from "sdp-transform";
 | 
			
		||||
import {
 | 
			
		||||
  acquireContext,
 | 
			
		||||
  releaseContext,
 | 
			
		||||
} from "matrix-js-sdk/src/webrtc/audioContext";
 | 
			
		||||
 | 
			
		||||
import { useSpatialAudio } from "../settings/useSetting";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -68,9 +72,15 @@ export const useMediaStream = (
 | 
			
		|||
      audioOutputDevice &&
 | 
			
		||||
      mediaRef.current !== undefined
 | 
			
		||||
    ) {
 | 
			
		||||
      console.log(`useMediaStream setSinkId ${audioOutputDevice}`);
 | 
			
		||||
      // Chrome for Android doesn't support this
 | 
			
		||||
      mediaRef.current.setSinkId?.(audioOutputDevice);
 | 
			
		||||
      if (mediaRef.current.setSinkId) {
 | 
			
		||||
        console.log(
 | 
			
		||||
          `useMediaStream setting output setSinkId ${audioOutputDevice}`
 | 
			
		||||
        );
 | 
			
		||||
        // Chrome for Android doesn't support this
 | 
			
		||||
        mediaRef.current.setSinkId(audioOutputDevice);
 | 
			
		||||
      } else {
 | 
			
		||||
        console.log("Can't set output - no setsinkid");
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }, [audioOutputDevice]);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -146,7 +156,7 @@ export const useAudioContext = (): [
 | 
			
		|||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (audioRef.current && !context.current) {
 | 
			
		||||
      context.current = new AudioContext();
 | 
			
		||||
      context.current = acquireContext();
 | 
			
		||||
 | 
			
		||||
      if (window.chrome) {
 | 
			
		||||
        // We're in Chrome, which needs a loopback hack applied to enable AEC
 | 
			
		||||
| 
						 | 
				
			
			@ -160,9 +170,11 @@ export const useAudioContext = (): [
 | 
			
		|||
        })();
 | 
			
		||||
        return () => {
 | 
			
		||||
          audioEl.srcObject = null;
 | 
			
		||||
          releaseContext();
 | 
			
		||||
        };
 | 
			
		||||
      } else {
 | 
			
		||||
        destination.current = context.current.destination;
 | 
			
		||||
        return releaseContext;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }, []);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue