Merge remote-tracking branch 'origin/livekit' into michaelk/various_testid_tags

This commit is contained in:
Michael Kaye
2023-09-25 13:21:44 +01:00
91 changed files with 4158 additions and 3436 deletions

View File

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

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

52
src/Glass.tsx Normal file
View File

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

View File

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

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2022 - 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,77 +14,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);
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022 New Vector Ltd
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,123 +14,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
View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import { FC, useCallback, useState, FormEventHandler } from "react";
import { useHistory } from "react-router-dom";
import { randomString } from "matrix-js-sdk/src/randomstring";
import { Trans, useTranslation } from "react-i18next";
import { Heading } from "@vector-im/compound-web";
import { useClient } from "../ClientContext";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
@@ -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}
/>
)}
</>
);

View File

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

View File

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

View File

@@ -25,12 +25,6 @@ limitations under the License.
@import "@vector-im/compound-web/dist/style.css";
:root {
--font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
"Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
"Helvetica Neue", sans-serif;
--inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f,
U+25c2-2664, U+2666-2763, U+2765-2b05, U+2b07-2b1b, U+2b1d-10FFFF;
--font-scale: 1;
--font-size-micro: calc(10px * var(--font-scale));
--font-size-caption: calc(12px * var(--font-scale));
@@ -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,

View File

@@ -24,6 +24,7 @@ import * as Sentry from "@sentry/react";
import { getUrlParams } from "./UrlParams";
import { Config } from "./config/Config";
import { ElementCallOpenTelemetry } from "./otel/otel";
import { platform } from "./Platform";
enum LoadState {
None,
@@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { FC, useEffect, useState, useCallback } from "react";
import { FC, useEffect, useState, useCallback, ReactNode } from "react";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { useClientLegacy } from "../ClientContext";
@@ -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} />
)}
</>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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