Merge remote-tracking branch 'origin/livekit' into dbkr/refactor_urlparams

This commit is contained in:
David Baker
2023-09-18 17:54:49 +01:00
39 changed files with 1792 additions and 1879 deletions

View File

@@ -29,8 +29,9 @@
"@opentelemetry/instrumentation-document-load": "^0.33.0",
"@opentelemetry/instrumentation-user-interaction": "^0.33.0",
"@opentelemetry/sdk-trace-web": "^1.9.1",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-visually-hidden": "^1.0.3",
"@react-aria/button": "^3.3.4",
"@react-aria/dialog": "^3.1.4",
"@react-aria/focus": "^3.5.0",
"@react-aria/menu": "^3.3.0",
"@react-aria/overlays": "^3.7.3",
@@ -40,7 +41,6 @@
"@react-aria/utils": "^3.10.0",
"@react-spring/web": "^9.4.4",
"@react-stately/collections": "^3.3.4",
"@react-stately/overlays": "^3.1.3",
"@react-stately/select": "^3.1.3",
"@react-stately/tooltip": "^3.0.5",
"@react-stately/tree": "^3.2.0",
@@ -52,7 +52,6 @@
"@vitejs/plugin-basic-ssl": "^1.0.1",
"@vitejs/plugin-react": "^4.0.1",
"classnames": "^2.3.1",
"color-hash": "^2.0.1",
"events": "^3.3.0",
"i18next": "^21.10.0",
"i18next-browser-languagedetector": "^6.1.8",
@@ -77,11 +76,13 @@
"sdp-transform": "^2.14.1",
"tinyqueue": "^2.0.3",
"unique-names-generator": "^4.6.0",
"uuid": "9"
"uuid": "9",
"vaul": "^0.6.1"
},
"devDependencies": {
"@babel/core": "^7.16.5",
"@react-spring/rafz": "^9.7.3",
"@react-types/dialog": "^3.5.5",
"@sentry/vite-plugin": "^0.3.0",
"@storybook/react": "^6.5.0-alpha.5",
"@testing-library/jest-dom": "^5.16.5",

View File

@@ -22,11 +22,11 @@
"By clicking \"Join call now\", you agree to our <2>End User Licensing Agreement (EULA)</2>": "By clicking \"Join call now\", you agree to our <2>End User Licensing Agreement (EULA)</2>",
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.",
"Call link copied": "Call link copied",
"Call type menu": "Call type menu",
"Camera": "Camera",
"Close": "Close",
"Confirm password": "Confirm password",
"Connectivity to the server has been lost.": "Connectivity to the server has been lost.",
"Continue in browser": "Continue in browser",
"Copied!": "Copied!",
"Copy": "Copy",
"Copy and share this call link": "Copy and share this call link",
@@ -66,13 +66,17 @@
"Microphone off": "Microphone off",
"Microphone on": "Microphone on",
"More": "More",
"Mute microphone": "Mute microphone",
"Name of call": "Name of call",
"No": "No",
"Not encrypted": "Not encrypted",
"Not now, return to home screen": "Not now, return to home screen",
"Not registered yet? <2>Create an account</2>": "Not registered yet? <2>Create an account</2>",
"Open in the app": "Open in the app",
"Password": "Password",
"Passwords must match": "Passwords must match",
"Profile": "Profile",
"Ready to join?": "Ready to join?",
"Recaptcha dismissed": "Recaptcha dismissed",
"Recaptcha not loaded": "Recaptcha not loaded",
"Reconnect": "Reconnect",
@@ -82,6 +86,7 @@
"Retry sending logs": "Retry sending logs",
"Return to home screen": "Return to home screen",
"Select an option": "Select an option",
"Select app": "Select app",
"Send debug logs": "Send debug logs",
"Sending debug logs…": "Sending debug logs…",
"Sending…": "Sending…",
@@ -96,6 +101,9 @@
"Sign out": "Sign out",
"Speaker": "Speaker",
"Spotlight": "Spotlight",
"Start new call": "Start new call",
"Start video": "Start video",
"Stop video": "Stop video",
"Submit": "Submit",
"Submit feedback": "Submit feedback",
"Submitting…": "Submitting…",
@@ -104,20 +112,14 @@
"Thanks!": "Thanks!",
"This call already exists, would you like to join?": "This call already exists, would you like to join?",
"This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>End User Licensing Agreement (EULA)</12>": "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>End User Licensing Agreement (EULA)</12>",
"Unmute microphone": "Unmute microphone",
"User menu": "User menu",
"Username": "Username",
"Version: {{version}}": "Version: {{version}}",
"Video": "Video",
"Video call": "Video call",
"Video call name": "Video call name",
"Video off": "Video off",
"Video on": "Video on",
"Waiting for other participants…": "Waiting for other participants…",
"Walkie-talkie call": "Walkie-talkie call",
"Walkie-talkie call name": "Walkie-talkie call name",
"Yes, join call": "Yes, join call",
"You": "You",
"You were disconnected from the call": "You were disconnected from the call",
"Your feedback": "Your feedback",
"Your recent calls": "Your recent calls"
"Your feedback": "Your feedback"
}

31
src/Glass.module.css Normal file
View File

@@ -0,0 +1,31 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.glass {
border-radius: 36px;
padding: 11px;
border: 1px solid var(--cpd-color-alpha-gray-400);
background: var(--cpd-color-alpha-gray-400);
backdrop-filter: blur(10px);
}
.glass > * {
border-radius: 24px;
}
.glass.frosted {
backdrop-filter: blur(20px);
}

52
src/Glass.tsx Normal file
View File

