mirror of
https://github.com/vector-im/element-call.git
synced 2026-04-15 07:50:26 +00:00
Merge remote-tracking branch 'origin/livekit' into michaelk/various_testid_tags
This commit is contained in:
97
src/App.tsx
97
src/App.tsx
@@ -15,7 +15,12 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Switch,
|
||||
Route,
|
||||
useLocation,
|
||||
} from "react-router-dom";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { OverlayProvider } from "@react-aria/overlays";
|
||||
import { History } from "history";
|
||||
@@ -34,6 +39,26 @@ import { MediaDevicesProvider } from "./livekit/MediaDevicesContext";
|
||||
|
||||
const SentryRoute = Sentry.withSentryRouting(Route);
|
||||
|
||||
interface BackgroundProviderProps {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
const BackgroundProvider = ({ children }: BackgroundProviderProps) => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
let backgroundImage = "";
|
||||
if (!["/login", "/register"].includes(pathname)) {
|
||||
backgroundImage = "var(--background-gradient)";
|
||||
}
|
||||
|
||||
document.getElementsByTagName("body")[0].style.backgroundImage =
|
||||
backgroundImage;
|
||||
}, [pathname]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
interface AppProps {
|
||||
history: History;
|
||||
}
|
||||
@@ -53,40 +78,42 @@ export default function App({ history }: AppProps) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
<Router history={history}>
|
||||
{loaded ? (
|
||||
<Suspense fallback={null}>
|
||||
<ClientProvider>
|
||||
<MediaDevicesProvider>
|
||||
<InspectorContextProvider>
|
||||
<Sentry.ErrorBoundary fallback={errorPage}>
|
||||
<OverlayProvider>
|
||||
<DisconnectedBanner />
|
||||
<Switch>
|
||||
<SentryRoute exact path="/">
|
||||
<HomePage />
|
||||
</SentryRoute>
|
||||
<SentryRoute exact path="/login">
|
||||
<LoginPage />
|
||||
</SentryRoute>
|
||||
<SentryRoute exact path="/register">
|
||||
<RegisterPage />
|
||||
</SentryRoute>
|
||||
<SentryRoute path="/inspector">
|
||||
<SequenceDiagramViewerPage />
|
||||
</SentryRoute>
|
||||
<SentryRoute path="*">
|
||||
<RoomPage />
|
||||
</SentryRoute>
|
||||
</Switch>
|
||||
</OverlayProvider>
|
||||
</Sentry.ErrorBoundary>
|
||||
</InspectorContextProvider>
|
||||
</MediaDevicesProvider>
|
||||
</ClientProvider>
|
||||
</Suspense>
|
||||
) : (
|
||||
<LoadingView />
|
||||
)}
|
||||
<BackgroundProvider>
|
||||
{loaded ? (
|
||||
<Suspense fallback={null}>
|
||||
<ClientProvider>
|
||||
<MediaDevicesProvider>
|
||||
<InspectorContextProvider>
|
||||
<Sentry.ErrorBoundary fallback={errorPage}>
|
||||
<OverlayProvider>
|
||||
<DisconnectedBanner />
|
||||
<Switch>
|
||||
<SentryRoute exact path="/">
|
||||
<HomePage />
|
||||
</SentryRoute>
|
||||
<SentryRoute exact path="/login">
|
||||
<LoginPage />
|
||||
</SentryRoute>
|
||||
<SentryRoute exact path="/register">
|
||||
<RegisterPage />
|
||||
</SentryRoute>
|
||||
<SentryRoute path="/inspector">
|
||||
<SequenceDiagramViewerPage />
|
||||
</SentryRoute>
|
||||
<SentryRoute path="*">
|
||||
<RoomPage />
|
||||
</SentryRoute>
|
||||
</Switch>
|
||||
</OverlayProvider>
|
||||
</Sentry.ErrorBoundary>
|
||||
</InspectorContextProvider>
|
||||
</MediaDevicesProvider>
|
||||
</ClientProvider>
|
||||
</Suspense>
|
||||
) : (
|
||||
<LoadingView />
|
||||
)}
|
||||
</BackgroundProvider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
31
src/Glass.module.css
Normal file
31
src/Glass.module.css
Normal 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
52
src/Glass.tsx
Normal 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>
|
||||
)
|
||||
);
|
||||
@@ -21,6 +21,7 @@ limitations under the License.
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
padding-inline: var(--inline-content-inset);
|
||||
}
|
||||
|
||||
.nav {
|
||||
@@ -28,7 +29,6 @@ limitations under the License.
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding-inline: var(--inline-content-inset);
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,210 @@ 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;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin-top: 0;
|
||||
.dialog .content {
|
||||
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: var(--border-radius);
|
||||
border-start-end-radius: var(--border-radius);
|
||||
/* Drawer comes in the Android style by default */
|
||||
--border-radius: 28px;
|
||||
--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 {
|
||||
--border-radius: 10px;
|
||||
--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;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
203
src/Modal.tsx
203
src/Modal.tsx
@@ -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,130 @@ 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(),
|
||||
// Empirically, Chrome on Android can end up not matching (hover: none), but
|
||||
// still matching (pointer: coarse) :/
|
||||
const touchscreen = useMediaQuery("(hover: none) or (pointer: coarse)");
|
||||
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
38
src/Platform.ts
Normal 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";
|
||||
}
|
||||
258
src/UrlParams.ts
258
src/UrlParams.ts
@@ -21,15 +21,35 @@ import { Config } from "./config/Config";
|
||||
|
||||
export const PASSWORD_STRING = "password=";
|
||||
|
||||
interface UrlParams {
|
||||
interface RoomIdentifier {
|
||||
roomAlias: string | null;
|
||||
roomId: string | null;
|
||||
viaServers: string[];
|
||||
}
|
||||
|
||||
// If you need to add a new flag to this interface, prefer a name that describes
|
||||
// a specific behavior (such as 'confineToRoom'), rather than one that describes
|
||||
// the situations that call for this behavior ('isEmbedded'). This makes it
|
||||
// clearer what each flag means, and helps us avoid coupling Element Call's
|
||||
// behavior to the needs of specific consumers.
|
||||
interface UrlParams {
|
||||
/**
|
||||
* Whether the app is running in embedded mode, and should keep the user
|
||||
* confined to the current room.
|
||||
* Anything about what room we're pointed to should be from useRoomIdentifier which
|
||||
* parses the path and resolves alias with respect to the default server name, however
|
||||
* roomId is an exception as we need the room ID in embedded (matroyska) mode, and not
|
||||
* the room alias (or even the via params because we are not trying to join it). This
|
||||
* is also not validated, where it is in useRoomIdentifier().
|
||||
*/
|
||||
isEmbedded: boolean;
|
||||
roomId: string | null;
|
||||
/**
|
||||
* Whether the app should keep the user confined to the current call/room.
|
||||
*/
|
||||
confineToRoom: boolean;
|
||||
/**
|
||||
* Whether upon entering a room, the user should be prompted to launch the
|
||||
* native mobile app. (Affects only Android and iOS.)
|
||||
*/
|
||||
appPrompt: boolean;
|
||||
/**
|
||||
* Whether the app should pause before joining the call until it sees an
|
||||
* io.element.join widget action, allowing it to be preloaded.
|
||||
@@ -43,10 +63,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.
|
||||
*/
|
||||
@@ -94,76 +110,148 @@ interface UrlParams {
|
||||
password: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the app parameters for the current URL.
|
||||
* @param ignoreRoomAlias If true, does not try to parse a room alias from the URL
|
||||
* @param search The URL search string
|
||||
* @param pathname The URL path name
|
||||
* @param hash The URL hash
|
||||
* @returns The app parameters encoded in the URL
|
||||
*/
|
||||
export const getUrlParams = (
|
||||
ignoreRoomAlias?: boolean,
|
||||
search = window.location.search,
|
||||
pathname = window.location.pathname,
|
||||
hash = window.location.hash
|
||||
): UrlParams => {
|
||||
// This is legacy code - we're moving away from using aliases
|
||||
let roomAlias: string | null = null;
|
||||
if (!ignoreRoomAlias) {
|
||||
// Here we handle the beginning of the alias and make sure it starts with a
|
||||
// "#"
|
||||
if (hash === "" || hash.startsWith("#?")) {
|
||||
roomAlias = pathname.substring(1); // Strip the "/"
|
||||
|
||||
// Delete "/room/", if present
|
||||
if (roomAlias.startsWith("room/")) {
|
||||
roomAlias = roomAlias.substring("room/".length);
|
||||
}
|
||||
// Add "#", if not present
|
||||
if (!roomAlias.startsWith("#")) {
|
||||
roomAlias = `#${roomAlias}`;
|
||||
}
|
||||
} else {
|
||||
roomAlias = hash;
|
||||
}
|
||||
|
||||
// Delete "?" and what comes afterwards
|
||||
roomAlias = roomAlias.split("?")[0];
|
||||
|
||||
if (roomAlias.length <= 1) {
|
||||
// Make roomAlias is null, if it only is a "#"
|
||||
roomAlias = null;
|
||||
} else {
|
||||
// Add server part, if not present
|
||||
if (!roomAlias.includes(":")) {
|
||||
roomAlias = `${roomAlias}:${Config.defaultServerName()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This is here as a stopgap, but what would be far nicer is a function that
|
||||
// takes a UrlParams and returns a query string. That would enable us to
|
||||
// consolidate all the data about URL parameters and their meanings to this one
|
||||
// file.
|
||||
export function editFragmentQuery(
|
||||
hash: string,
|
||||
edit: (params: URLSearchParams) => URLSearchParams
|
||||
): string {
|
||||
const fragmentQueryStart = hash.indexOf("?");
|
||||
const fragmentParams = new URLSearchParams(
|
||||
fragmentQueryStart === -1 ? "" : hash.substring(fragmentQueryStart)
|
||||
const fragmentParams = edit(
|
||||
new URLSearchParams(
|
||||
fragmentQueryStart === -1 ? "" : hash.substring(fragmentQueryStart)
|
||||
)
|
||||
);
|
||||
const queryParams = new URLSearchParams(search);
|
||||
return `${hash.substring(
|
||||
0,
|
||||
fragmentQueryStart
|
||||
)}?${fragmentParams.toString()}`;
|
||||
}
|
||||
|
||||
class ParamParser {
|
||||
private fragmentParams: URLSearchParams;
|
||||
private queryParams: URLSearchParams;
|
||||
|
||||
constructor(search: string, hash: string) {
|
||||
this.queryParams = new URLSearchParams(search);
|
||||
|
||||
const fragmentQueryStart = hash.indexOf("?");
|
||||
this.fragmentParams = new URLSearchParams(
|
||||
fragmentQueryStart === -1 ? "" : hash.substring(fragmentQueryStart)
|
||||
);
|
||||
}
|
||||
|
||||
// Normally, URL params should be encoded in the fragment so as to avoid
|
||||
// leaking them to the server. However, we also check the normal query
|
||||
// string for backwards compatibility with versions that only used that.
|
||||
const hasParam = (name: string): boolean =>
|
||||
fragmentParams.has(name) || queryParams.has(name);
|
||||
const getParam = (name: string): string | null =>
|
||||
fragmentParams.get(name) ?? queryParams.get(name);
|
||||
const getAllParams = (name: string): string[] => [
|
||||
...fragmentParams.getAll(name),
|
||||
...queryParams.getAll(name),
|
||||
];
|
||||
getParam(name: string): string | null {
|
||||
return this.fragmentParams.get(name) ?? this.queryParams.get(name);
|
||||
}
|
||||
|
||||
const fontScale = parseFloat(getParam("fontScale") ?? "");
|
||||
getAllParams(name: string): string[] {
|
||||
return [
|
||||
...this.fragmentParams.getAll(name),
|
||||
...this.queryParams.getAll(name),
|
||||
];
|
||||
}
|
||||
|
||||
getFlagParam(name: string, defaultValue = false): boolean {
|
||||
const param = this.getParam(name);
|
||||
return param === null ? defaultValue : param !== "false";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the app parameters for the current URL.
|
||||
* @param search The URL search string
|
||||
* @param hash The URL hash
|
||||
* @returns The app parameters encoded in the URL
|
||||
*/
|
||||
export const getUrlParams = (
|
||||
search = window.location.search,
|
||||
hash = window.location.hash
|
||||
): UrlParams => {
|
||||
const parser = new ParamParser(search, hash);
|
||||
|
||||
const fontScale = parseFloat(parser.getParam("fontScale") ?? "");
|
||||
|
||||
return {
|
||||
// NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl:
|
||||
// what would we do if it were invalid? If the widget API says that's what
|
||||
// the room ID is, then that's what it is.
|
||||
roomId: parser.getParam("roomId"),
|
||||
password: parser.getParam("password"),
|
||||
// This flag has 'embed' as an alias for historical reasons
|
||||
confineToRoom:
|
||||
parser.getFlagParam("confineToRoom") || parser.getFlagParam("embed"),
|
||||
appPrompt: parser.getFlagParam("appPrompt", true),
|
||||
preload: parser.getFlagParam("preload"),
|
||||
hideHeader: parser.getFlagParam("hideHeader"),
|
||||
hideScreensharing: parser.getFlagParam("hideScreensharing"),
|
||||
e2eEnabled: parser.getFlagParam("enableE2e", true),
|
||||
userId: parser.getParam("userId"),
|
||||
displayName: parser.getParam("displayName"),
|
||||
deviceId: parser.getParam("deviceId"),
|
||||
baseUrl: parser.getParam("baseUrl"),
|
||||
lang: parser.getParam("lang"),
|
||||
fonts: parser.getAllParams("font"),
|
||||
fontScale: Number.isNaN(fontScale) ? null : fontScale,
|
||||
analyticsID: parser.getParam("analyticsID"),
|
||||
allowIceFallback: parser.getFlagParam("allowIceFallback"),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to simplify use of getUrlParams.
|
||||
* @returns The app parameters for the current URL
|
||||
*/
|
||||
export const useUrlParams = (): UrlParams => {
|
||||
const { search, hash } = useLocation();
|
||||
return useMemo(() => getUrlParams(search, hash), [search, hash]);
|
||||
};
|
||||
|
||||
export function getRoomIdentifierFromUrl(
|
||||
pathname: string,
|
||||
search: string,
|
||||
hash: string
|
||||
): RoomIdentifier {
|
||||
let roomAlias: string | null = null;
|
||||
|
||||
// Here we handle the beginning of the alias and make sure it starts with a "#"
|
||||
if (hash === "" || hash.startsWith("#?")) {
|
||||
roomAlias = pathname.substring(1); // Strip the "/"
|
||||
|
||||
// Delete "/room/", if present
|
||||
if (roomAlias.startsWith("room/")) {
|
||||
roomAlias = roomAlias.substring("room/".length);
|
||||
}
|
||||
// Add "#", if not present
|
||||
if (!roomAlias.startsWith("#")) {
|
||||
roomAlias = `#${roomAlias}`;
|
||||
}
|
||||
} else {
|
||||
roomAlias = hash;
|
||||
}
|
||||
|
||||
// Delete "?" and what comes afterwards
|
||||
roomAlias = roomAlias.split("?")[0];
|
||||
|
||||
if (roomAlias.length <= 1) {
|
||||
// Make roomAlias is null, if it only is a "#"
|
||||
roomAlias = null;
|
||||
} else {
|
||||
// Add server part, if not present
|
||||
if (!roomAlias.includes(":")) {
|
||||
roomAlias = `${roomAlias}:${Config.defaultServerName()}`;
|
||||
}
|
||||
}
|
||||
|
||||
const parser = new ParamParser(search, hash);
|
||||
|
||||
// Make sure roomId is valid
|
||||
let roomId: string | null = getParam("roomId");
|
||||
let roomId: string | null = parser.getParam("roomId");
|
||||
if (!roomId?.startsWith("!")) {
|
||||
roomId = null;
|
||||
} else if (!roomId.includes("")) {
|
||||
@@ -173,34 +261,14 @@ export const getUrlParams = (
|
||||
return {
|
||||
roomAlias,
|
||||
roomId,
|
||||
password: getParam("password"),
|
||||
viaServers: getAllParams("via"),
|
||||
isEmbedded: hasParam("embed"),
|
||||
preload: hasParam("preload"),
|
||||
hideHeader: hasParam("hideHeader"),
|
||||
hideScreensharing: hasParam("hideScreensharing"),
|
||||
isPtt: hasParam("ptt"),
|
||||
e2eEnabled: getParam("enableE2e") !== "false", // Defaults to true
|
||||
userId: getParam("userId"),
|
||||
displayName: getParam("displayName"),
|
||||
deviceId: getParam("deviceId"),
|
||||
baseUrl: getParam("baseUrl"),
|
||||
lang: getParam("lang"),
|
||||
fonts: getAllParams("font"),
|
||||
fontScale: Number.isNaN(fontScale) ? null : fontScale,
|
||||
analyticsID: getParam("analyticsID"),
|
||||
allowIceFallback: hasParam("allowIceFallback"),
|
||||
viaServers: parser.getAllParams("viaServers"),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to simplify use of getUrlParams.
|
||||
* @returns The app parameters for the current URL
|
||||
*/
|
||||
export const useUrlParams = (): UrlParams => {
|
||||
const { search, pathname, hash } = useLocation();
|
||||
export const useRoomIdentifier = (): RoomIdentifier => {
|
||||
const { pathname, search, hash } = useLocation();
|
||||
return useMemo(
|
||||
() => getUrlParams(false, search, pathname, hash),
|
||||
[search, pathname, hash]
|
||||
() => getRoomIdentifierFromUrl(pathname, search, hash),
|
||||
[pathname, search, hash]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -20,11 +20,12 @@ import { useEnableE2EE } from "../settings/useSetting";
|
||||
import { useLocalStorage } from "../useLocalStorage";
|
||||
import { useClient } from "../ClientContext";
|
||||
import { PASSWORD_STRING, useUrlParams } from "../UrlParams";
|
||||
import { widget } from "../widget";
|
||||
|
||||
export const getRoomSharedKeyLocalStorageKey = (roomId: string): string =>
|
||||
`room-shared-key-${roomId}`;
|
||||
|
||||
export const useInternalRoomSharedKey = (
|
||||
const useInternalRoomSharedKey = (
|
||||
roomId: string
|
||||
): [string | null, (value: string) => void] => {
|
||||
const key = useMemo(() => getRoomSharedKeyLocalStorageKey(roomId), [roomId]);
|
||||
@@ -34,52 +35,62 @@ export const useInternalRoomSharedKey = (
|
||||
return [e2eeEnabled ? roomSharedKey : null, setRoomSharedKey];
|
||||
};
|
||||
|
||||
export const useRoomSharedKey = (roomId: string): string | null => {
|
||||
return useInternalRoomSharedKey(roomId)[0];
|
||||
};
|
||||
|
||||
export const useManageRoomSharedKey = (roomId: string): string | null => {
|
||||
const { password } = useUrlParams();
|
||||
const useKeyFromUrl = (roomId: string): string | null => {
|
||||
const urlParams = useUrlParams();
|
||||
const [e2eeSharedKey, setE2EESharedKey] = useInternalRoomSharedKey(roomId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!password) return;
|
||||
if (password === "") return;
|
||||
if (password === e2eeSharedKey) return;
|
||||
if (!urlParams.password) return;
|
||||
if (urlParams.password === "") return;
|
||||
if (urlParams.password === e2eeSharedKey) return;
|
||||
|
||||
setE2EESharedKey(password);
|
||||
}, [password, e2eeSharedKey, setE2EESharedKey]);
|
||||
setE2EESharedKey(urlParams.password);
|
||||
}, [urlParams, e2eeSharedKey, setE2EESharedKey]);
|
||||
|
||||
return urlParams.password ?? null;
|
||||
};
|
||||
|
||||
export const useRoomSharedKey = (roomId: string): string | null => {
|
||||
// make sure we've extracted the key from the URL first
|
||||
// (and we still need to take the value it returns because
|
||||
// the effect won't run in time for it to save to localstorage in
|
||||
// time for us to read it out again).
|
||||
const passwordFormUrl = useKeyFromUrl(roomId);
|
||||
|
||||
return useInternalRoomSharedKey(roomId)[0] ?? passwordFormUrl;
|
||||
};
|
||||
|
||||
export const useManageRoomSharedKey = (roomId: string): string | null => {
|
||||
const urlParams = useUrlParams();
|
||||
|
||||
const urlPassword = useKeyFromUrl(roomId);
|
||||
|
||||
const [e2eeSharedKey] = useInternalRoomSharedKey(roomId);
|
||||
|
||||
useEffect(() => {
|
||||
const hash = location.hash;
|
||||
|
||||
if (!hash.includes("?")) return;
|
||||
if (!hash.includes(PASSWORD_STRING)) return;
|
||||
if (password !== e2eeSharedKey) return;
|
||||
if (urlParams.password !== e2eeSharedKey) return;
|
||||
|
||||
const [hashStart, passwordStart] = hash.split(PASSWORD_STRING);
|
||||
const hashEnd = passwordStart.split("&")[1];
|
||||
const hashEnd = passwordStart.split("&").slice(1).join("&");
|
||||
|
||||
location.replace((hashStart ?? "") + (hashEnd ?? ""));
|
||||
}, [password, e2eeSharedKey]);
|
||||
}, [urlParams, e2eeSharedKey]);
|
||||
|
||||
return e2eeSharedKey;
|
||||
return e2eeSharedKey ?? urlPassword;
|
||||
};
|
||||
|
||||
export const useIsRoomE2EE = (roomId: string): boolean | null => {
|
||||
const { isEmbedded } = useUrlParams();
|
||||
const client = useClient();
|
||||
const room = useMemo(
|
||||
() => client.client?.getRoom(roomId) ?? null,
|
||||
[roomId, client.client]
|
||||
const { client } = useClient();
|
||||
const room = useMemo(() => client?.getRoom(roomId) ?? null, [roomId, client]);
|
||||
// For now, rooms in widget mode are never considered encrypted.
|
||||
// In the future, when widget mode gains encryption support, then perhaps we
|
||||
// should inspect the e2eEnabled URL parameter here?
|
||||
return useMemo(
|
||||
() => widget === null && (room === null || !room.getCanonicalAlias()),
|
||||
[room]
|
||||
);
|
||||
const isE2EE = useMemo(() => {
|
||||
if (isEmbedded) {
|
||||
return false;
|
||||
} else {
|
||||
return room ? !room?.getCanonicalAlias() : null;
|
||||
}
|
||||
}, [isEmbedded, room]);
|
||||
|
||||
return isE2EE;
|
||||
};
|
||||
|
||||
16
src/graphics/backgroundGradient.svg
Normal file
16
src/graphics/backgroundGradient.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg width="1440" height="800" viewBox="0 0 1440 800" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_f_4162_80259)">
|
||||
<path d="M-37.0486 666.028C267.76 138.867 944.304 -46.1945 1477.05 260.929" stroke="url(#paint0_linear_4162_80259)" stroke-width="192" stroke-linecap="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_4162_80259" x="-333.118" y="-183.694" width="2106.24" height="1145.68" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="100" result="effect1_foregroundBlur_4162_80259"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_4162_80259" x1="1255.31" y1="320.254" x2="184.899" y2="607.497" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00C59E"/>
|
||||
<stop offset="0.75" stop-color="#0044A5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 938 B |
48
src/graphics/loggedOutGradient.svg
Normal file
48
src/graphics/loggedOutGradient.svg
Normal file
@@ -0,0 +1,48 @@
|
||||
<svg width="1440" height="500" viewBox="0 0 1440 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_f_4162_80135)">
|
||||
<circle cx="720" cy="1620" r="1500" fill="url(#paint0_linear_4162_80135)"/>
|
||||
<circle cx="720" cy="1620" r="1498.92" stroke="white" stroke-opacity="0.5" stroke-width="2.16028" style="mix-blend-mode:overlay"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_f_4162_80135)">
|
||||
<circle cx="720" cy="1550.86" r="1272.86" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_4162_80135" x="-900" y="0" width="3240" height="3240" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="60" result="effect1_foregroundBlur_4162_80135"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_4162_80135" x="-672.863" y="158" width="2785.73" height="2785.73" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="60" result="effect1_foregroundBlur_4162_80135"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_4162_80135" x1="549.5" y1="120" x2="549.5" y2="505.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#062993"/>
|
||||
<stop offset="0.040404" stop-color="#02389D"/>
|
||||
<stop offset="0.0808081" stop-color="#0045A6"/>
|
||||
<stop offset="0.121212" stop-color="#0051AD"/>
|
||||
<stop offset="0.161616" stop-color="#005DB4"/>
|
||||
<stop offset="0.20202" stop-color="#0069BA"/>
|
||||
<stop offset="0.242424" stop-color="#0075BB"/>
|
||||
<stop offset="0.282828" stop-color="#0081BB"/>
|
||||
<stop offset="0.323232" stop-color="#008CB9"/>
|
||||
<stop offset="0.363636" stop-color="#0098B7"/>
|
||||
<stop offset="0.40404" stop-color="#00A3B3"/>
|
||||
<stop offset="0.444444" stop-color="#00AEAD"/>
|
||||
<stop offset="0.484848" stop-color="#00B8A4"/>
|
||||
<stop offset="0.525253" stop-color="#00C2A0"/>
|
||||
<stop offset="0.565657" stop-color="#00CC99"/>
|
||||
<stop offset="0.606061" stop-color="#3AD396"/>
|
||||
<stop offset="0.646465" stop-color="#5DD898"/>
|
||||
<stop offset="0.686869" stop-color="#79DD99"/>
|
||||
<stop offset="0.727273" stop-color="#92E29B"/>
|
||||
<stop offset="0.767677" stop-color="#A8E69F"/>
|
||||
<stop offset="0.808081" stop-color="#BBEAA5"/>
|
||||
<stop offset="0.848485" stop-color="#CDEEAE"/>
|
||||
<stop offset="0.888889" stop-color="#DCF2B9"/>
|
||||
<stop offset="0.929293" stop-color="#EAF6C7"/>
|
||||
<stop offset="0.969697" stop-color="#F5FBD5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -17,11 +17,12 @@ limitations under the License.
|
||||
import { Link } from "react-router-dom";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import { CopyButton } from "../button";
|
||||
import { Avatar, Size } from "../Avatar";
|
||||
import styles from "./CallList.module.css";
|
||||
import { getRoomUrl } from "../matrix-utils";
|
||||
import { getAbsoluteRoomUrl, getRelativeRoomUrl } from "../matrix-utils";
|
||||
import { Body } from "../typography/Typography";
|
||||
import { GroupCallRoom } from "./useGroupCallRooms";
|
||||
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
|
||||
@@ -34,13 +35,13 @@ export function CallList({ rooms, client }: CallListProps) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.callList}>
|
||||
{rooms.map(({ room, roomAlias, roomName, avatarUrl, participants }) => (
|
||||
{rooms.map(({ room, roomName, avatarUrl, participants }) => (
|
||||
<CallTile
|
||||
key={roomAlias}
|
||||
key={room.roomId}
|
||||
client={client}
|
||||
name={roomName}
|
||||
avatarUrl={avatarUrl}
|
||||
roomId={room.roomId}
|
||||
room={room}
|
||||
participants={participants}
|
||||
/>
|
||||
))}
|
||||
@@ -57,17 +58,22 @@ export function CallList({ rooms, client }: CallListProps) {
|
||||
interface CallTileProps {
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
roomId: string;
|
||||
room: Room;
|
||||
participants: RoomMember[];
|
||||
client: MatrixClient;
|
||||
}
|
||||
function CallTile({ name, avatarUrl, roomId }: CallTileProps) {
|
||||
const roomSharedKey = useRoomSharedKey(roomId);
|
||||
function CallTile({ name, avatarUrl, room }: CallTileProps) {
|
||||
const roomSharedKey = useRoomSharedKey(room.roomId);
|
||||
|
||||
return (
|
||||
<div className={styles.callTile}>
|
||||
<Link to={`/room/#?roomId=${roomId}`} className={styles.callTileLink}>
|
||||
<Avatar id={roomId} name={name} size={Size.LG} src={avatarUrl} />
|
||||
<Link
|
||||
// note we explicitly omit the password here as we don't want it on this link because
|
||||
// it's just for the user to navigate around and not for sharing
|
||||
to={getRelativeRoomUrl(room.roomId, room.name)}
|
||||
className={styles.callTileLink}
|
||||
>
|
||||
<Avatar id={room.roomId} name={name} size={Size.LG} src={avatarUrl} />
|
||||
<div className={styles.callInfo}>
|
||||
<Body overflowEllipsis fontWeight="semiBold">
|
||||
{name}
|
||||
@@ -78,7 +84,11 @@ function CallTile({ name, avatarUrl, roomId }: CallTileProps) {
|
||||
<CopyButton
|
||||
className={styles.copyButton}
|
||||
variant="icon"
|
||||
value={getRoomUrl(roomId, roomSharedKey ?? undefined)}
|
||||
value={getAbsoluteRoomUrl(
|
||||
room.roomId,
|
||||
room.name,
|
||||
roomSharedKey ?? undefined
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 />
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,9 +19,11 @@ 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,
|
||||
getRelativeRoomUrl,
|
||||
roomAliasLocalpartFromRoomName,
|
||||
sanitiseRoomNameInput,
|
||||
} from "../matrix-utils";
|
||||
@@ -33,11 +35,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 +46,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 +71,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) {
|
||||
@@ -85,7 +87,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
history.push(`/room/#?roomId=${roomId}`);
|
||||
history.push(getRelativeRoomUrl(roomId, roomName));
|
||||
}
|
||||
|
||||
submit().catch((error) => {
|
||||
@@ -93,7 +95,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 +103,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
|
||||
}
|
||||
});
|
||||
},
|
||||
[client, history, modalState, callType, e2eeEnabled]
|
||||
[client, history, setJoinExistingCallModalOpen, e2eeEnabled]
|
||||
);
|
||||
|
||||
const recentRooms = useGroupCallRooms(client);
|
||||
@@ -111,32 +113,29 @@ 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>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<UserMenuContainer />
|
||||
</RightNav>
|
||||
</Header>
|
||||
<div className={commonStyles.container}>
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<UserMenuContainer />
|
||||
</RightNav>
|
||||
</Header>
|
||||
<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 +165,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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -26,16 +27,15 @@ import { FieldRow, InputField, ErrorMessage } from "../input/Input";
|
||||
import { Button } from "../button";
|
||||
import {
|
||||
createRoom,
|
||||
getRelativeRoomUrl,
|
||||
roomAliasLocalpartFromRoomName,
|
||||
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 +48,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 +72,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 +89,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 +113,7 @@ export const UnauthenticatedView: FC = () => {
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
modalState.open();
|
||||
setJoinExistingCallModalOpen(true);
|
||||
return;
|
||||
} else {
|
||||
throw error;
|
||||
@@ -123,7 +126,7 @@ export const UnauthenticatedView: FC = () => {
|
||||
}
|
||||
|
||||
setClient({ client, session });
|
||||
history.push(`/room/#?roomId=${roomId}`);
|
||||
history.push(getRelativeRoomUrl(roomId, roomName));
|
||||
}
|
||||
|
||||
submit().catch((error) => {
|
||||
@@ -138,39 +141,35 @@ 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>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav hideMobile>
|
||||
<UserMenuContainer />
|
||||
</RightNav>
|
||||
</Header>
|
||||
<div className={commonStyles.container}>
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav hideMobile>
|
||||
<UserMenuContainer />
|
||||
</RightNav>
|
||||
</Header>
|
||||
<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 +234,12 @@ export const UnauthenticatedView: FC = () => {
|
||||
</Body>
|
||||
</footer>
|
||||
</div>
|
||||
{modalState.isOpen && onFinished && (
|
||||
<JoinExistingCallModal onJoin={onFinished} {...modalProps} />
|
||||
{onFinished && (
|
||||
<JoinExistingCallModal
|
||||
onJoin={onFinished}
|
||||
open={joinExistingCallModalOpen}
|
||||
onDismiss={onDismissJoinExistingCallModal}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
min-height: calc(100% - 64px);
|
||||
min-height: 100%;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -42,8 +42,4 @@ limitations under the License.
|
||||
.logo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
min-height: calc(100% - 76px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEv
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export interface GroupCallRoom {
|
||||
roomAlias: string;
|
||||
roomAlias?: string;
|
||||
roomName: string;
|
||||
avatarUrl: string;
|
||||
room: Room;
|
||||
@@ -94,7 +94,7 @@ export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
|
||||
const groupCall = client.getGroupCallForRoom(room.roomId)!;
|
||||
|
||||
return {
|
||||
roomAlias: room.getCanonicalAlias(),
|
||||
roomAlias: room.getCanonicalAlias() ?? undefined,
|
||||
roomName: room.name,
|
||||
avatarUrl: room.getMxcAvatarUrl()!,
|
||||
room,
|
||||
@@ -103,7 +103,7 @@ export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {
|
||||
};
|
||||
});
|
||||
|
||||
setRooms(items as GroupCallRoom[]);
|
||||
setRooms(items);
|
||||
}
|
||||
|
||||
updateRooms();
|
||||
|
||||
@@ -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));
|
||||
@@ -51,12 +45,17 @@ limitations under the License.
|
||||
--inline-content-inset: max(var(--cpd-space-4x), calc((100vw - 1180px) / 2));
|
||||
--small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15);
|
||||
--subtle-drop-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);
|
||||
--background-gradient: url("graphics/backgroundGradient.svg");
|
||||
}
|
||||
|
||||
.cpd-theme-dark {
|
||||
--cpd-color-border-accent: var(--cpd-color-green-1100);
|
||||
--stopgap-color-on-solid-accent: var(--cpd-color-text-primary);
|
||||
--stopgap-background-85: rgba(16, 19, 23, 0.85);
|
||||
|
||||
background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh));
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
@@ -144,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;
|
||||
}
|
||||
@@ -152,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 {
|
||||
@@ -160,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,
|
||||
|
||||
@@ -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,
|
||||
@@ -61,7 +62,7 @@ export class Initializer {
|
||||
languageDetector.addDetector({
|
||||
name: "urlFragment",
|
||||
// Look for a language code in the URL's fragment
|
||||
lookup: () => getUrlParams(true).lang ?? undefined,
|
||||
lookup: () => getUrlParams().lang ?? undefined,
|
||||
});
|
||||
|
||||
i18n
|
||||
@@ -94,7 +95,7 @@ export class Initializer {
|
||||
}
|
||||
|
||||
// Custom fonts
|
||||
const { fonts, fontScale } = getUrlParams(true);
|
||||
const { fonts, fontScale } = getUrlParams();
|
||||
if (fontScale !== null) {
|
||||
document.documentElement.style.setProperty(
|
||||
"--font-scale",
|
||||
@@ -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 {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { createMediaDeviceObserver } from "@livekit/components-core";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
isFirefox,
|
||||
useAudioInput,
|
||||
useAudioOutput,
|
||||
useVideoInput,
|
||||
@@ -65,7 +66,8 @@ function useObservableState<T>(
|
||||
function useMediaDevice(
|
||||
kind: MediaDeviceKind,
|
||||
fallbackDevice: string | undefined,
|
||||
usingNames: boolean
|
||||
usingNames: boolean,
|
||||
alwaysDefault: boolean = false
|
||||
): MediaDevice {
|
||||
// Make sure we don't needlessly reset to a device observer without names,
|
||||
// once permissions are already given
|
||||
@@ -86,18 +88,19 @@ function useMediaDevice(
|
||||
const available = useObservableState(deviceObserver, []);
|
||||
const [selectedId, select] = useState(fallbackDevice);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
return useMemo(() => {
|
||||
const devId = available.some((d) => d.deviceId === selectedId)
|
||||
? selectedId
|
||||
: available.some((d) => d.deviceId === fallbackDevice)
|
||||
? fallbackDevice
|
||||
: available.at(0)?.deviceId;
|
||||
|
||||
return {
|
||||
available,
|
||||
selectedId: available.some((d) => d.deviceId === selectedId)
|
||||
? selectedId
|
||||
: available.some((d) => d.deviceId === fallbackDevice)
|
||||
? fallbackDevice
|
||||
: available.at(0)?.deviceId,
|
||||
selectedId: alwaysDefault ? undefined : devId,
|
||||
select,
|
||||
}),
|
||||
[available, selectedId, fallbackDevice, select]
|
||||
);
|
||||
};
|
||||
}, [available, selectedId, fallbackDevice, select, alwaysDefault]);
|
||||
}
|
||||
|
||||
const deviceStub: MediaDevice = {
|
||||
@@ -120,10 +123,19 @@ interface Props {
|
||||
}
|
||||
|
||||
export const MediaDevicesProvider: FC<Props> = ({ children }) => {
|
||||
// Counts the number of callers currently using device names
|
||||
// Counts the number of callers currently using device names.
|
||||
const [numCallersUsingNames, setNumCallersUsingNames] = useState(0);
|
||||
const usingNames = numCallersUsingNames > 0;
|
||||
|
||||
// Setting the audio device to something other than 'undefined' breaks echo-cancellation
|
||||
// and even can introduce multiple different output devices for one call.
|
||||
const alwaysUseDefaultAudio = isFirefox();
|
||||
|
||||
// On FF we dont need to query the names
|
||||
// (call enumerateDevices + create meadia stream to trigger permissions)
|
||||
// for ouput devices because the selector wont be shown on FF.
|
||||
const useOutputNames = usingNames && !isFirefox();
|
||||
|
||||
const [audioInputSetting, setAudioInputSetting] = useAudioInput();
|
||||
const [audioOutputSetting, setAudioOutputSetting] = useAudioOutput();
|
||||
const [videoInputSetting, setVideoInputSetting] = useVideoInput();
|
||||
@@ -136,7 +148,8 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
|
||||
const audioOutput = useMediaDevice(
|
||||
"audiooutput",
|
||||
audioOutputSetting,
|
||||
usingNames
|
||||
useOutputNames,
|
||||
alwaysUseDefaultAudio
|
||||
);
|
||||
const videoInput = useMediaDevice(
|
||||
"videoinput",
|
||||
@@ -150,7 +163,9 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
|
||||
}, [setAudioInputSetting, audioInput.selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioOutput.selectedId !== undefined)
|
||||
// Skip setting state for ff output. Redundent since it is set to always return 'undefined'
|
||||
// but makes it clear while debugging that this is not happening on FF. + perf ;)
|
||||
if (audioOutput.selectedId !== undefined && !isFirefox())
|
||||
setAudioOutputSetting(audioOutput.selectedId);
|
||||
}, [setAudioOutputSetting, audioOutput.selectedId]);
|
||||
|
||||
@@ -200,8 +215,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]);
|
||||
|
||||
@@ -14,7 +14,12 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConnectionState, Room, RoomEvent } from "livekit-client";
|
||||
import {
|
||||
AudioCaptureOptions,
|
||||
ConnectionState,
|
||||
Room,
|
||||
RoomEvent,
|
||||
} from "livekit-client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
@@ -42,7 +47,34 @@ function sfuConfigValid(sfuConfig?: SFUConfig): boolean {
|
||||
return Boolean(sfuConfig?.url) && Boolean(sfuConfig?.jwt);
|
||||
}
|
||||
|
||||
async function doConnect(
|
||||
livekitRoom: Room,
|
||||
sfuConfig: SFUConfig,
|
||||
audioEnabled: boolean,
|
||||
audioOptions: AudioCaptureOptions
|
||||
): Promise<void> {
|
||||
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt);
|
||||
|
||||
// Always create an audio track manually.
|
||||
// livekit (by default) keeps the mic track open when you mute, but if you start muted,
|
||||
// doesn't publish it until you unmute. We want to publish it from the start so we're
|
||||
// always capturing audio: it helps keep bluetooth headsets in the right mode and
|
||||
// mobile browsers to know we're doing a call.
|
||||
const audioTracks = await livekitRoom!.localParticipant.createTracks({
|
||||
audio: audioOptions,
|
||||
});
|
||||
if (audioTracks.length < 1) {
|
||||
logger.info("Tried to pre-create local audio track but got no tracks");
|
||||
return;
|
||||
}
|
||||
if (!audioEnabled) await audioTracks[0].mute();
|
||||
|
||||
await livekitRoom?.localParticipant.publishTrack(audioTracks[0]);
|
||||
}
|
||||
|
||||
export function useECConnectionState(
|
||||
initialAudioOptions: AudioCaptureOptions,
|
||||
initialAudioEnabled: boolean,
|
||||
livekitRoom?: Room,
|
||||
sfuConfig?: SFUConfig
|
||||
): ECConnectionState {
|
||||
@@ -53,6 +85,7 @@ export function useECConnectionState(
|
||||
);
|
||||
|
||||
const [isSwitchingFocus, setSwitchingFocus] = useState(false);
|
||||
const [isInDoConnect, setIsInDoConnect] = useState(false);
|
||||
|
||||
const onConnStateChanged = useCallback((state: ConnectionState) => {
|
||||
if (state == ConnectionState.Connected) setSwitchingFocus(false);
|
||||
@@ -89,12 +122,46 @@ export function useECConnectionState(
|
||||
(async () => {
|
||||
setSwitchingFocus(true);
|
||||
await livekitRoom?.disconnect();
|
||||
await livekitRoom?.connect(sfuConfig!.url, sfuConfig!.jwt);
|
||||
setIsInDoConnect(true);
|
||||
try {
|
||||
await doConnect(
|
||||
livekitRoom!,
|
||||
sfuConfig!,
|
||||
initialAudioEnabled,
|
||||
initialAudioOptions
|
||||
);
|
||||
} finally {
|
||||
setIsInDoConnect(false);
|
||||
}
|
||||
})();
|
||||
} else if (
|
||||
!sfuConfigValid(currentSFUConfig.current) &&
|
||||
sfuConfigValid(sfuConfig)
|
||||
) {
|
||||
// if we're transitioning from an invalid config to a valid one (ie. connecting)
|
||||
// then do an initial connection, including publishing the microphone track:
|
||||
// livekit (by default) keeps the mic track open when you mute, but if you start muted,
|
||||
// doesn't publish it until you unmute. We want to publish it from the start so we're
|
||||
// always capturing audio: it helps keep bluetooth headsets in the right mode and
|
||||
// mobile browsers to know we're doing a call.
|
||||
setIsInDoConnect(true);
|
||||
doConnect(
|
||||
livekitRoom!,
|
||||
sfuConfig!,
|
||||
initialAudioEnabled,
|
||||
initialAudioOptions
|
||||
).finally(() => setIsInDoConnect(false));
|
||||
}
|
||||
|
||||
currentSFUConfig.current = Object.assign({}, sfuConfig);
|
||||
}, [sfuConfig, livekitRoom]);
|
||||
}, [sfuConfig, livekitRoom, initialAudioOptions, initialAudioEnabled]);
|
||||
|
||||
return isSwitchingFocus ? ECAddonConnectionState.ECSwitchingFocus : connState;
|
||||
// Because we create audio tracks by hand, there's more to connecting than
|
||||
// just what LiveKit does in room.connect, and we should continue to return
|
||||
// ConnectionState.Connecting for the entire duration of the doConnect promise
|
||||
return isSwitchingFocus
|
||||
? ECAddonConnectionState.ECSwitchingFocus
|
||||
: isInDoConnect
|
||||
? ConnectionState.Connecting
|
||||
: connState;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
setLogLevel,
|
||||
} from "livekit-client";
|
||||
import { useLiveKitRoom } from "@livekit/components-react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import E2EEWorker from "livekit-client/e2ee-worker?worker";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
@@ -98,6 +98,11 @@ export function useLiveKit(
|
||||
[e2eeOptions]
|
||||
);
|
||||
|
||||
// useECConnectionState creates and publishes an audio track by hand. To keep
|
||||
// this from racing with LiveKit's automatic creation of the audio track, we
|
||||
// block audio from being enabled until the connection is finished.
|
||||
const [blockAudio, setBlockAudio] = useState(true);
|
||||
|
||||
// We have to create the room manually here due to a bug inside
|
||||
// @livekit/components-react. JSON.stringify() is used in deps of a
|
||||
// useEffect() with an argument that references itself, if E2EE is enabled
|
||||
@@ -105,12 +110,25 @@ export function useLiveKit(
|
||||
const { room } = useLiveKitRoom({
|
||||
token: sfuConfig?.jwt,
|
||||
serverUrl: sfuConfig?.url,
|
||||
audio: initialMuteStates.current.audio.enabled,
|
||||
audio: initialMuteStates.current.audio.enabled && !blockAudio,
|
||||
video: initialMuteStates.current.video.enabled,
|
||||
room: roomWithoutProps,
|
||||
connect: false,
|
||||
});
|
||||
|
||||
const connectionState = useECConnectionState(room, sfuConfig);
|
||||
const connectionState = useECConnectionState(
|
||||
{
|
||||
deviceId: initialDevices.current.audioOutput.selectedId,
|
||||
},
|
||||
initialMuteStates.current.audio.enabled,
|
||||
room,
|
||||
sfuConfig
|
||||
);
|
||||
|
||||
// Unblock audio once the connection is finished
|
||||
useEffect(() => {
|
||||
if (connectionState === ConnectionState.Connected) setBlockAudio(false);
|
||||
}, [connectionState, setBlockAudio]);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync the requested mute states with LiveKit's mute states. We do it this
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -343,15 +340,35 @@ export async function createRoom(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a URL to that will load Element Call with the given room
|
||||
* @param roomId of the room
|
||||
* @param password
|
||||
* @returns
|
||||
* Returns an absolute URL to that will load Element Call with the given room
|
||||
* @param roomId ID of the room
|
||||
* @param roomName Name of the room
|
||||
* @param password e2e key for the room
|
||||
*/
|
||||
export function getRoomUrl(roomId: string, password?: string): string {
|
||||
export function getAbsoluteRoomUrl(
|
||||
roomId: string,
|
||||
roomName?: string,
|
||||
password?: string
|
||||
): string {
|
||||
return `${window.location.protocol}//${
|
||||
window.location.host
|
||||
}/room/#?roomId=${roomId}${password ? "&" + PASSWORD_STRING + password : ""}`;
|
||||
}${getRelativeRoomUrl(roomId, roomName, password)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a relative URL to that will load Element Call with the given room
|
||||
* @param roomId ID of the room
|
||||
* @param roomName Name of the room
|
||||
* @param password e2e key for the room
|
||||
*/
|
||||
export function getRelativeRoomUrl(
|
||||
roomId: string,
|
||||
roomName?: string,
|
||||
password?: string
|
||||
): string {
|
||||
return `/room/#${
|
||||
roomName ? "/" + roomAliasLocalpartFromRoomName(roomName) : ""
|
||||
}?roomId=${roomId}${password ? "&" + PASSWORD_STRING + password : ""}`;
|
||||
}
|
||||
|
||||
export function getAvatarUrl(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
91
src/room/AppSelectionModal.tsx
Normal file
91
src/room/AppSelectionModal.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
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 { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { Modal } from "../Modal";
|
||||
import { useIsRoomE2EE, useRoomSharedKey } from "../e2ee/sharedKeyManagement";
|
||||
import { getAbsoluteRoomUrl } 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 roomIsEncrypted = useIsRoomE2EE(roomId ?? "");
|
||||
if (roomIsEncrypted && roomSharedKey === undefined) {
|
||||
logger.error(
|
||||
"Generating app redirect URL for encrypted room but don't have key available!"
|
||||
);
|
||||
}
|
||||
|
||||
const appUrl = useMemo(() => {
|
||||
// If the room ID is not known, fall back to the URL of the current page
|
||||
// Also, we don't really know the room name at this stage as we haven't
|
||||
// started a client and synced to get the room details. We could take the one
|
||||
// we got in our own URL and use that, but it's not a string that a human
|
||||
// ever sees so it's somewhat redundant. We just don't pass a name.
|
||||
const url = new URL(
|
||||
roomId === null
|
||||
? window.location.href
|
||||
: getAbsoluteRoomUrl(roomId, undefined, roomSharedKey ?? undefined)
|
||||
);
|
||||
// Edit the URL to prevent the app selection prompt from appearing a second
|
||||
// time within the app, and to keep the user confined to the current room
|
||||
url.hash = editFragmentQuery(url.hash, (params) => {
|
||||
params.set("appPrompt", "false");
|
||||
params.set("confineToRoom", "true");
|
||||
return params;
|
||||
});
|
||||
|
||||
const result = new URL("io.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>
|
||||
);
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { FormEventHandler, useCallback, useState } from "react";
|
||||
import { FC, FormEventHandler, useCallback, useState } from "react";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
@@ -30,19 +30,23 @@ import { FieldRow, InputField } from "../input/Input";
|
||||
import { StarRatingInput } from "../input/StarRatingInput";
|
||||
import { RageshakeButton } from "../settings/RageshakeButton";
|
||||
|
||||
export function CallEndedView({
|
||||
client,
|
||||
isPasswordlessUser,
|
||||
endedCallId,
|
||||
leaveError,
|
||||
reconnect,
|
||||
}: {
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
isPasswordlessUser: boolean;
|
||||
confineToRoom: boolean;
|
||||
endedCallId: string;
|
||||
leaveError?: Error;
|
||||
reconnect: () => void;
|
||||
}) {
|
||||
}
|
||||
|
||||
export const CallEndedView: FC<Props> = ({
|
||||
client,
|
||||
isPasswordlessUser,
|
||||
confineToRoom,
|
||||
endedCallId,
|
||||
leaveError,
|
||||
reconnect,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
|
||||
@@ -72,14 +76,14 @@ export function CallEndedView({
|
||||
if (isPasswordlessUser) {
|
||||
// setting this renders the callEndedView with the invitation to create an account
|
||||
setSurverySubmitted(true);
|
||||
} else {
|
||||
} else if (!confineToRoom) {
|
||||
// if the user already has an account immediately go back to the home screen
|
||||
history.push("/");
|
||||
}
|
||||
}, 1000);
|
||||
}, 1000);
|
||||
},
|
||||
[endedCallId, history, isPasswordlessUser, starRating]
|
||||
[endedCallId, history, isPasswordlessUser, confineToRoom, starRating]
|
||||
);
|
||||
|
||||
const createAccountDialog = isPasswordlessUser && (
|
||||
@@ -161,11 +165,13 @@ export function CallEndedView({
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Body className={styles.footer}>
|
||||
<Link color="primary" to="/">
|
||||
{t("Return to home screen")}
|
||||
</Link>
|
||||
</Body>
|
||||
{!confineToRoom && (
|
||||
<Body className={styles.footer}>
|
||||
<Link color="primary" to="/">
|
||||
{t("Return to home screen")}
|
||||
</Link>
|
||||
</Body>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
@@ -183,15 +189,18 @@ export function CallEndedView({
|
||||
"\n" +
|
||||
t("How did it go?")}
|
||||
</Headline>
|
||||
{!surveySubmitted && PosthogAnalytics.instance.isEnabled()
|
||||
{(!surveySubmitted || confineToRoom) &&
|
||||
PosthogAnalytics.instance.isEnabled()
|
||||
? qualitySurveyDialog
|
||||
: createAccountDialog}
|
||||
</main>
|
||||
<Body className={styles.footer}>
|
||||
<Link color="primary" to="/">
|
||||
{t("Not now, return to home screen")}
|
||||
</Link>
|
||||
</Body>
|
||||
{!confineToRoom && (
|
||||
<Body className={styles.footer}>
|
||||
<Link color="primary" to="/">
|
||||
{t("Not now, return to home screen")}
|
||||
</Link>
|
||||
</Body>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -200,12 +209,10 @@ export function CallEndedView({
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<LeftNav>{!confineToRoom && <HeaderLogo />}</LeftNav>
|
||||
<RightNav />
|
||||
</Header>
|
||||
<div className={styles.container}>{renderBody()}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,10 +14,13 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode, useCallback } from "react";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { MatrixError } from "matrix-js-sdk";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Heading, Link, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { useLoadGroupCall } from "./useLoadGroupCall";
|
||||
import { ErrorView, FullScreenView } from "../FullScreenView";
|
||||
@@ -27,7 +30,6 @@ interface Props {
|
||||
roomIdOrAlias: string;
|
||||
viaServers: string[];
|
||||
children: (rtcSession: MatrixRTCSession) => ReactNode;
|
||||
createPtt: boolean;
|
||||
}
|
||||
|
||||
export function GroupCallLoader({
|
||||
@@ -35,14 +37,17 @@ 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);
|
||||
|
||||
const history = useHistory();
|
||||
const onHomeClick = useCallback(
|
||||
(ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
history.push("/");
|
||||
},
|
||||
[history]
|
||||
);
|
||||
|
||||
switch (groupCallState.kind) {
|
||||
@@ -55,6 +60,24 @@ export function GroupCallLoader({
|
||||
case "loaded":
|
||||
return <>{children(groupCallState.rtcSession)}</>;
|
||||
case "failed":
|
||||
return <ErrorView error={groupCallState.error} />;
|
||||
if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{t("Call not found")}</Heading>
|
||||
<Text>
|
||||
{t(
|
||||
"Calls are now end-to-end encrypted and need to be created from the home page. This helps make sure everyone's using the same encryption key."
|
||||
)}
|
||||
</Text>
|
||||
{/* XXX: A 'create it for me' button would be the obvious UX here. Two screens already have
|
||||
dupes of this flow, let's make a common component and put it here. */}
|
||||
<Link href="/" onClick={onHomeClick}>
|
||||
{t("Home")}
|
||||
</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else {
|
||||
return <ErrorView error={groupCallState.error} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,12 @@ limitations under the License.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Room } from "livekit-client";
|
||||
import { Room, isE2EESupported } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { JoinRule, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { Heading, Link, Text } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { IWidgetApiRequest } from "matrix-widget-api";
|
||||
import { widget, ElementWidgetActions, JoinCallData } from "../widget";
|
||||
@@ -45,7 +46,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";
|
||||
|
||||
@@ -58,7 +58,7 @@ declare global {
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
isPasswordlessUser: boolean;
|
||||
isEmbedded: boolean;
|
||||
confineToRoom: boolean;
|
||||
preload: boolean;
|
||||
hideHeader: boolean;
|
||||
rtcSession: MatrixRTCSession;
|
||||
@@ -67,7 +67,7 @@ interface Props {
|
||||
export function GroupCallView({
|
||||
client,
|
||||
isPasswordlessUser,
|
||||
isEmbedded,
|
||||
confineToRoom,
|
||||
preload,
|
||||
hideHeader,
|
||||
rtcSession,
|
||||
@@ -78,8 +78,6 @@ export function GroupCallView({
|
||||
const e2eeSharedKey = useManageRoomSharedKey(rtcSession.room.roomId);
|
||||
const isRoomE2EE = useIsRoomE2EE(rtcSession.room.roomId);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
window.rtcSession = rtcSession;
|
||||
return () => {
|
||||
@@ -208,17 +206,6 @@ export function GroupCallView({
|
||||
}
|
||||
}, [rtcSession, preload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmbedded && !preload) {
|
||||
// In embedded mode, bypass the lobby and just enter the call straight away
|
||||
enterRTCSession(rtcSession);
|
||||
|
||||
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
|
||||
// use the room ID as above
|
||||
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
|
||||
}
|
||||
}, [rtcSession, isEmbedded, preload]);
|
||||
|
||||
const [left, setLeft] = useState(false);
|
||||
const [leaveError, setLeaveError] = useState<Error | undefined>(undefined);
|
||||
const history = useHistory();
|
||||
@@ -239,7 +226,8 @@ export function GroupCallView({
|
||||
|
||||
leaveRTCSession(rtcSession);
|
||||
if (widget) {
|
||||
// we need to wait until the callEnded event is tracked. Otherwise the iFrame gets killed before the callEnded event got tracked.
|
||||
// we need to wait until the callEnded event is tracked on posthog.
|
||||
// Otherwise the iFrame gets killed before the callEnded event got tracked.
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 10)); // 10ms
|
||||
widget.api.setAlwaysOnScreen(false);
|
||||
PosthogAnalytics.instance.logout();
|
||||
@@ -248,13 +236,13 @@ export function GroupCallView({
|
||||
|
||||
if (
|
||||
!isPasswordlessUser &&
|
||||
!isEmbedded &&
|
||||
!confineToRoom &&
|
||||
!PosthogAnalytics.instance.isEnabled()
|
||||
) {
|
||||
history.push("/");
|
||||
}
|
||||
},
|
||||
[rtcSession, isPasswordlessUser, isEmbedded, history]
|
||||
[rtcSession, isPasswordlessUser, confineToRoom, history]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -286,15 +274,28 @@ 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;
|
||||
|
||||
const onHomeClick = useCallback(
|
||||
(ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
history.push("/");
|
||||
},
|
||||
[history]
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (e2eeEnabled && isRoomE2EE && !e2eeSharedKey) {
|
||||
return (
|
||||
<ErrorView
|
||||
@@ -305,14 +306,30 @@ export function GroupCallView({
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!e2eeEnabled && isRoomE2EE) {
|
||||
} else if (!isE2EESupported() && isRoomE2EE) {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>Incompatible Browser</Heading>
|
||||
<Text>
|
||||
{t(
|
||||
"Your web browser does not support media end-to-end encryption. Supported Browsers are Chrome, Safari, Firefox >=117"
|
||||
)}
|
||||
</Text>
|
||||
<Link href="/" onClick={onHomeClick}>
|
||||
{t("Home")}
|
||||
</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else if (!e2eeEnabled && isRoomE2EE) {
|
||||
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
|
||||
room={rtcSession.room}
|
||||
open={shareModalOpen}
|
||||
onDismiss={onDismissShareModal}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isJoined) {
|
||||
@@ -342,7 +359,7 @@ export function GroupCallView({
|
||||
// submitting anything.
|
||||
if (
|
||||
isPasswordlessUser ||
|
||||
(PosthogAnalytics.instance.isEnabled() && !isEmbedded) ||
|
||||
(PosthogAnalytics.instance.isEnabled() && widget === null) ||
|
||||
leaveError
|
||||
) {
|
||||
return (
|
||||
@@ -350,6 +367,7 @@ export function GroupCallView({
|
||||
endedCallId={rtcSession.room.roomId}
|
||||
client={client}
|
||||
isPasswordlessUser={isPasswordlessUser}
|
||||
confineToRoom={confineToRoom}
|
||||
leaveError={leaveError}
|
||||
reconnect={onReconnect}
|
||||
/>
|
||||
@@ -362,12 +380,6 @@ export function GroupCallView({
|
||||
}
|
||||
} else if (preload) {
|
||||
return null;
|
||||
} else if (isEmbedded) {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>{t("Loading…")}</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
@@ -377,7 +389,7 @@ export function GroupCallView({
|
||||
matrixInfo={matrixInfo}
|
||||
muteStates={muteStates}
|
||||
onEnter={() => enterRTCSession(rtcSession)}
|
||||
isEmbedded={isEmbedded}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={hideHeader}
|
||||
participatingMembers={participatingMembers}
|
||||
onShareClick={onShareClick}
|
||||
|
||||
@@ -21,7 +21,7 @@ limitations under the License.
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
--footerPadding: 8px;
|
||||
--footerPadding: var(--cpd-space-4x);
|
||||
--footerHeight: calc(50px + 2 * var(--footerPadding));
|
||||
}
|
||||
|
||||
@@ -83,17 +83,19 @@ limitations under the License.
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
@media (min-height: 300px) {
|
||||
@media (min-height: 400px) {
|
||||
.inRoom {
|
||||
--footerPadding: 40px;
|
||||
--footerPadding: var(--cpd-space-10x);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-height: 800px) {
|
||||
.inRoom {
|
||||
--footerPadding: var(--cpd-space-15x);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.inRoom {
|
||||
--footerPadding: 60px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
gap: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
@@ -27,10 +27,9 @@ import { ConnectionState, Room, Track } from "livekit-client";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { Room as MatrixRoom } from "matrix-js-sdk/src/models/room";
|
||||
import { Ref, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
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";
|
||||
@@ -73,7 +71,10 @@ import { MuteStates } from "./MuteStates";
|
||||
import { MatrixInfo } from "./VideoPreview";
|
||||
import { ShareButton } from "../button/ShareButton";
|
||||
import { LayoutToggle } from "./LayoutToggle";
|
||||
import { ECConnectionState } from "../livekit/useECConnectionState";
|
||||
import {
|
||||
ECAddonConnectionState,
|
||||
ECConnectionState,
|
||||
} from "../livekit/useECConnectionState";
|
||||
import { useOpenIDSFU } from "../livekit/openIDSFU";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
@@ -82,6 +83,9 @@ const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
// For now we can disable screensharing in Safari.
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
|
||||
// How long we wait after a focus switch before showing the real participant list again
|
||||
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
|
||||
|
||||
export interface ActiveCallProps
|
||||
extends Omit<InCallViewProps, "livekitRoom" | "connState"> {
|
||||
e2eeConfig?: E2EEConfig;
|
||||
@@ -236,7 +240,7 @@ export function InCallView({
|
||||
const reducedControls = boundsValid && bounds.width <= 340;
|
||||
const noControls = reducedControls && bounds.height <= 400;
|
||||
|
||||
const items = useParticipantTiles(livekitRoom, rtcSession.room);
|
||||
const items = useParticipantTiles(livekitRoom, rtcSession.room, connState);
|
||||
const { fullscreenItem, toggleFullscreen, exitFullscreen } =
|
||||
useFullscreen(items);
|
||||
|
||||
@@ -307,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 +441,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>
|
||||
);
|
||||
}
|
||||
@@ -482,8 +475,11 @@ function findMatrixMember(
|
||||
|
||||
function useParticipantTiles(
|
||||
livekitRoom: Room,
|
||||
matrixRoom: MatrixRoom
|
||||
matrixRoom: MatrixRoom,
|
||||
connState: ECConnectionState
|
||||
): TileDescriptor<ItemData>[] {
|
||||
const previousTiles = useRef<TileDescriptor<ItemData>[]>([]);
|
||||
|
||||
const sfuParticipants = useParticipants({
|
||||
room: livekitRoom,
|
||||
});
|
||||
@@ -565,5 +561,44 @@ function useParticipantTiles(
|
||||
return allGhosts ? [] : tiles;
|
||||
}, [matrixRoom, sfuParticipants]);
|
||||
|
||||
return items;
|
||||
// We carry over old tiles from the previous focus for some time after a focus switch
|
||||
// so that the video tiles don't all disappear and reappear.
|
||||
// This is set to true when the state transitions to Switching Focus and remains
|
||||
// true for a short time after it changes (ie. connState is only switching focus for
|
||||
// the time it takes us to reconnect to the conference).
|
||||
// If there are still members that haven't reconnected after that time, they'll just
|
||||
// appear to disconnect and will reappear once they reconnect.
|
||||
const [isSwitchingFocus, setIsSwitchingFocus] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (connState === ECAddonConnectionState.ECSwitchingFocus) {
|
||||
setIsSwitchingFocus(true);
|
||||
} else if (isSwitchingFocus) {
|
||||
setTimeout(() => {
|
||||
setIsSwitchingFocus(false);
|
||||
}, POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS);
|
||||
}
|
||||
}, [connState, setIsSwitchingFocus, isSwitchingFocus]);
|
||||
|
||||
if (
|
||||
connState === ECAddonConnectionState.ECSwitchingFocus ||
|
||||
isSwitchingFocus
|
||||
) {
|
||||
logger.debug("Switching focus: injecting previous tiles");
|
||||
|
||||
// inject the previous tile for members that haven't rejoined yet
|
||||
const newItems = items.slice(0);
|
||||
const rejoined = new Set(newItems.map((p) => p.id));
|
||||
|
||||
for (const prevTile of previousTiles.current) {
|
||||
if (!rejoined.has(prevTile.id)) {
|
||||
newItems.push(prevTile);
|
||||
}
|
||||
}
|
||||
|
||||
return newItems;
|
||||
} else {
|
||||
previousTiles.current = items;
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ limitations under the License.
|
||||
|
||||
.toggle input {
|
||||
appearance: none;
|
||||
/*
|
||||
* Safari puts a margin on these, which is not removed via appearance: none
|
||||
* mobile safari also has them take up space in the DOM, so set width 0
|
||||
*/
|
||||
margin: 0;
|
||||
width: 0;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,61 +14,26 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.room {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.joinRoom {
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--cpd-space-6x);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
padding-block-end: var(--footerHeight);
|
||||
}
|
||||
|
||||
.joinRoomContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
@media (max-width: 500px) {
|
||||
.join {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.joinRoomFooter {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.homeLink {
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
.joinCallButton {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
max-width: 222px;
|
||||
height: 40px;
|
||||
bottom: 86px;
|
||||
left: 50%;
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-body);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
width: 320px !important;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.copyButton:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.passwordField {
|
||||
width: 320px !important;
|
||||
margin-bottom: 20px;
|
||||
flex: 0;
|
||||
@media (min-height: 650px) {
|
||||
.content {
|
||||
gap: var(--cpd-space-10x);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,27 +14,35 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, FC } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { FC, useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { Button, Link } from "@vector-im/compound-web";
|
||||
import classNames from "classnames";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
import styles from "./LobbyView.module.css";
|
||||
import { Button, CopyButton } from "../button";
|
||||
import inCallStyles from "./InCallView.module.css";
|
||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||
import { getRoomUrl } from "../matrix-utils";
|
||||
import { Body, Link } from "../typography/Typography";
|
||||
import { useLocationNavigation } from "../useLocationNavigation";
|
||||
import { MatrixInfo, VideoPreview } from "./VideoPreview";
|
||||
import { MuteStates } from "./MuteStates";
|
||||
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
|
||||
import { ShareButton } from "../button/ShareButton";
|
||||
import {
|
||||
HangupButton,
|
||||
MicButton,
|
||||
SettingsButton,
|
||||
VideoButton,
|
||||
} from "../button/Button";
|
||||
import { SettingsModal } from "../settings/SettingsModal";
|
||||
import { useMediaQuery } from "../useMediaQuery";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
matrixInfo: MatrixInfo;
|
||||
muteStates: MuteStates;
|
||||
onEnter: () => void;
|
||||
isEmbedded: boolean;
|
||||
confineToRoom: boolean;
|
||||
hideHeader: boolean;
|
||||
participatingMembers: RoomMember[];
|
||||
onShareClick: (() => void) | null;
|
||||
@@ -45,74 +53,104 @@ export const LobbyView: FC<Props> = ({
|
||||
matrixInfo,
|
||||
muteStates,
|
||||
onEnter,
|
||||
isEmbedded,
|
||||
confineToRoom,
|
||||
hideHeader,
|
||||
participatingMembers,
|
||||
onShareClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const roomSharedKey = useRoomSharedKey(matrixInfo.roomId);
|
||||
useLocationNavigation();
|
||||
|
||||
const joinCallButtonRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
if (joinCallButtonRef.current) {
|
||||
joinCallButtonRef.current.focus();
|
||||
}
|
||||
}, [joinCallButtonRef]);
|
||||
const onAudioPress = useCallback(
|
||||
() => muteStates.audio.setEnabled?.((e) => !e),
|
||||
[muteStates]
|
||||
);
|
||||
const onVideoPress = useCallback(
|
||||
() => muteStates.video.setEnabled?.((e) => !e),
|
||||
[muteStates]
|
||||
);
|
||||
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
|
||||
const openSettings = useCallback(
|
||||
() => setSettingsModalOpen(true),
|
||||
[setSettingsModalOpen]
|
||||
);
|
||||
const closeSettings = useCallback(
|
||||
() => setSettingsModalOpen(false),
|
||||
[setSettingsModalOpen]
|
||||
);
|
||||
|
||||
const history = useHistory();
|
||||
const onLeaveClick = useCallback(() => history.push("/"), [history]);
|
||||
|
||||
const recentsButtonInFooter = useMediaQuery("(max-height: 500px)");
|
||||
const recentsButton = !confineToRoom && (
|
||||
<Link className={styles.recents} href="#" onClick={onLeaveClick}>
|
||||
{t("Back to recents")}
|
||||
</Link>
|
||||
);
|
||||
|
||||
// TODO: Unify this component with InCallView, so we can get slick joining
|
||||
// animations and don't have to feel bad about reusing its CSS
|
||||
return (
|
||||
<div className={styles.room}>
|
||||
{!hideHeader && (
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo
|
||||
id={matrixInfo.roomId}
|
||||
name={matrixInfo.roomName}
|
||||
avatarUrl={matrixInfo.roomAvatar}
|
||||
encrypted={matrixInfo.roomEncrypted}
|
||||
participants={participatingMembers}
|
||||
client={client}
|
||||
/>
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
{onShareClick !== null && <ShareButton onClick={onShareClick} />}
|
||||
</RightNav>
|
||||
</Header>
|
||||
)}
|
||||
<div className={styles.joinRoom}>
|
||||
<div className={styles.joinRoomContent}>
|
||||
<VideoPreview matrixInfo={matrixInfo} muteStates={muteStates} />
|
||||
<Trans>
|
||||
<>
|
||||
<div className={classNames(styles.room, inCallStyles.inRoom)}>
|
||||
{!hideHeader && (
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo
|
||||
id={matrixInfo.roomId}
|
||||
name={matrixInfo.roomName}
|
||||
avatarUrl={matrixInfo.roomAvatar}
|
||||
encrypted={matrixInfo.roomEncrypted}
|
||||
participants={participatingMembers}
|
||||
client={client}
|
||||
/>
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
{onShareClick !== null && <ShareButton onClick={onShareClick} />}
|
||||
</RightNav>
|
||||
</Header>
|
||||
)}
|
||||
<div className={styles.content}>
|
||||
<VideoPreview matrixInfo={matrixInfo} muteStates={muteStates}>
|
||||
<Button
|
||||
ref={joinCallButtonRef}
|
||||
className={styles.copyButton}
|
||||
className={styles.join}
|
||||
size="lg"
|
||||
onPress={() => onEnter()}
|
||||
onClick={onEnter}
|
||||
data-testid="lobby_joinCall"
|
||||
>
|
||||
Join call now
|
||||
{t("Join call")}
|
||||
</Button>
|
||||
<Body>Or</Body>
|
||||
<CopyButton
|
||||
variant="secondaryCopy"
|
||||
value={getRoomUrl(matrixInfo.roomId, roomSharedKey ?? undefined)}
|
||||
className={styles.copyButton}
|
||||
copiedMessage={t("Call link copied")}
|
||||
data-testid="lobby_inviteLink"
|
||||
>
|
||||
Copy call link and join later
|
||||
</CopyButton>
|
||||
</Trans>
|
||||
</VideoPreview>
|
||||
{!recentsButtonInFooter && recentsButton}
|
||||
</div>
|
||||
<div className={inCallStyles.footer}>
|
||||
{recentsButtonInFooter && recentsButton}
|
||||
<div className={inCallStyles.buttons}>
|
||||
<VideoButton
|
||||
muted={!muteStates.video.enabled}
|
||||
onPress={onVideoPress}
|
||||
disabled={muteStates.video.setEnabled === null}
|
||||
/>
|
||||
<MicButton
|
||||
muted={!muteStates.audio.enabled}
|
||||
onPress={onAudioPress}
|
||||
disabled={muteStates.audio.setEnabled === null}
|
||||
/>
|
||||
<SettingsButton onPress={openSettings} />
|
||||
{!confineToRoom && <HangupButton onPress={onLeaveClick} />}
|
||||
</div>
|
||||
</div>
|
||||
{!isEmbedded && (
|
||||
<Body className={styles.joinRoomFooter}>
|
||||
<Link color="primary" to="/">
|
||||
{t("Take me Home")}
|
||||
</Link>
|
||||
</Body>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{client && (
|
||||
<SettingsModal
|
||||
client={client}
|
||||
open={settingsModalOpen}
|
||||
onDismiss={closeSettings}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
@@ -22,22 +22,19 @@ import { ErrorView, LoadingView } from "../FullScreenView";
|
||||
import { RoomAuthView } from "./RoomAuthView";
|
||||
import { GroupCallLoader } from "./GroupCallLoader";
|
||||
import { GroupCallView } from "./GroupCallView";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
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 {
|
||||
roomAlias,
|
||||
roomId,
|
||||
viaServers,
|
||||
isEmbedded,
|
||||
preload,
|
||||
hideHeader,
|
||||
isPtt,
|
||||
displayName,
|
||||
} = useUrlParams();
|
||||
const { confineToRoom, appPrompt, preload, hideHeader, displayName } =
|
||||
useUrlParams();
|
||||
|
||||
const { roomAlias, roomId, viaServers } = useRoomIdentifier();
|
||||
|
||||
const roomIdOrAlias = roomId ?? roomAlias;
|
||||
if (!roomIdOrAlias) {
|
||||
console.error("No room specified");
|
||||
@@ -78,38 +75,43 @@ export const RoomPage: FC = () => {
|
||||
client={client!}
|
||||
rtcSession={rtcSession}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
isEmbedded={isEmbedded}
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
hideHeader={hideHeader}
|
||||
/>
|
||||
),
|
||||
[client, passwordlessUser, isEmbedded, preload, hideHeader]
|
||||
[client, passwordlessUser, confineToRoom, 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 Android and iOS, show a prompt to launch the mobile app. */}
|
||||
{appPrompt && (platform === "android" || platform === "ios") && (
|
||||
<AppSelectionModal roomId={roomId} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -16,36 +16,36 @@ limitations under the License.
|
||||
|
||||
import { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Room } from "matrix-js-sdk";
|
||||
|
||||
import { Modal, ModalContent, ModalProps } from "../Modal";
|
||||
import { Modal } from "../Modal";
|
||||
import { CopyButton } from "../button";
|
||||
import { getRoomUrl } from "../matrix-utils";
|
||||
import { getAbsoluteRoomUrl } from "../matrix-utils";
|
||||
import styles from "./ShareModal.module.css";
|
||||
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
interface Props extends Omit<ModalProps, "title" | "children"> {
|
||||
roomId: string;
|
||||
interface Props {
|
||||
room: Room;
|
||||
open: boolean;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export const ShareModal: FC<Props> = ({ roomId, ...rest }) => {
|
||||
export const ShareModal: FC<Props> = ({ room, open, onDismiss }) => {
|
||||
const { t } = useTranslation();
|
||||
const roomSharedKey = useRoomSharedKey(roomId);
|
||||
const roomSharedKey = useRoomSharedKey(room.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={getAbsoluteRoomUrl(
|
||||
room.roomId,
|
||||
room.name,
|
||||
roomSharedKey ?? undefined
|
||||
)}
|
||||
data-testid="modal_inviteLink"
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,21 +15,29 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
.preview {
|
||||
position: relative;
|
||||
min-height: 280px;
|
||||
height: 50vh;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
background-color: var(--stopgap-bgColor3);
|
||||
margin: 20px;
|
||||
margin-inline: var(--inline-content-inset);
|
||||
min-block-size: 0;
|
||||
block-size: 50vh;
|
||||
}
|
||||
|
||||
.preview video {
|
||||
.preview.content {
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-fit: cover;
|
||||
background-color: black;
|
||||
transform: scaleX(-1);
|
||||
background-color: var(--cpd-color-bg-subtle-primary);
|
||||
}
|
||||
|
||||
.avatarContainer {
|
||||
@@ -41,40 +49,32 @@ limitations under the License.
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--stopgap-bgColor3);
|
||||
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||
}
|
||||
|
||||
.cameraPermissions {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.previewButtons {
|
||||
.buttonBar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 66px;
|
||||
height: calc(30 * var(--cpd-space-1x));
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--stopgap-background-85);
|
||||
gap: var(--cpd-space-4x);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
var(--cpd-color-bg-canvas-default) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.previewButtons > * {
|
||||
margin-right: 30px;
|
||||
.preview.content .buttonBar {
|
||||
padding-inline: var(--inline-content-inset);
|
||||
}
|
||||
|
||||
.previewButtons > :last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.preview {
|
||||
margin-top: 40px;
|
||||
@media (min-aspect-ratio: 1 / 1) {
|
||||
.preview video {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,25 +14,23 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useMemo, useRef, FC } from "react";
|
||||
import { useEffect, useMemo, useRef, FC, ReactNode } 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,
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import classNames from "classnames";
|
||||
|
||||
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";
|
||||
import { MuteStates } from "./MuteStates";
|
||||
import { Glass } from "../Glass";
|
||||
import { useMediaQuery } from "../useMediaQuery";
|
||||
|
||||
export type MatrixInfo = {
|
||||
userId: string;
|
||||
@@ -48,27 +46,16 @@ export type MatrixInfo = {
|
||||
interface Props {
|
||||
matrixInfo: MatrixInfo;
|
||||
muteStates: MuteStates;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const VideoPreview: FC<Props> = ({ matrixInfo, muteStates }) => {
|
||||
const { client } = useClient();
|
||||
export const VideoPreview: FC<Props> = ({
|
||||
matrixInfo,
|
||||
muteStates,
|
||||
children,
|
||||
}) => {
|
||||
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
|
||||
|
||||
const {
|
||||
modalState: settingsModalState,
|
||||
modalProps: settingsModalProps,
|
||||
}: {
|
||||
modalState: OverlayTriggerState;
|
||||
modalProps: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
} = useModalTriggerState();
|
||||
|
||||
const openSettings = useCallback(() => {
|
||||
settingsModalState.open();
|
||||
}, [settingsModalState]);
|
||||
|
||||
const devices = useMediaDevices();
|
||||
|
||||
// Capture the audio options as they were when we first mounted, because
|
||||
@@ -116,54 +103,35 @@ export const VideoPreview: FC<Props> = ({ matrixInfo, muteStates }) => {
|
||||
};
|
||||
}, [videoTrack]);
|
||||
|
||||
const onAudioPress = useCallback(
|
||||
() => muteStates.audio.setEnabled?.((e) => !e),
|
||||
[muteStates]
|
||||
);
|
||||
const onVideoPress = useCallback(
|
||||
() => muteStates.video.setEnabled?.((e) => !e),
|
||||
[muteStates]
|
||||
const content = (
|
||||
<>
|
||||
<video data-testid="preview_video" ref={videoEl} muted playsInline disablePictureInPicture />
|
||||
{!muteStates.video.enabled && (
|
||||
<div className={styles.avatarContainer}>
|
||||
<Avatar
|
||||
id={matrixInfo.userId}
|
||||
name={matrixInfo.displayName}
|
||||
size={Math.min(previewBounds.width, previewBounds.height) / 2}
|
||||
src={matrixInfo.avatarUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.buttonBar}>{children}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.preview} ref={previewRef}>
|
||||
<video
|
||||
data-testid="preview_video"
|
||||
ref={videoEl}
|
||||
muted
|
||||
playsInline
|
||||
disablePictureInPicture
|
||||
/>
|
||||
<>
|
||||
{!muteStates.video.enabled && (
|
||||
<div className={styles.avatarContainer}>
|
||||
<Avatar
|
||||
id={matrixInfo.userId}
|
||||
name={matrixInfo.displayName}
|
||||
size={(previewBounds.height - 66) / 2}
|
||||
src={matrixInfo.avatarUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.previewButtons}>
|
||||
<VideoButton
|
||||
muted={!muteStates.video.enabled}
|
||||
onPress={onVideoPress}
|
||||
disabled={muteStates.video.setEnabled === null}
|
||||
data-testid="preview_videomute"
|
||||
/>
|
||||
<MicButton
|
||||
muted={!muteStates.audio.enabled}
|
||||
onPress={onAudioPress}
|
||||
disabled={muteStates.audio.setEnabled === null}
|
||||
data-testid="preview_mute"
|
||||
/>
|
||||
<SettingsButton onPress={openSettings} />
|
||||
</div>
|
||||
</>
|
||||
{settingsModalState.isOpen && client && (
|
||||
<SettingsModal client={client} {...settingsModalProps} />
|
||||
)}
|
||||
return useMediaQuery("(max-width: 550px)") ? (
|
||||
<div
|
||||
className={classNames(styles.preview, styles.content)}
|
||||
ref={previewRef}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
) : (
|
||||
<Glass className={styles.preview}>
|
||||
<div className={styles.content} ref={previewRef}>
|
||||
{content}
|
||||
</div>
|
||||
</Glass>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,14 +20,10 @@ import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { SyncState } from "matrix-js-sdk/src/sync";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
|
||||
import type { Room } from "matrix-js-sdk/src/models/room";
|
||||
import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import { setLocalStorageItem } from "../useLocalStorage";
|
||||
import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
|
||||
import { useEnableE2EE } from "../settings/useSetting";
|
||||
import { getRoomSharedKeyLocalStorageKey } from "../e2ee/sharedKeyManagement";
|
||||
|
||||
export type GroupCallLoaded = {
|
||||
kind: "loaded";
|
||||
@@ -56,8 +52,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" });
|
||||
@@ -66,59 +61,42 @@ export const useLoadGroupCall = (
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOrCreateRoom = async (): Promise<Room> => {
|
||||
try {
|
||||
let room: Room | null = null;
|
||||
if (roomIdOrAlias[0] === "#") {
|
||||
// We lowercase the localpart when we create the room, so we must lowercase
|
||||
// it here too (we just do the whole alias). We can't do the same to room IDs
|
||||
// though.
|
||||
const sanitisedIdOrAlias =
|
||||
roomIdOrAlias[0] === "#"
|
||||
? roomIdOrAlias.toLowerCase()
|
||||
: roomIdOrAlias;
|
||||
|
||||
const room = await client.joinRoom(sanitisedIdOrAlias, {
|
||||
viaServers,
|
||||
});
|
||||
logger.info(
|
||||
`Joined ${sanitisedIdOrAlias}, waiting room to be ready for group calls`
|
||||
// Also, we explicitly look up the room alias here. We previously just tried to
|
||||
// join anyway but the js-sdk recreates the room if you pass the alias for a
|
||||
// room you're already joined to (which it probably ought not to).
|
||||
const lookupResult = await client.getRoomIdForAlias(
|
||||
roomIdOrAlias.toLowerCase()
|
||||
);
|
||||
await client.waitUntilRoomReadyForGroupCalls(room.roomId);
|
||||
logger.info(`${sanitisedIdOrAlias}, is ready for group calls`);
|
||||
return room;
|
||||
} catch (error) {
|
||||
if (
|
||||
isLocalRoomId(roomIdOrAlias, client) &&
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
(error.errcode === "M_NOT_FOUND" ||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
(error.message &&
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
error.message.indexOf("Failed to fetch alias") !== -1))
|
||||
) {
|
||||
// The room doesn't exist, but we can create it
|
||||
const [, roomId] = await createRoom(
|
||||
client,
|
||||
roomNameFromRoomId(roomIdOrAlias),
|
||||
createPtt,
|
||||
e2eeEnabled ?? false
|
||||
);
|
||||
|
||||
if (e2eeEnabled) {
|
||||
setLocalStorageItem(
|
||||
getRoomSharedKeyLocalStorageKey(roomId),
|
||||
randomString(32)
|
||||
);
|
||||
}
|
||||
|
||||
// likewise, wait for the room
|
||||
await client.waitUntilRoomReadyForGroupCalls(roomId);
|
||||
return client.getRoom(roomId)!;
|
||||
logger.info(`${roomIdOrAlias} resolved to ${lookupResult.room_id}`);
|
||||
room = client.getRoom(lookupResult.room_id);
|
||||
if (!room) {
|
||||
logger.info(`Room ${lookupResult.room_id} not found, joining.`);
|
||||
room = await client.joinRoom(lookupResult.room_id, {
|
||||
viaServers: lookupResult.servers,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
logger.info(
|
||||
`Already in room ${lookupResult.room_id}, not rejoining.`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// room IDs we just try to join by their ID, which will not work in the
|
||||
// general case without providing some servers to join via. We could provide
|
||||
// our own server, but in practice that is implicit.
|
||||
room = await client.joinRoom(roomIdOrAlias);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Joined ${roomIdOrAlias}, waiting room to be ready for group calls`
|
||||
);
|
||||
await client.waitUntilRoomReadyForGroupCalls(room.roomId);
|
||||
logger.info(`${roomIdOrAlias}, is ready for group calls`);
|
||||
return room;
|
||||
};
|
||||
|
||||
const fetchOrCreateGroupCall = async (): Promise<MatrixRTCSession> => {
|
||||
@@ -151,7 +129,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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
useDeveloperSettingsTab,
|
||||
useShowConnectionStats,
|
||||
useEnableE2EE,
|
||||
isFirefox,
|
||||
} from "./useSetting";
|
||||
import { FieldRow, InputField } from "../input/Input";
|
||||
import { Button } from "../button";
|
||||
@@ -43,26 +44,24 @@ import { Body, Caption } from "../typography/Typography";
|
||||
import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
|
||||
import { ProfileSettingsTab } from "./ProfileSettingsTab";
|
||||
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import {
|
||||
useMediaDevices,
|
||||
MediaDevice,
|
||||
useMediaDeviceNames,
|
||||
} from "../livekit/MediaDevicesContext";
|
||||
import { widget } from "../widget";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
open: boolean;
|
||||
onDismiss: () => void;
|
||||
client: MatrixClient;
|
||||
roomId?: string;
|
||||
defaultTab?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const SettingsModal = (props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { isEmbedded } = useUrlParams();
|
||||
|
||||
const [showInspector, setShowInspector] = useShowInspector();
|
||||
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
|
||||
const [developerSettingsTab, setDeveloperSettingsTab] =
|
||||
@@ -119,7 +118,7 @@ export const SettingsModal = (props: Props) => {
|
||||
);
|
||||
|
||||
const devices = useMediaDevices();
|
||||
useMediaDeviceNames(devices);
|
||||
useMediaDeviceNames(devices, props.open);
|
||||
|
||||
const audioTab = (
|
||||
<TabItem
|
||||
@@ -132,7 +131,8 @@ export const SettingsModal = (props: Props) => {
|
||||
}
|
||||
>
|
||||
{generateDeviceSelection(devices.audioInput, t("Microphone"))}
|
||||
{generateDeviceSelection(devices.audioOutput, t("Speaker"))}
|
||||
{!isFirefox() &&
|
||||
generateDeviceSelection(devices.audioOutput, t("Speaker"))}
|
||||
</TabItem>
|
||||
);
|
||||
|
||||
@@ -282,17 +282,16 @@ export const SettingsModal = (props: Props) => {
|
||||
);
|
||||
|
||||
const tabs = [audioTab, videoTab];
|
||||
if (!isEmbedded) tabs.push(profileTab);
|
||||
if (widget === null) tabs.push(profileTab);
|
||||
tabs.push(feedbackTab, moreTab);
|
||||
if (developerSettingsTab) tabs.push(developerTab);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("Settings")}
|
||||
isDismissable
|
||||
mobileFullScreen
|
||||
className={styles.settingsModal}
|
||||
{...props}
|
||||
open={props.open}
|
||||
onDismiss={props.onDismiss}
|
||||
>
|
||||
<TabContainer
|
||||
onSelectionChange={onSelectedTabChanged}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,8 +58,12 @@ export const getSetting = <T>(name: string, defaultValue: T): T => {
|
||||
export const setSetting = <T>(name: string, newValue: T) =>
|
||||
setLocalStorageItem(getSettingKey(name), JSON.stringify(newValue));
|
||||
|
||||
const canEnableSpatialAudio = () => {
|
||||
export const isFirefox = () => {
|
||||
const { userAgent } = navigator;
|
||||
return userAgent.includes("Firefox");
|
||||
};
|
||||
|
||||
const canEnableSpatialAudio = () => {
|
||||
// Spatial audio means routing audio through audio contexts. On Chrome,
|
||||
// this bypasses the AEC processor and so breaks echo cancellation.
|
||||
// We only allow spatial audio to be enabled on Firefox which we know
|
||||
@@ -69,7 +73,7 @@ const canEnableSpatialAudio = () => {
|
||||
// widely enough, we can allow spatial audio everywhere. It's currently in a
|
||||
// chrome flag, so we could enable this in Electron if we enabled the chrome flag
|
||||
// in the Electron wrapper.
|
||||
return userAgent.includes("Firefox");
|
||||
return isFirefox();
|
||||
};
|
||||
|
||||
export const useSpatialAudio = (): DisableableSetting<boolean> => {
|
||||
|
||||
@@ -15,8 +15,8 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
.tabContainer {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ limitations under the License.
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background-color: #444;
|
||||
background-color: var(--cpd-color-bg-subtle-primary);
|
||||
}
|
||||
|
||||
.videoTile.isLocal:not(.screenshare) video {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -167,19 +177,20 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
|
||||
{toolbarButtons.length > 0 && (!maximised || fullscreen) && (
|
||||
<div className={classNames(styles.toolbar)}>{toolbarButtons}</div>
|
||||
)}
|
||||
{content === TileContent.UserMedia && !sfuParticipant.isCameraEnabled && (
|
||||
<>
|
||||
<div className={styles.videoMutedOverlay} />
|
||||
<Avatar
|
||||
key={member?.userId}
|
||||
id={member?.userId ?? displayName}
|
||||
name={displayName}
|
||||
size={Math.round(Math.min(targetWidth, targetHeight) / 2)}
|
||||
src={member?.getMxcAvatarUrl()}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{content === TileContent.UserMedia &&
|
||||
!sfuParticipant.isCameraEnabled && (
|
||||
<>
|
||||
<div className={styles.videoMutedOverlay} />
|
||||
<Avatar
|
||||
key={member?.userId}
|
||||
id={member?.userId ?? displayName}
|
||||
name={displayName}
|
||||
size={Math.round(Math.min(targetWidth, targetHeight) / 2)}
|
||||
src={member?.getMxcAvatarUrl()}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{content === TileContent.ScreenShare ? (
|
||||
<div className={styles.presenterLabel}>
|
||||
<span>{t("{{displayName}} is presenting", { displayName })}</span>
|
||||
@@ -210,10 +221,11 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
|
||||
: Track.Source.ScreenShare
|
||||
}
|
||||
/>
|
||||
{videoTileSettingsModalState.isOpen && !maximised && (
|
||||
{!maximised && (
|
||||
<VideoTileSettingsModal
|
||||
{...videoTileSettingsModalProps}
|
||||
data={data}
|
||||
open={videoTileSettingsModalOpen}
|
||||
onDismiss={closeVideoTileSettingsModal}
|
||||
/>
|
||||
)}
|
||||
</animated.div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -109,7 +109,7 @@ export const widget: WidgetHelpers | null = (() => {
|
||||
baseUrl,
|
||||
e2eEnabled,
|
||||
allowIceFallback,
|
||||
} = getUrlParams(true);
|
||||
} = getUrlParams();
|
||||
if (!roomId) throw new Error("Room ID must be supplied");
|
||||
if (!userId) throw new Error("User ID must be supplied");
|
||||
if (!deviceId) throw new Error("Device ID must be supplied");
|
||||
|
||||
Reference in New Issue
Block a user