@@ -0,0 +1,52 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
ComponentPropsWithoutRef,
ReactNode,
forwardRef,
Children,
} from "react";
import classNames from "classnames";
import styles from "./Glass.module.css";
interface Props extends ComponentPropsWithoutRef<"div"> {
children: ReactNode;
className?: string;
/**
* Increases the blur effect.
* @default false
*/
frosted?: boolean;
}
/**
* Adds a border of glass around a child component.
*/
export const Glass = forwardRef<HTMLDivElement, Props>(
({ frosted = false, children, className, ...rest }, ref) => (
<div
ref={ref}
className={classNames(className, styles.glass, {
[styles.frosted]: frosted,
})}
{...rest}
>
{Children.only(children)}
</div>
)
);

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,77 +14,204 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.modalOverlay {
.overlay {
position: fixed;
z-index: 100;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: rgba(23, 25, 28, 0.5);
display: flex;
align-items: center;
justify-content: center;
inset: 0;
background: rgba(3, 12, 27, 0.528);
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.dialogOverlay[data-state="open"] {
animation: fade-in 200ms;
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.dialogOverlay[data-state="closed"] {
animation: fade-out 130ms;
}
.modal {
background: var(--cpd-color-bg-subtle-secondary);
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.15);
border-radius: 8px;
max-width: 90vw;
width: 600px;
position: fixed;
z-index: 101;
display: flex;
flex-direction: column;
}
.modalHeader {
display: flex;
justify-content: space-between;
padding: 34px 32px 0 32px;
.dialog {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
box-sizing: border-box;
inline-size: 520px;
max-inline-size: 90%;
max-block-size: 600px;
}
.modalHeader h3 {
font-weight: 600;
font-size: var(--font-size-title);
margin: 0;
@keyframes zoom-in {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(80%);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(100%);
}
}
.closeButton {
position: relative;
display: flex;
justify-content: center;
align-items: center;
background-color: transparent;
padding: 0;
border: none;
cursor: pointer;
@keyframes zoom-out {
from {
opacity: 1;
transform: translate(-50%, -50%) scale(100%);
}
to {
opacity: 0;
transform: translate(-50%, -50%) scale(80%);
}
}
.dialog[data-state="open"] {
animation: zoom-in 200ms;
}
.dialog[data-state="closed"] {
animation: zoom-out 130ms;
}
@media (prefers-reduced-motion) {
.dialog[data-state="open"] {
animation-name: fade-in;
}
.dialog[data-state="closed"] {
animation-name: fade-out;
}
}
.content {
padding: 24px 32px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.content p {
margin-top: 0;
.dialog .content {
flex-grow: 1;
background: var(--cpd-color-bg-canvas-default);
}
@media (max-width: 799px) {
.modalHeader {
display: flex;
justify-content: space-between;
padding: 32px 20px 0 20px;
}
.drawer .content {
overflow: auto;
}
.modal.mobileFullScreen {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
width: 100%;
height: 100%;
max-width: none;
max-height: none;
border-radius: 0;
.drawer {
background: var(--cpd-color-bg-canvas-default);
inset-block-end: 0;
inset-inline: max(0px, calc((100% - 520px) / 2));
max-block-size: 90%;
border-start-start-radius: 20px;
border-start-end-radius: 20px;
/* Drawer handle comes in the Android style by default */
--handle-block-size: 4px;
--handle-inline-size: 32px;
--handle-inset-block-start: var(--cpd-space-4x);
--handle-inset-block-end: var(--cpd-space-4x);
}
body[data-platform="ios"] .drawer {
--handle-block-size: 5px;
--handle-inline-size: 36px;
--handle-inset-block-start: var(--cpd-space-1-5x);
--handle-inset-block-end: calc(var(--cpd-space-1x) / 4);
}
.close {
cursor: pointer;
color: var(--cpd-color-icon-secondary);
border-radius: var(--cpd-radius-pill-effect);
padding: var(--cpd-space-1x);
background: var(--cpd-color-bg-subtle-secondary);
border: none;
}
.close svg {
display: block;
}
@media (hover: hover) {
.close:hover {
background: var(--cpd-color-bg-subtle-primary);
color: var(--cpd-color-icon-primary);
}
}
.close:active {
background: var(--cpd-color-bg-subtle-primary);
color: var(--cpd-color-icon-primary);
}
.header {
background: var(--cpd-color-bg-subtle-secondary);
display: grid;
}
.dialog .header {
padding-block-start: var(--cpd-space-4x);
grid-template-columns:
var(--cpd-space-10x) 1fr minmax(var(--cpd-space-6x), auto)
var(--cpd-space-4x);
grid-template-rows: auto minmax(var(--cpd-space-4x), auto);
/* TODO: Support tabs */
grid-template-areas: ". title close ." "tabs tabs tabs tabs";
align-items: center;
}
.dialog .header h2 {
grid-area: title;
margin: 0;
}
.drawer .header {
grid-template-areas: "tabs";
position: relative;
}
.close {
grid-area: close;
}
.dialog .body {
padding-inline: var(--cpd-space-10x);
padding-block: var(--cpd-space-10x) var(--cpd-space-12x);
overflow: auto;
}
.drawer .body {
padding-inline: var(--cpd-space-4x);
padding-block: var(--cpd-space-9x) var(--cpd-space-10x);
}
.handle {
content: "";
position: absolute;
block-size: var(--handle-block-size);
inset-inline: calc((100% - var(--handle-inline-size)) / 2);
inset-block-start: var(--handle-inset-block-start);
background: var(--cpd-color-icon-secondary);
border-radius: var(--cpd-radius-pill-effect);
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,123 +14,128 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
/* eslint-disable jsx-a11y/no-autofocus */
import { useRef, useMemo, ReactNode } from "react";
import {
useOverlay,
usePreventScroll,
useModal,
OverlayContainer,
OverlayProps,
} from "@react-aria/overlays";
import {
OverlayTriggerState,
useOverlayTriggerState,
} from "@react-stately/overlays";
import { useDialog } from "@react-aria/dialog";
import { FocusScope } from "@react-aria/focus";
import { useButton } from "@react-aria/button";
import classNames from "classnames";
import { ReactNode, useCallback } from "react";
import { AriaDialogProps } from "@react-types/dialog";
import { useTranslation } from "react-i18next";
import {
Root as DialogRoot,
Portal as DialogPortal,
Overlay as DialogOverlay,
Content as DialogContent,
Title as DialogTitle,
Close as DialogClose,
} from "@radix-ui/react-dialog";
import { Drawer } from "vaul";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { ReactComponent as CloseIcon } from "@vector-im/compound-design-tokens/icons/close.svg";
import classNames from "classnames";
import { Heading } from "@vector-im/compound-web";
import { ReactComponent as CloseIcon } from "./icons/Close.svg";
import styles from "./Modal.module.css";
import { useMediaQuery } from "./useMediaQuery";
import { Glass } from "./Glass";
export interface ModalProps extends OverlayProps, AriaDialogProps {
// TODO: Support tabs
export interface ModalProps extends AriaDialogProps {
title: string;
children: ReactNode;
className?: string;
mobileFullScreen?: boolean;
onClose: () => void;
/**
* The controlled open state of the modal.
*/
// An option to leave the open state uncontrolled is intentionally not
// provided, since modals are always opened due to external triggers, and it
// is the author's belief that controlled components lead to more obvious code.
open: boolean;
/**
* Callback for when the user dismisses the modal. If undefined, the modal
* will be non-dismissable.
*/
onDismiss?: () => void;
}
/**
* A modal, taking the form of a drawer / bottom sheet on touchscreen devices,
* and a dialog box on desktop.
*/
export function Modal({
title,
children,
className,
mobileFullScreen,
onClose,
open,
onDismiss,
...rest
}: ModalProps) {
const { t } = useTranslation();
const modalRef = useRef(null);
const { overlayProps, underlayProps } = useOverlay(
{ ...rest, onClose },
modalRef
);
usePreventScroll();
const { modalProps } = useModal();
const { dialogProps, titleProps } = useDialog(rest, modalRef);
const closeButtonRef = useRef(null);
const { buttonProps: closeButtonProps } = useButton(
{
onPress: () => onClose(),
const touchscreen = useMediaQuery("(hover: none)");
const onOpenChange = useCallback(
(open: boolean) => {
if (!open) onDismiss?.();
},
closeButtonRef
[onDismiss]
);
return (
<OverlayContainer>
<div className={styles.modalOverlay} {...underlayProps}>
<FocusScope contain restoreFocus autoFocus>
<div
{...overlayProps}
{...dialogProps}
{...modalProps}
ref={modalRef}
className={classNames(
styles.modal,
{ [styles.mobileFullScreen]: mobileFullScreen },
className
)}
if (touchscreen) {
return (
<Drawer.Root
open={open}
onOpenChange={onOpenChange}
dismissible={onDismiss !== undefined}
>
<Drawer.Portal>
<Drawer.Overlay className={styles.overlay} />
<Drawer.Content
className={classNames(className, styles.modal, styles.drawer)}
{...rest}
>
<div className={styles.modalHeader}>
<h3 {...titleProps}>{title}</h3>
<button
{...closeButtonProps}
ref={closeButtonRef}
className={styles.closeButton}
data-testid="modal_close"
title={t("Close")}
>
<CloseIcon />
</button>
<div className={styles.content}>
<div className={styles.header}>
<div className={styles.handle} />
<VisuallyHidden asChild>
<Drawer.Title>{title}</Drawer.Title>
</VisuallyHidden>
</div>
<div className={styles.body}>{children}</div>
</div>
{children}
</div>
</FocusScope>
</div>
</OverlayContainer>
);
}
interface ModalContentProps {
children: ReactNode;
className?: string;
}
export function ModalContent({
children,
className,
...rest
}: ModalContentProps) {
return (
<div className={classNames(styles.content, className)} {...rest}>
{children}
</div>
);
}
export function useModalTriggerState(): {
modalState: OverlayTriggerState;
modalProps: { isOpen: boolean; onClose: () => void };
} {
const modalState = useOverlayTriggerState({});
const modalProps = useMemo(
() => ({ isOpen: modalState.isOpen, onClose: modalState.close }),
[modalState]
);
return { modalState, modalProps };
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
);
} else {
return (
<DialogRoot open={open} onOpenChange={onOpenChange}>
<DialogPortal>
<DialogOverlay
className={classNames(styles.overlay, styles.dialogOverlay)}
/>
<DialogContent asChild {...rest}>
<Glass
frosted
className={classNames(className, styles.modal, styles.dialog)}
>
<div className={styles.content}>
<div className={styles.header}>
<DialogTitle asChild>
<Heading as="h2" weight="semibold" size="md">
{title}
</Heading>
</DialogTitle>
{onDismiss !== undefined && (
<DialogClose
className={styles.close}
data-testid="modal_close"
aria-label={t("Close")}
>
<CloseIcon width={20} height={20} />
</DialogClose>
)}
</div>
<div className={styles.body}>{children}</div>
</div>
</Glass>
</DialogContent>
</DialogPortal>
</DialogRoot>
);
}
}

38
src/Platform.ts Normal file
View File

@@ -0,0 +1,38 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* The platform on which the application is running.
*/
// The granularity of this value is kind of arbitrary: it distinguishes exactly
// the platforms that the app needs to know about in order to correctly
// implement the designs and work around platform-specific browser weirdness.
// Feel free to increase or decrease that granularity in the future as project
// requirements change.
export let platform: "android" | "ios" | "desktop";
if (/android/i.test(navigator.userAgent)) {
platform = "android";
// We include 'Mac' here and double-check for touch support because iPads on
// iOS 13 pretend to be a MacOS desktop
} else if (
/iPad|iPhone|iPod|Mac/.test(navigator.userAgent) &&
"ontouchend" in document
) {
platform = "ios";
} else {
platform = "desktop";
}

View File

@@ -54,10 +54,6 @@ interface UrlParams {
* Whether to hide the screen-sharing button.
*/
hideScreensharing: boolean;
/**
* Whether to start a walkie-talkie call instead of a video call.
*/
isPtt: boolean;
/**
* Whether to use end-to-end encryption.
*/
@@ -105,6 +101,22 @@ interface UrlParams {
password: string | null;
}
export function editFragmentQuery(
hash: string,
edit: (params: URLSearchParams) => URLSearchParams
): string {
const fragmentQueryStart = hash.indexOf("?");
const fragmentParams = edit(
new URLSearchParams(
fragmentQueryStart === -1 ? "" : hash.substring(fragmentQueryStart)
)
);
return `${hash.substring(
0,
fragmentQueryStart
)}?${fragmentParams.toString()}`;
}
class ParamParser {
private fragmentParams: URLSearchParams;
private queryParams: URLSearchParams;
@@ -161,7 +173,6 @@ export const getUrlParams = (
preload: parser.hasParam("preload"),
hideHeader: parser.hasParam("hideHeader"),
hideScreensharing: parser.hasParam("hideScreensharing"),
isPtt: parser.hasParam("ptt"),
e2eEnabled: parser.getParam("enableE2e") !== "false", // Defaults to true
userId: parser.getParam("userId"),
displayName: parser.getParam("displayName"),

View File

@@ -19,7 +19,6 @@ import { useHistory, useLocation } from "react-router-dom";
import { useClientLegacy } from "./ClientContext";
import { useProfile } from "./profile/useProfile";
import { useModalTriggerState } from "./Modal";
import { SettingsModal } from "./settings/SettingsModal";
import { UserMenu } from "./UserMenu";
@@ -32,7 +31,11 @@ export function UserMenuContainer({ preventNavigation = false }: Props) {
const history = useHistory();
const { client, logout, authenticated, passwordlessUser } = useClientLegacy();
const { displayName, avatarUrl } = useProfile(client);
const { modalState, modalProps } = useModalTriggerState();
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const onDismissSettingsModal = useCallback(
() => setSettingsModalOpen(false),
[setSettingsModalOpen]
);
const [defaultSettingsTab, setDefaultSettingsTab] = useState<string>();
@@ -41,11 +44,11 @@ export function UserMenuContainer({ preventNavigation = false }: Props) {
switch (value) {
case "user":
setDefaultSettingsTab("profile");
modalState.open();
setSettingsModalOpen(true);
break;
case "settings":
setDefaultSettingsTab("audio");
modalState.open();
setSettingsModalOpen(true);
break;
case "logout":
logout?.();
@@ -55,7 +58,7 @@ export function UserMenuContainer({ preventNavigation = false }: Props) {
break;
}
},
[history, location, logout, modalState]
[history, location, logout, setSettingsModalOpen]
);
const userName = client?.getUserIdLocalpart() ?? "";
@@ -70,11 +73,12 @@ export function UserMenuContainer({ preventNavigation = false }: Props) {
userId={client?.getUserId() ?? ""}
displayName={displayName || (userName ? userName.replace("@", "") : "")}
/>
{modalState.isOpen && client && (
{client && (
<SettingsModal
client={client}
defaultTab={defaultSettingsTab}
{...modalProps}
open={settingsModalOpen}
onDismiss={onDismissSettingsModal}
/>
)}
</>

View File

@@ -145,11 +145,11 @@ export function MicButton({
}) {
const { t } = useTranslation();
const Icon = muted ? MicOffSolidIcon : MicOnSolidIcon;
const label = muted ? t("Microphone off") : t("Microphone on");
const label = muted ? t("Unmute microphone") : t("Mute microphone");
return (
<Tooltip label={label}>
<Button variant="toolbar" {...rest} on={!muted}>
<Button variant="toolbar" {...rest} on={muted}>
<Icon aria-label={label} />
</Button>
</Tooltip>
@@ -166,11 +166,11 @@ export function VideoButton({
}) {
const { t } = useTranslation();
const Icon = muted ? VideoCallOffIcon : VideoCallIcon;
const label = muted ? t("Video off") : t("Video on");
const label = muted ? t("Start video") : t("Stop video");
return (
<Tooltip label={label}>
<Button variant="toolbar" {...rest} on={!muted}>
<Button variant="toolbar" {...rest} on={muted}>
<Icon aria-label={label} />
</Button>
</Tooltip>

View File

@@ -1,85 +0,0 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { FC } from "react";
import { Item } from "@react-stately/collections";
import { useTranslation } from "react-i18next";
import { Headline } from "../typography/Typography";
import { Button } from "../button";
import { PopoverMenuTrigger } from "../popover/PopoverMenu";
import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
import { ReactComponent as CheckIcon } from "../icons/Check.svg";
import styles from "./CallTypeDropdown.module.css";
import commonStyles from "./common.module.css";
import menuStyles from "../Menu.module.css";
import { Menu } from "../Menu";
export enum CallType {
Video = "video",
Radio = "radio",
}
interface Props {
callType: CallType;
setCallType: (value: CallType) => void;
}
export const CallTypeDropdown: FC<Props> = ({ callType, setCallType }) => {
const { t } = useTranslation();
const onAction = (key: React.Key) => {
setCallType(key.toString() as CallType);
};
const onClose = () => {};
return (
<PopoverMenuTrigger placement="bottom">
<Button variant="dropdown" className={commonStyles.headline}>
<Headline className={styles.label}>
{callType === CallType.Video
? t("Video call")
: t("Walkie-talkie call")}
</Headline>
</Button>
{(props: JSX.IntrinsicAttributes) => (
<Menu
{...props}
label={t("Call type menu")}
onAction={onAction}
onClose={onClose}
>
<Item key={CallType.Video} textValue={t("Video call")}>
<VideoIcon />
<span>{t("Video call")}</span>
{callType === CallType.Video && (
<CheckIcon className={menuStyles.checkIcon} />
)}
</Item>
<Item key={CallType.Radio} textValue={t("Walkie-talkie call")}>
<MicIcon />
<span>{t("Walkie-talkie call")}</span>
{callType === CallType.Radio && (
<CheckIcon className={menuStyles.checkIcon} />
)}
</Item>
</Menu>
)}
</PopoverMenuTrigger>
);
};

View File

@@ -34,10 +34,7 @@ export function HomePage() {
return <ErrorView error={clientState.error} />;
} else {
return clientState.authenticated ? (
<RegisteredView
isPasswordlessUser={clientState.authenticated.isPasswordlessUser}
client={clientState.authenticated.client}
/>
<RegisteredView client={clientState.authenticated.client} />
) : (
<UnauthenticatedView />
);

View File

@@ -17,36 +17,29 @@ limitations under the License.
import { PressEvent } from "@react-types/shared";
import { useTranslation } from "react-i18next";
import { Modal, ModalContent } from "../Modal";
import { Modal } from "../Modal";
import { Button } from "../button";
import { FieldRow } from "../input/Input";
import styles from "./JoinExistingCallModal.module.css";
interface Props {
open: boolean;
onDismiss: () => void;
onJoin: (e: PressEvent) => void;
onClose: () => void;
// TODO: add used parameters for <Modal>
[index: string]: unknown;
}
export function JoinExistingCallModal({ onJoin, onClose, ...rest }: Props) {
export function JoinExistingCallModal({ onJoin, open, onDismiss }: Props) {
const { t } = useTranslation();
return (
<Modal
title={t("Join existing call?")}
isDismissable
{...rest}
onClose={onClose}
>
<ModalContent>
<p>{t("This call already exists, would you like to join?")}</p>
<FieldRow rightAlign className={styles.buttons}>
<Button onPress={onClose}>{t("No")}</Button>
<Button onPress={onJoin} data-testid="home_joinExistingRoom">
{t("Yes, join call")}
</Button>
</FieldRow>
</ModalContent>
<Modal title={t("Join existing call?")} open={open} onDismiss={onDismiss}>
<p>{t("This call already exists, would you like to join?")}</p>
<FieldRow rightAlign className={styles.buttons}>
<Button onPress={onDismiss}>{t("No")}</Button>
<Button onPress={onJoin} data-testid="home_joinExistingRoom">
{t("Yes, join call")}
</Button>
</FieldRow>
</Modal>
);
}

View File

@@ -19,6 +19,7 @@ import { useHistory } from "react-router-dom";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { useTranslation } from "react-i18next";
import { Heading } from "@vector-im/compound-web";
import {
createRoom,
@@ -33,11 +34,9 @@ import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { Button } from "../button";
import { CallList } from "./CallList";
import { UserMenuContainer } from "../UserMenuContainer";
import { useModalTriggerState } from "../Modal";
import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { Caption, Title } from "../typography/Typography";
import { Caption } from "../typography/Typography";
import { Form } from "../form/Form";
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
import { useEnableE2EE, useOptInAnalytics } from "../settings/useSetting";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { E2EEBanner } from "../E2EEBanner";
@@ -46,17 +45,20 @@ import { getRoomSharedKeyLocalStorageKey } from "../e2ee/sharedKeyManagement";
interface Props {
client: MatrixClient;
isPasswordlessUser: boolean;
}
export function RegisteredView({ client, isPasswordlessUser }: Props) {
const [callType, setCallType] = useState(CallType.Video);
export function RegisteredView({ client }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>();
const [optInAnalytics] = useOptInAnalytics();
const history = useHistory();
const { t } = useTranslation();
const { modalState, modalProps } = useModalTriggerState();
const [joinExistingCallModalOpen, setJoinExistingCallModalOpen] =
useState(false);
const onDismissJoinExistingCallModal = useCallback(
() => setJoinExistingCallModalOpen(false),
[setJoinExistingCallModalOpen]
);
const [e2eeEnabled] = useEnableE2EE();
const onSubmit: FormEventHandler<HTMLFormElement> = useCallback(
@@ -68,14 +70,13 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
typeof roomNameData === "string"
? sanitiseRoomNameInput(roomNameData)
: "";
const ptt = callType === CallType.Radio;
async function submit() {
setError(undefined);
setLoading(true);
const roomId = (
await createRoom(client, roomName, ptt, e2eeEnabled ?? false)
await createRoom(client, roomName, e2eeEnabled ?? false)
)[1];
if (e2eeEnabled) {
@@ -93,7 +94,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
setExistingAlias(roomAliasLocalpartFromRoomName(roomName));
setLoading(false);
setError(undefined);
modalState.open();
setJoinExistingCallModalOpen(true);
} else {
console.error(error);
setLoading(false);
@@ -101,7 +102,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
}
});
},
[client, history, modalState, callType, e2eeEnabled]
[client, history, setJoinExistingCallModalOpen, e2eeEnabled]
);
const recentRooms = useGroupCallRooms(client);
@@ -111,11 +112,6 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
history.push(`/${existingAlias}`);
}, [history, existingAlias]);
const callNameLabel =
callType === CallType.Video
? t("Video call name")
: t("Walkie-talkie call name");
return (
<>
<Header>
@@ -129,14 +125,16 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
<div className={commonStyles.container}>
<main className={commonStyles.main}>
<HeaderLogo className={commonStyles.logo} />
<CallTypeDropdown callType={callType} setCallType={setCallType} />
<Heading size="lg" weight="semibold">
{t("Start new call")}
</Heading>
<Form className={styles.form} onSubmit={onSubmit}>
<FieldRow className={styles.fieldRow}>
<InputField
id="callName"
name="callName"
label={callNameLabel}
placeholder={callNameLabel}
label={t("Name of call")}
placeholder={t("Name of call")}
type="text"
required
autoComplete="off"
@@ -166,18 +164,15 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
)}
</Form>
{recentRooms.length > 0 && (
<>
<Title className={styles.recentCallsTitle}>
{t("Your recent calls")}
</Title>
<CallList rooms={recentRooms} client={client} />
</>
<CallList rooms={recentRooms} client={client} />
)}
</main>
</div>
{modalState.isOpen && (
<JoinExistingCallModal onJoin={onJoinExistingRoom} {...modalProps} />
)}
<JoinExistingCallModal
onJoin={onJoinExistingRoom}
open={joinExistingCallModalOpen}
onDismiss={onDismissJoinExistingCallModal}
/>
</>
);
}

View File

@@ -18,6 +18,7 @@ import { FC, useCallback, useState, FormEventHandler } from "react";
import { useHistory } from "react-router-dom";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { Trans, useTranslation } from "react-i18next";
import { Heading } from "@vector-im/compound-web";
import { useClient } from "../ClientContext";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
@@ -30,12 +31,10 @@ import {
sanitiseRoomNameInput,
} from "../matrix-utils";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { useModalTriggerState } from "../Modal";
import { JoinExistingCallModal } from "./JoinExistingCallModal";
import { useRecaptcha } from "../auth/useRecaptcha";
import { Body, Caption, Link } from "../typography/Typography";
import { Form } from "../form/Form";
import { CallType, CallTypeDropdown } from "./CallTypeDropdown";
import styles from "./UnauthenticatedView.module.css";
import commonStyles from "./common.module.css";
import { generateRandomName } from "../auth/generateRandomName";
@@ -48,14 +47,18 @@ import { setLocalStorageItem } from "../useLocalStorage";
export const UnauthenticatedView: FC = () => {
const { setClient } = useClient();
const [callType, setCallType] = useState(CallType.Video);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>();
const [optInAnalytics] = useOptInAnalytics();
const { recaptchaKey, register } = useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
const { modalState, modalProps } = useModalTriggerState();
const [joinExistingCallModalOpen, setJoinExistingCallModalOpen] =
useState(false);
const onDismissJoinExistingCallModal = useCallback(
() => setJoinExistingCallModalOpen(false),
[setJoinExistingCallModalOpen]
);
const [onFinished, setOnFinished] = useState<() => void>();
const history = useHistory();
const { t } = useTranslation();
@@ -68,7 +71,6 @@ export const UnauthenticatedView: FC = () => {
const data = new FormData(e.target as HTMLFormElement);
const roomName = sanitiseRoomNameInput(data.get("callName") as string);
const displayName = data.get("displayName") as string;
const ptt = callType === CallType.Radio;
async function submit() {
setError(undefined);
@@ -86,7 +88,7 @@ export const UnauthenticatedView: FC = () => {
let roomId: string;
try {
roomId = (
await createRoom(client, roomName, ptt, e2eeEnabled ?? false)
await createRoom(client, roomName, e2eeEnabled ?? false)
)[1];
if (e2eeEnabled) {
@@ -110,7 +112,7 @@ export const UnauthenticatedView: FC = () => {
});
setLoading(false);
modalState.open();
setJoinExistingCallModalOpen(true);
return;
} else {
throw error;
@@ -138,18 +140,12 @@ export const UnauthenticatedView: FC = () => {
reset,
execute,
history,
callType,
modalState,
setJoinExistingCallModalOpen,
setClient,
e2eeEnabled,
]
);
const callNameLabel =
callType === CallType.Video
? t("Video call name")
: t("Walkie-talkie call name");
return (
<>
<Header>
@@ -163,14 +159,16 @@ export const UnauthenticatedView: FC = () => {
<div className={commonStyles.container}>
<main className={commonStyles.main}>
<HeaderLogo className={commonStyles.logo} />
<CallTypeDropdown callType={callType} setCallType={setCallType} />
<Heading size="lg" weight="semibold">
{t("Start new call")}
</Heading>
<Form className={styles.form} onSubmit={onSubmit}>
<FieldRow>
<InputField
id="callName"
name="callName"
label={callNameLabel}
placeholder={callNameLabel}
label={t("Name of call")}
placeholder={t("Name of call")}
type="text"
required
autoComplete="off"
@@ -235,8 +233,12 @@ export const UnauthenticatedView: FC = () => {
</Body>
</footer>
</div>
{modalState.isOpen && onFinished && (
<JoinExistingCallModal onJoin={onFinished} {...modalProps} />
{onFinished && (
<JoinExistingCallModal
onJoin={onFinished}
open={joinExistingCallModalOpen}
onDismiss={onDismissJoinExistingCallModal}
/>
)}
</>
);

View File

@@ -25,12 +25,6 @@ limitations under the License.
@import "@vector-im/compound-web/dist/style.css";
:root {
--font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
"Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
"Helvetica Neue", sans-serif;
--inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f,
U+25c2-2664, U+2666-2763, U+2765-2b05, U+2b07-2b1b, U+2b1d-10FFFF;
--font-scale: 1;
--font-size-micro: calc(10px * var(--font-scale));
--font-size-caption: calc(12px * var(--font-scale));
@@ -149,7 +143,6 @@ body {
color: var(--cpd-color-text-primary);
color-scheme: dark;
margin: 0;
font-family: var(--font-family);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@@ -157,7 +150,9 @@ body {
html,
body,
#root {
height: 100%;
/* We use !important here to override vaul drawers, which have a side effect
of setting height: auto; on the body element and messing up our layouts */
height: 100% !important;
}
#root {
@@ -165,6 +160,21 @@ body,
flex-direction: column;
}
/* On Android and iOS, prefer native system fonts. The global.css file of
Compound Web is where these variables ultimately get consumed to set the page's
font-family. */
body[data-platform="android"] {
--cpd-font-family-sans: "Roboto", "Noto", "Inter", sans-serif;
}
body[data-platform="ios"] {
--cpd-font-family-sans: -apple-system, BlinkMacSystemFont, "Inter", sans-serif;
}
body[data-platform="desktop"] {
--cpd-font-family-sans: "Inter", sans-serif;
}
h1,
h2,
h3,

View File

@@ -24,6 +24,7 @@ import * as Sentry from "@sentry/react";
import { getUrlParams } from "./UrlParams";
import { Config } from "./config/Config";
import { ElementCallOpenTelemetry } from "./otel/otel";
import { platform } from "./Platform";
enum LoadState {
None,
@@ -107,6 +108,9 @@ export class Initializer {
fonts.map((f) => `"${f}"`).join(", ")
);
}
// Add the platform to the DOM, so CSS can query it
document.body.setAttribute("data-platform", platform);
}
public static init(): Promise<void> | null {

View File

@@ -200,8 +200,10 @@ export const useMediaDevices = () => useContext(MediaDevicesContext);
* default because it may involve requesting additional permissions from the
* user.
*/
export const useMediaDeviceNames = (context: MediaDevices) =>
export const useMediaDeviceNames = (context: MediaDevices, enabled = true) =>
useEffect(() => {
context.startUsingDeviceNames();
return context.stopUsingDeviceNames;
}, [context]);
if (enabled) {
context.startUsingDeviceNames();
return context.stopUsingDeviceNames;
}
}, [context, enabled]);

View File

@@ -272,7 +272,6 @@ export function isLocalRoomId(roomId: string, client: MatrixClient): boolean {
export async function createRoom(
client: MatrixClient,
name: string,
ptt: boolean,
e2ee: boolean
): Promise<[string, string]> {
logger.log(`Creating room for group call`);
@@ -327,14 +326,12 @@ export async function createRoom(
const result = await createPromise;
logger.log(
`Creating ${ptt ? "PTT" : "video"} group call in ${result.room_id}`
);
logger.log(`Creating group call in ${result.room_id}`);
await client.createGroupCall(
result.room_id,
ptt ? GroupCallType.Voice : GroupCallType.Video,
ptt,
GroupCallType.Video,
false,
GroupCallIntent.Room,
true
);

View File

@@ -1,11 +1,11 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
@@ -14,6 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.label {
margin-bottom: 0;
.modal p {
text-align: center;
margin-block-end: var(--cpd-space-8x);
}
.modal button,
.modal a {
width: 100%;
}
.modal button {
margin-block-end: var(--cpd-space-6x);
}
.modal a {
box-sizing: border-box;
}

View File

@@ -0,0 +1,80 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { FC, MouseEvent, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Text } from "@vector-im/compound-web";
import { ReactComponent as PopOutIcon } from "@vector-im/compound-design-tokens/icons/pop-out.svg";
import { Modal } from "../Modal";
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
import { getRoomUrl } from "../matrix-utils";
import styles from "./AppSelectionModal.module.css";
import { editFragmentQuery } from "../UrlParams";
interface Props {
roomId: string | null;
}
export const AppSelectionModal: FC<Props> = ({ roomId }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(true);
const onBrowserClick = useCallback(
(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setOpen(false);
},
[setOpen]
);
const roomSharedKey = useRoomSharedKey(roomId ?? "");
const appUrl = useMemo(() => {
// If the room ID is not known, fall back to the URL of the current page
const url = new URL(
roomId === null
? window.location.href
: getRoomUrl(roomId, roomSharedKey ?? undefined)
);
// Edit the URL so that it opens in embedded mode. We do this for two
// reasons: It causes the mobile app to limit the user to only visiting the
// room in question, and it prevents this app selection prompt from being
// shown a second time.
url.hash = editFragmentQuery(url.hash, (params) => {
params.set("isEmbedded", "");
return params;
});
const result = new URL("element://call");
result.searchParams.set("url", url.toString());
return result.toString();
}, [roomId, roomSharedKey]);
return (
<Modal className={styles.modal} title={t("Select app")} open={open}>
<Text size="md" weight="semibold">
{t("Ready to join?")}
</Text>
<Button kind="secondary" onClick={onBrowserClick}>
{t("Continue in browser")}
</Button>
<Button as="a" href={appUrl} Icon={PopOutIcon}>
{t("Open in the app")}
</Button>
</Modal>
);
};

View File

@@ -17,7 +17,8 @@ limitations under the License.
.headline {
text-align: center;
margin-bottom: 60px;
white-space: pre;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.callEndedContent {
@@ -66,6 +67,7 @@ limitations under the License.
flex: 1;
flex-direction: column;
align-items: center;
padding-inline: var(--inline-content-inset);
}
.logo {

View File

@@ -27,7 +27,6 @@ interface Props {
roomIdOrAlias: string;
viaServers: string[];
children: (rtcSession: MatrixRTCSession) => ReactNode;
createPtt: boolean;
}
export function GroupCallLoader({
@@ -35,15 +34,9 @@ export function GroupCallLoader({
roomIdOrAlias,
viaServers,
children,
createPtt,
}: Props): JSX.Element {
const { t } = useTranslation();
const groupCallState = useLoadGroupCall(
client,
roomIdOrAlias,
viaServers,
createPtt
);
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
switch (groupCallState.kind) {
case "loading":

View File

@@ -45,7 +45,6 @@ import {
import { useEnableE2EE } from "../settings/useSetting";
import { useRoomAvatar } from "./useRoomAvatar";
import { useRoomName } from "./useRoomName";
import { useModalTriggerState } from "../Modal";
import { useJoinRule } from "./useJoinRule";
import { ShareModal } from "./ShareModal";
@@ -286,12 +285,15 @@ export function GroupCallView({
const joinRule = useJoinRule(rtcSession.room);
const { modalState: shareModalState, modalProps: shareModalProps } =
useModalTriggerState();
const [shareModalOpen, setShareModalOpen] = useState(false);
const onDismissShareModal = useCallback(
() => setShareModalOpen(false),
[setShareModalOpen]
);
const onShareClickFn = useCallback(
() => shareModalState.open(),
[shareModalState]
() => setShareModalOpen(true),
[setShareModalOpen]
);
const onShareClick = joinRule === JoinRule.Public ? onShareClickFn : null;
@@ -311,8 +313,12 @@ export function GroupCallView({
return <ErrorView error={new Error("You need to enable E2EE to join.")} />;
}
const shareModal = shareModalState.isOpen && (
<ShareModal roomId={rtcSession.room.roomId} {...shareModalProps} />
const shareModal = (
<ShareModal
roomId={rtcSession.room.roomId}
open={shareModalOpen}
onDismiss={onDismissShareModal}
/>
);
if (isJoined) {

View File

@@ -30,7 +30,6 @@ import { Room as MatrixRoom } from "matrix-js-sdk/src/models/room";
import { Ref, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import useMeasure from "react-use-measure";
import { OverlayTriggerState } from "@react-stately/overlays";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
@@ -51,7 +50,6 @@ import {
VideoGrid,
} from "../video-grid/VideoGrid";
import { useShowConnectionStats } from "../settings/useSetting";
import { useModalTriggerState } from "../Modal";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useUrlParams } from "../UrlParams";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
@@ -313,25 +311,20 @@ export function InCallView({
);
};
const {
modalState: rageshakeRequestModalState,
modalProps: rageshakeRequestModalProps,
} = useRageshakeRequestModal(rtcSession.room.roomId);
const rageshakeRequestModalProps = useRageshakeRequestModal(
rtcSession.room.roomId
);
const {
modalState: settingsModalState,
modalProps: settingsModalProps,
}: {
modalState: OverlayTriggerState;
modalProps: {
isOpen: boolean;
onClose: () => void;
};
} = useModalTriggerState();
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const openSettings = useCallback(() => {
settingsModalState.open();
}, [settingsModalState]);
const openSettings = useCallback(
() => setSettingsModalOpen(true),
[setSettingsModalOpen]
);
const closeSettings = useCallback(
() => setSettingsModalOpen(false),
[setSettingsModalOpen]
);
const toggleScreensharing = useCallback(async () => {
exitFullscreen();
@@ -442,19 +435,13 @@ export function InCallView({
show={showInspector}
/>
)*/}
{rageshakeRequestModalState.isOpen && !noControls && (
<RageshakeRequestModal
{...rageshakeRequestModalProps}
roomId={rtcSession.room.roomId}
/>
)}
{settingsModalState.isOpen && (
<SettingsModal
client={client}
roomId={rtcSession.room.roomId}
{...settingsModalProps}
/>
)}
{!noControls && <RageshakeRequestModal {...rageshakeRequestModalProps} />}
<SettingsModal
client={client}
roomId={rtcSession.room.roomId}
open={settingsModalOpen}
onDismiss={closeSettings}
/>
</div>
);
}

View File

@@ -17,7 +17,7 @@ limitations under the License.
import { FC, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Modal, ModalContent, ModalProps } from "../Modal";
import { Modal, ModalProps } from "../Modal";
import { Button } from "../button";
import { FieldRow, ErrorMessage } from "../input/Input";
import { useSubmitRageshake } from "../settings/submit-rageshake";
@@ -26,51 +26,49 @@ import { Body } from "../typography/Typography";
interface Props extends Omit<ModalProps, "title" | "children"> {
rageshakeRequestId: string;
roomId: string;
onClose: () => void;
open: boolean;
onDismiss: () => void;
}
export const RageshakeRequestModal: FC<Props> = ({
rageshakeRequestId,
roomId,
...rest
open,
onDismiss,
}) => {
const { t } = useTranslation();
const { submitRageshake, sending, sent, error } = useSubmitRageshake();
useEffect(() => {
if (sent) {
rest.onClose();
}
}, [sent, rest]);
if (sent) onDismiss();
}, [sent, onDismiss]);
return (
<Modal title={t("Debug log request")} isDismissable {...rest}>
<ModalContent>
<Body>
{t(
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log."
)}
</Body>
<FieldRow>
<Button
onPress={() =>
submitRageshake({
sendLogs: true,
rageshakeRequestId,
roomId,
})
}
disabled={sending}
>
{sending ? t("Sending debug logs…") : t("Send debug logs")}
</Button>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage error={error} />
</FieldRow>
<Modal title={t("Debug log request")} open={open} onDismiss={onDismiss}>
<Body>
{t(
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log."
)}
</ModalContent>
</Body>
<FieldRow>
<Button
onPress={() =>
submitRageshake({
sendLogs: true,
rageshakeRequestId,
roomId,
})
}
disabled={sending}
>
{sending ? t("Sending debug logs…") : t("Send debug logs")}
</Button>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage error={error} />
</FieldRow>
)}
</Modal>
);
};

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { FC, useEffect, useState, useCallback } from "react";
import { FC, useEffect, useState, useCallback, ReactNode } from "react";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { useClientLegacy } from "../ClientContext";
@@ -26,10 +26,11 @@ import { useRoomIdentifier, useUrlParams } from "../UrlParams";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { useOptInAnalytics } from "../settings/useSetting";
import { HomePage } from "../home/HomePage";
import { platform } from "../Platform";
import { AppSelectionModal } from "./AppSelectionModal";
export const RoomPage: FC = () => {
const { isEmbedded, preload, hideHeader, isPtt, displayName } =
useUrlParams();
const { isEmbedded, preload, hideHeader, displayName } = useUrlParams();
const { roomAlias, roomId, viaServers } = useRoomIdentifier();
@@ -81,30 +82,36 @@ export const RoomPage: FC = () => {
[client, passwordlessUser, isEmbedded, preload, hideHeader]
);
let content: ReactNode;
if (loading || isRegistering) {
return <LoadingView />;
}
if (error) {
return <ErrorView error={error} />;
}
if (!client) {
return <RoomAuthView />;
}
if (!roomIdOrAlias) {
return <HomePage />;
content = <LoadingView />;
} else if (error) {
content = <ErrorView error={error} />;
} else if (!client) {
content = <RoomAuthView />;
} else if (!roomIdOrAlias) {
// TODO: This doesn't belong here, the app routes need to be reworked
content = <HomePage />;
} else {
content = (
<GroupCallLoader
client={client}
roomIdOrAlias={roomIdOrAlias}
viaServers={viaServers}
>
{groupCallView}
</GroupCallLoader>
);
}
return (
<GroupCallLoader
client={client}
roomIdOrAlias={roomIdOrAlias}
viaServers={viaServers}
createPtt={isPtt}
>
{groupCallView}
</GroupCallLoader>
<>
{content}
{/* On mobile, show a prompt to launch the mobile app. If in embedded mode,
that means we *are* in the mobile app and should show no further prompt. */}
{(platform === "android" || platform === "ios") && !isEmbedded && (
<AppSelectionModal roomId={roomId} />
)}
</>
);
};

View File

@@ -14,10 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.inviteModal {
max-width: 413px;
}
.copyButton {
width: 100%;
}

View File

@@ -17,35 +17,30 @@ limitations under the License.
import { FC } from "react";
import { useTranslation } from "react-i18next";
import { Modal, ModalContent, ModalProps } from "../Modal";
import { Modal } from "../Modal";
import { CopyButton } from "../button";
import { getRoomUrl } from "../matrix-utils";
import styles from "./ShareModal.module.css";
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
interface Props extends Omit<ModalProps, "title" | "children"> {
interface Props {
roomId: string;
open: boolean;
onDismiss: () => void;
}
export const ShareModal: FC<Props> = ({ roomId, ...rest }) => {
export const ShareModal: FC<Props> = ({ roomId, open, onDismiss }) => {
const { t } = useTranslation();
const roomSharedKey = useRoomSharedKey(roomId);
return (
<Modal
title={t("Share this call")}
isDismissable
className={styles.inviteModal}
{...rest}
>
<ModalContent>
<p>{t("Copy and share this call link")}</p>
<CopyButton
className={styles.copyButton}
value={getRoomUrl(roomId, roomSharedKey ?? undefined)}
data-testid="modal_inviteLink"
/>
</ModalContent>
<Modal title={t("Share this call")} open={open} onDismiss={onDismiss}>
<p>{t("Copy and share this call link")}</p>
<CopyButton
className={styles.copyButton}
value={getRoomUrl(roomId, roomSharedKey ?? undefined)}
data-testid="modal_inviteLink"
/>
</Modal>
);
};

View File

@@ -14,10 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { useEffect, useCallback, useMemo, useRef, FC } from "react";
import { useEffect, useCallback, useMemo, useRef, FC, useState } from "react";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { OverlayTriggerState } from "@react-stately/overlays";
import { usePreviewTracks } from "@livekit/components-react";
import {
CreateLocalTracksOptions,
@@ -28,7 +27,6 @@ import {
import { MicButton, SettingsButton, VideoButton } from "../button";
import { Avatar } from "../Avatar";
import styles from "./VideoPreview.module.css";
import { useModalTriggerState } from "../Modal";
import { SettingsModal } from "../settings/SettingsModal";
import { useClient } from "../ClientContext";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
@@ -54,20 +52,16 @@ export const VideoPreview: FC<Props> = ({ matrixInfo, muteStates }) => {
const { client } = useClient();
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
const {
modalState: settingsModalState,
modalProps: settingsModalProps,
}: {
modalState: OverlayTriggerState;
modalProps: {
isOpen: boolean;
onClose: () => void;
};
} = useModalTriggerState();
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const openSettings = useCallback(() => {
settingsModalState.open();
}, [settingsModalState]);
const openSettings = useCallback(
() => setSettingsModalOpen(true),
[setSettingsModalOpen]
);
const closeSettings = useCallback(
() => setSettingsModalOpen(false),
[setSettingsModalOpen]
);
const devices = useMediaDevices();
@@ -153,8 +147,12 @@ export const VideoPreview: FC<Props> = ({ matrixInfo, muteStates }) => {
<SettingsButton onPress={openSettings} />
</div>
</>
{settingsModalState.isOpen && client && (
<SettingsModal client={client} {...settingsModalProps} />
{client && (
<SettingsModal
client={client}
open={settingsModalOpen}
onDismiss={closeSettings}
/>
)}
</div>
);

View File

@@ -56,8 +56,7 @@ export interface GroupCallLoadState {
export const useLoadGroupCall = (
client: MatrixClient,
roomIdOrAlias: string,
viaServers: string[],
createPtt: boolean
viaServers: string[]
): GroupCallStatus => {
const { t } = useTranslation();
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
@@ -101,7 +100,6 @@ export const useLoadGroupCall = (
const [, roomId] = await createRoom(
client,
roomNameFromRoomId(roomIdOrAlias),
createPtt,
e2eeEnabled ?? false
);
@@ -151,7 +149,7 @@ export const useLoadGroupCall = (
.then(fetchOrCreateGroupCall)
.then((rtcSession) => setState({ kind: "loaded", rtcSession }))
.catch((error) => setState({ kind: "failed", error }));
}, [client, roomIdOrAlias, viaServers, createPtt, t, e2eeEnabled]);
}, [client, roomIdOrAlias, viaServers, t, e2eeEnabled]);
return state;
};

View File

@@ -16,21 +16,7 @@ limitations under the License.
import { useEffect } from "react";
// https://stackoverflow.com/a/9039885
function isIOS() {
return (
[
"iPad Simulator",
"iPhone Simulator",
"iPod Simulator",
"iPad",
"iPhone",
"iPod",
].includes(navigator.platform) ||
// iPad on iOS 13 detection
(navigator.userAgent.includes("Mac") && "ontouchend" in document)
);
}
import { platform } from "../Platform";
export function usePageUnload(callback: () => void) {
useEffect(() => {
@@ -53,7 +39,7 @@ export function usePageUnload(callback: () => void) {
}
// iOS doesn't fire beforeunload event, so leave the call when you hide the page.
if (isIOS()) {
if (platform === "ios") {
window.addEventListener("pagehide", onBeforeUnload);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -15,18 +15,13 @@ limitations under the License.
*/
.settingsModal {
width: 774px;
height: 480px;
block-size: 550px;
}
.settingsModal p {
color: var(--cpd-color-text-secondary);
}
.tabContainer {
padding: 27px 20px;
}
.fieldRowText {
margin-bottom: 0;
}

View File

@@ -51,11 +51,11 @@ import {
} from "../livekit/MediaDevicesContext";
interface Props {
isOpen: boolean;
open: boolean;
onDismiss: () => void;
client: MatrixClient;
roomId?: string;
defaultTab?: string;
onClose: () => void;
}
export const SettingsModal = (props: Props) => {
@@ -119,7 +119,7 @@ export const SettingsModal = (props: Props) => {
);
const devices = useMediaDevices();
useMediaDeviceNames(devices);
useMediaDeviceNames(devices, props.open);
const audioTab = (
<TabItem
@@ -289,10 +289,9 @@ export const SettingsModal = (props: Props) => {
return (
<Modal
title={t("Settings")}
isDismissable
mobileFullScreen
className={styles.settingsModal}
{...props}
open={props.open}
onDismiss={props.onDismiss}
>
<TabContainer
onSelectionChange={onSelectedTabChanged}

View File

@@ -14,20 +14,25 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { useCallback, useContext, useEffect, useState } from "react";
import {
ComponentProps,
useCallback,
useContext,
useEffect,
useState,
} from "react";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import pako from "pako";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { OverlayTriggerState } from "@react-stately/overlays";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { getLogsForReport } from "./rageshake";
import { useClient } from "../ClientContext";
import { InspectorContext } from "../room/GroupCallInspector";
import { useModalTriggerState } from "../Modal";
import { Config } from "../config/Config";
import { ElementCallOpenTelemetry } from "../otel/otel";
import { RageshakeRequestModal } from "../room/RageshakeRequestModal";
const gzip = (text: string): Blob => {
// encode as UTF-8
@@ -343,22 +348,12 @@ export function useRageshakeRequest(): (
return sendRageshakeRequest;
}
interface ModalProps {
isOpen: boolean;
onClose: () => void;
}
interface ModalPropsWithId extends ModalProps {
rageshakeRequestId: string;
}
export function useRageshakeRequestModal(roomId: string): {
modalState: OverlayTriggerState;
modalProps: ModalPropsWithId;
} {
const { modalState, modalProps } = useModalTriggerState() as {
modalState: OverlayTriggerState;
modalProps: ModalProps;
};
export function useRageshakeRequestModal(
roomId: string
): ComponentProps<typeof RageshakeRequestModal> {
const [open, setOpen] = useState(false);
const onDismiss = useCallback(() => setOpen(false), [setOpen]);
const { client } = useClient();
const [rageshakeRequestId, setRageshakeRequestId] = useState<string>();
@@ -374,7 +369,7 @@ export function useRageshakeRequestModal(roomId: string): {
client.getUserId() !== event.getSender()
) {
setRageshakeRequestId(event.getContent().request_id);
modalState.open();
setOpen(true);
}
};
@@ -383,10 +378,12 @@ export function useRageshakeRequestModal(roomId: string): {
return () => {
client.removeListener(ClientEvent.Event, onEvent);
};
}, [modalState.open, roomId, client, modalState]);
}, [setOpen, roomId, client]);
return {
modalState,
modalProps: { ...modalProps, rageshakeRequestId: rageshakeRequestId ?? "" },
rageshakeRequestId: rageshakeRequestId ?? "",
roomId,
open,
onDismiss,
};
}

View File

@@ -78,32 +78,3 @@ limitations under the License.
padding: 0;
overflow-y: auto;
}
@media (min-width: 800px) {
.tab {
width: 200px;
padding: 0 16px;
}
.tab > * {
margin: 0 12px 0 0;
}
.tabContainer {
width: 100%;
flex-direction: row;
padding: 20px 18px;
box-sizing: border-box;
overflow: hidden;
}
.tabList {
flex-direction: column;
margin-bottom: 0;
gap: 0;
}
.tabPanel {
padding: 0 40px;
}
}

View File

@@ -14,7 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ComponentProps, forwardRef, useCallback, useEffect } from "react";
import {
ComponentProps,
forwardRef,
useCallback,
useEffect,
useState,
} from "react";
import { animated } from "@react-spring/web";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
@@ -36,7 +42,6 @@ import { Avatar } from "../Avatar";
import styles from "./VideoTile.module.css";
import { useReactiveState } from "../useReactiveState";
import { AudioButton, FullscreenButton } from "../button/Button";
import { useModalTriggerState } from "../Modal";
import { VideoTileSettingsModal } from "./VideoTileSettingsModal";
export interface ItemData {
@@ -117,11 +122,16 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
onToggleFullscreen(data.id);
}, [data, onToggleFullscreen]);
const {
modalState: videoTileSettingsModalState,
modalProps: videoTileSettingsModalProps,
} = useModalTriggerState();
const onOptionsPress = videoTileSettingsModalState.open;
const [videoTileSettingsModalOpen, setVideoTileSettingsModalOpen] =
useState(false);
const openVideoTileSettingsModal = useCallback(
() => setVideoTileSettingsModalOpen(true),
[setVideoTileSettingsModalOpen]
);
const closeVideoTileSettingsModal = useCallback(
() => setVideoTileSettingsModalOpen(false),
[setVideoTileSettingsModalOpen]
);
const toolbarButtons: JSX.Element[] = [];
if (!sfuParticipant.isLocal) {
@@ -130,7 +140,7 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
key="localVolume"
className={styles.button}
volume={(sfuParticipant as RemoteParticipant).getVolume() ?? 0}
onPress={onOptionsPress}
onPress={openVideoTileSettingsModal}
/>
);
@@ -208,10 +218,11 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
: Track.Source.ScreenShare
}
/>
{videoTileSettingsModalState.isOpen && !maximised && (
{!maximised && (
<VideoTileSettingsModal
{...videoTileSettingsModalProps}
data={data}
open={videoTileSettingsModalOpen}
onDismiss={closeVideoTileSettingsModal}
/>
)}
</animated.div>

View File

@@ -66,23 +66,21 @@ const LocalVolume: React.FC<LocalVolumeProps> = ({
);
};
// TODO: Extend ModalProps
interface Props {
data: ItemData;
onClose: () => void;
open: boolean;
onDismiss: () => void;
}
export const VideoTileSettingsModal = ({ data, onClose, ...rest }: Props) => {
export const VideoTileSettingsModal = ({ data, open, onDismiss }: Props) => {
const { t } = useTranslation();
return (
<Modal
className={styles.videoTileSettingsModal}
title={t("Local volume")}
isDismissable
mobileFullScreen
onClose={onClose}
{...rest}
open={open}
onDismiss={onDismiss}
>
<div className={styles.content}>
<LocalVolume

2250
yarn.lock

File diff suppressed because it is too large Load Diff