diff --git a/package.json b/package.json index acc6068d..7cfd7337 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,9 @@ "@opentelemetry/instrumentation-document-load": "^0.33.0", "@opentelemetry/instrumentation-user-interaction": "^0.33.0", "@opentelemetry/sdk-trace-web": "^1.9.1", + "@radix-ui/react-dialog": "^1.0.4", + "@radix-ui/react-visually-hidden": "^1.0.3", "@react-aria/button": "^3.3.4", - "@react-aria/dialog": "^3.1.4", "@react-aria/focus": "^3.5.0", "@react-aria/menu": "^3.3.0", "@react-aria/overlays": "^3.7.3", @@ -40,7 +41,6 @@ "@react-aria/utils": "^3.10.0", "@react-spring/web": "^9.4.4", "@react-stately/collections": "^3.3.4", - "@react-stately/overlays": "^3.1.3", "@react-stately/select": "^3.1.3", "@react-stately/tooltip": "^3.0.5", "@react-stately/tree": "^3.2.0", @@ -52,7 +52,6 @@ "@vitejs/plugin-basic-ssl": "^1.0.1", "@vitejs/plugin-react": "^4.0.1", "classnames": "^2.3.1", - "color-hash": "^2.0.1", "events": "^3.3.0", "i18next": "^21.10.0", "i18next-browser-languagedetector": "^6.1.8", @@ -77,11 +76,13 @@ "sdp-transform": "^2.14.1", "tinyqueue": "^2.0.3", "unique-names-generator": "^4.6.0", - "uuid": "9" + "uuid": "9", + "vaul": "^0.6.1" }, "devDependencies": { "@babel/core": "^7.16.5", "@react-spring/rafz": "^9.7.3", + "@react-types/dialog": "^3.5.5", "@sentry/vite-plugin": "^0.3.0", "@storybook/react": "^6.5.0-alpha.5", "@testing-library/jest-dom": "^5.16.5", diff --git a/src/Glass.module.css b/src/Glass.module.css new file mode 100644 index 00000000..cdb9621a --- /dev/null +++ b/src/Glass.module.css @@ -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); +} diff --git a/src/Glass.tsx b/src/Glass.tsx new file mode 100644 index 00000000..5f01d47a --- /dev/null +++ b/src/Glass.tsx @@ -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( + ({ frosted = false, children, className, ...rest }, ref) => ( +
+ {Children.only(children)} +
+ ) +); diff --git a/src/Modal.module.css b/src/Modal.module.css index 19c2ce72..12d8853c 100644 --- a/src/Modal.module.css +++ b/src/Modal.module.css @@ -1,5 +1,5 @@ /* -Copyright 2022 New Vector Ltd +Copyright 2022 - 2023 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,77 +14,204 @@ See the License for the specific language governing permissions and limitations under the License. */ -.modalOverlay { +.overlay { position: fixed; z-index: 100; - top: 0; - left: 0; - bottom: 0; - right: 0; - background: rgba(23, 25, 28, 0.5); - display: flex; - align-items: center; - justify-content: center; + inset: 0; + background: rgba(3, 12, 27, 0.528); +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.dialogOverlay[data-state="open"] { + animation: fade-in 200ms; +} + +@keyframes fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +.dialogOverlay[data-state="closed"] { + animation: fade-out 130ms; } .modal { - background: var(--cpd-color-bg-subtle-secondary); - box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.15); - border-radius: 8px; - max-width: 90vw; - width: 600px; + position: fixed; + z-index: 101; display: flex; flex-direction: column; } -.modalHeader { - display: flex; - justify-content: space-between; - padding: 34px 32px 0 32px; +.dialog { + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + box-sizing: border-box; + inline-size: 520px; + max-inline-size: 90%; + max-block-size: 600px; } -.modalHeader h3 { - font-weight: 600; - font-size: var(--font-size-title); - margin: 0; +@keyframes zoom-in { + from { + opacity: 0; + transform: translate(-50%, -50%) scale(80%); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(100%); + } } -.closeButton { - position: relative; - display: flex; - justify-content: center; - align-items: center; - background-color: transparent; - padding: 0; - border: none; - cursor: pointer; +@keyframes zoom-out { + from { + opacity: 1; + transform: translate(-50%, -50%) scale(100%); + } + to { + opacity: 0; + transform: translate(-50%, -50%) scale(80%); + } +} + +.dialog[data-state="open"] { + animation: zoom-in 200ms; +} + +.dialog[data-state="closed"] { + animation: zoom-out 130ms; +} + +@media (prefers-reduced-motion) { + .dialog[data-state="open"] { + animation-name: fade-in; + } + + .dialog[data-state="closed"] { + animation-name: fade-out; + } } .content { - padding: 24px 32px; + display: flex; + flex-direction: column; + overflow: hidden; } -.content p { - margin-top: 0; +.dialog .content { + flex-grow: 1; + background: var(--cpd-color-bg-canvas-default); } -@media (max-width: 799px) { - .modalHeader { - display: flex; - justify-content: space-between; - padding: 32px 20px 0 20px; - } +.drawer .content { + overflow: auto; +} - .modal.mobileFullScreen { - position: fixed; - left: 0; - right: 0; - top: 0; - bottom: 0; - width: 100%; - height: 100%; - max-width: none; - max-height: none; - border-radius: 0; +.drawer { + background: var(--cpd-color-bg-canvas-default); + inset-block-end: 0; + inset-inline: max(0px, calc((100% - 520px) / 2)); + max-block-size: 90%; + border-start-start-radius: 20px; + border-start-end-radius: 20px; + /* Drawer handle comes in the Android style by default */ + --handle-block-size: 4px; + --handle-inline-size: 32px; + --handle-inset-block-start: var(--cpd-space-4x); + --handle-inset-block-end: var(--cpd-space-4x); +} + +body[data-platform="ios"] .drawer { + --handle-block-size: 5px; + --handle-inline-size: 36px; + --handle-inset-block-start: var(--cpd-space-1-5x); + --handle-inset-block-end: calc(var(--cpd-space-1x) / 4); +} + +.close { + cursor: pointer; + color: var(--cpd-color-icon-secondary); + border-radius: var(--cpd-radius-pill-effect); + padding: var(--cpd-space-1x); + background: var(--cpd-color-bg-subtle-secondary); + border: none; +} + +.close svg { + display: block; +} + +@media (hover: hover) { + .close:hover { + background: var(--cpd-color-bg-subtle-primary); + color: var(--cpd-color-icon-primary); } } + +.close:active { + background: var(--cpd-color-bg-subtle-primary); + color: var(--cpd-color-icon-primary); +} + +.header { + background: var(--cpd-color-bg-subtle-secondary); + display: grid; +} + +.dialog .header { + padding-block-start: var(--cpd-space-4x); + grid-template-columns: + var(--cpd-space-10x) 1fr minmax(var(--cpd-space-6x), auto) + var(--cpd-space-4x); + grid-template-rows: auto minmax(var(--cpd-space-4x), auto); + /* TODO: Support tabs */ + grid-template-areas: ". title close ." "tabs tabs tabs tabs"; + align-items: center; +} + +.dialog .header h2 { + grid-area: title; + margin: 0; +} + +.drawer .header { + grid-template-areas: "tabs"; + position: relative; +} + +.close { + grid-area: close; +} + +.dialog .body { + padding-inline: var(--cpd-space-10x); + padding-block: var(--cpd-space-10x) var(--cpd-space-12x); + overflow: auto; +} + +.drawer .body { + padding-inline: var(--cpd-space-4x); + padding-block: var(--cpd-space-9x) var(--cpd-space-10x); +} + +.handle { + content: ""; + position: absolute; + block-size: var(--handle-block-size); + inset-inline: calc((100% - var(--handle-inline-size)) / 2); + inset-block-start: var(--handle-inset-block-start); + background: var(--cpd-color-icon-secondary); + border-radius: var(--cpd-radius-pill-effect); +} diff --git a/src/Modal.tsx b/src/Modal.tsx index 56db481e..ea8aff70 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -1,5 +1,5 @@ /* -Copyright 2022 New Vector Ltd +Copyright 2023 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,123 +14,128 @@ See the License for the specific language governing permissions and limitations under the License. */ -/* eslint-disable jsx-a11y/no-autofocus */ - -import { useRef, useMemo, ReactNode } from "react"; -import { - useOverlay, - usePreventScroll, - useModal, - OverlayContainer, - OverlayProps, -} from "@react-aria/overlays"; -import { - OverlayTriggerState, - useOverlayTriggerState, -} from "@react-stately/overlays"; -import { useDialog } from "@react-aria/dialog"; -import { FocusScope } from "@react-aria/focus"; -import { useButton } from "@react-aria/button"; -import classNames from "classnames"; +import { ReactNode, useCallback } from "react"; import { AriaDialogProps } from "@react-types/dialog"; import { useTranslation } from "react-i18next"; +import { + Root as DialogRoot, + Portal as DialogPortal, + Overlay as DialogOverlay, + Content as DialogContent, + Title as DialogTitle, + Close as DialogClose, +} from "@radix-ui/react-dialog"; +import { Drawer } from "vaul"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; +import { ReactComponent as CloseIcon } from "@vector-im/compound-design-tokens/icons/close.svg"; +import classNames from "classnames"; +import { Heading } from "@vector-im/compound-web"; -import { ReactComponent as CloseIcon } from "./icons/Close.svg"; -import styles from "./Modal.module.css"; +import styles from "./NewModal.module.css"; +import { useMediaQuery } from "./useMediaQuery"; +import { Glass } from "./Glass"; -export interface ModalProps extends OverlayProps, AriaDialogProps { +// TODO: Support tabs +export interface ModalProps extends AriaDialogProps { title: string; children: ReactNode; className?: string; - mobileFullScreen?: boolean; - onClose: () => void; + /** + * The controlled open state of the modal. + */ + // An option to leave the open state uncontrolled is intentionally not + // provided, since modals are always opened due to external triggers, and it + // is the author's belief that controlled components lead to more obvious code. + open: boolean; + /** + * Callback for when the user dismisses the modal. If undefined, the modal + * will be non-dismissable. + */ + onDismiss?: () => void; } +/** + * A modal, taking the form of a drawer / bottom sheet on touchscreen devices, + * and a dialog box on desktop. + */ export function Modal({ title, children, className, - mobileFullScreen, - onClose, + open, + onDismiss, ...rest }: ModalProps) { const { t } = useTranslation(); - const modalRef = useRef(null); - const { overlayProps, underlayProps } = useOverlay( - { ...rest, onClose }, - modalRef - ); - usePreventScroll(); - const { modalProps } = useModal(); - const { dialogProps, titleProps } = useDialog(rest, modalRef); - const closeButtonRef = useRef(null); - const { buttonProps: closeButtonProps } = useButton( - { - onPress: () => onClose(), + const touchscreen = useMediaQuery("(hover: none)"); + const onOpenChange = useCallback( + (open: boolean) => { + if (!open) onDismiss?.(); }, - closeButtonRef + [onDismiss] ); - return ( - -
- -
+ + + -
-

{title}

- +
+
+
+ + {title} + +
+
{children}
- {children} -
- -
- - ); -} - -interface ModalContentProps { - children: ReactNode; - className?: string; -} - -export function ModalContent({ - children, - className, - ...rest -}: ModalContentProps) { - return ( -
- {children} -
- ); -} - -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 }; +
+
+ + ); + } else { + return ( + + + + + +
+
+ + + {title} + + + {onDismiss !== undefined && ( + + + + )} +
+
{children}
+
+
+
+
+
+ ); + } } diff --git a/src/Platform.ts b/src/Platform.ts new file mode 100644 index 00000000..0e5b71f1 --- /dev/null +++ b/src/Platform.ts @@ -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"; +} diff --git a/src/UserMenuContainer.tsx b/src/UserMenuContainer.tsx index a03e5b5a..359f75f6 100644 --- a/src/UserMenuContainer.tsx +++ b/src/UserMenuContainer.tsx @@ -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(); @@ -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 && ( )} diff --git a/src/home/JoinExistingCallModal.tsx b/src/home/JoinExistingCallModal.tsx index dfa6b095..35672295 100644 --- a/src/home/JoinExistingCallModal.tsx +++ b/src/home/JoinExistingCallModal.tsx @@ -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 - [index: string]: unknown; } -export function JoinExistingCallModal({ onJoin, onClose, ...rest }: Props) { + +export function JoinExistingCallModal({ onJoin, open, onDismiss }: Props) { const { t } = useTranslation(); return ( - - -

{t("This call already exists, would you like to join?")}

- - - - -
+ +

{t("This call already exists, would you like to join?")}

+ + + +
); } diff --git a/src/home/RegisteredView.tsx b/src/home/RegisteredView.tsx index 87c0a417..921ee0da 100644 --- a/src/home/RegisteredView.tsx +++ b/src/home/RegisteredView.tsx @@ -34,7 +34,6 @@ 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 } from "../typography/Typography"; import { Form } from "../form/Form"; @@ -54,7 +53,12 @@ export function RegisteredView({ client }: Props) { 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 = useCallback( @@ -90,7 +94,7 @@ export function RegisteredView({ client }: Props) { setExistingAlias(roomAliasLocalpartFromRoomName(roomName)); setLoading(false); setError(undefined); - modalState.open(); + setJoinExistingCallModalOpen(true); } else { console.error(error); setLoading(false); @@ -98,7 +102,7 @@ export function RegisteredView({ client }: Props) { } }); }, - [client, history, modalState, e2eeEnabled] + [client, history, setJoinExistingCallModalOpen, e2eeEnabled] ); const recentRooms = useGroupCallRooms(client); @@ -164,9 +168,11 @@ export function RegisteredView({ client }: Props) { )}
- {modalState.isOpen && ( - - )} + ); } diff --git a/src/home/UnauthenticatedView.tsx b/src/home/UnauthenticatedView.tsx index fb3a3445..82839ff7 100644 --- a/src/home/UnauthenticatedView.tsx +++ b/src/home/UnauthenticatedView.tsx @@ -31,7 +31,6 @@ import { sanitiseRoomNameInput, } from "../matrix-utils"; import { useInteractiveRegistration } from "../auth/useInteractiveRegistration"; -import { useModalTriggerState } from "../Modal"; import { JoinExistingCallModal } from "./JoinExistingCallModal"; import { useRecaptcha } from "../auth/useRecaptcha"; import { Body, Caption, Link } from "../typography/Typography"; @@ -54,7 +53,12 @@ export const UnauthenticatedView: FC = () => { 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(); @@ -108,7 +112,7 @@ export const UnauthenticatedView: FC = () => { }); setLoading(false); - modalState.open(); + setJoinExistingCallModalOpen(true); return; } else { throw error; @@ -131,7 +135,15 @@ export const UnauthenticatedView: FC = () => { reset(); }); }, - [register, reset, execute, history, modalState, setClient, e2eeEnabled] + [ + register, + reset, + execute, + history, + setJoinExistingCallModalOpen, + setClient, + e2eeEnabled, + ] ); return ( @@ -221,8 +233,12 @@ export const UnauthenticatedView: FC = () => {
- {modalState.isOpen && onFinished && ( - + {onFinished && ( + )} ); diff --git a/src/index.css b/src/index.css index c1e9fe1a..f3540994 100644 --- a/src/index.css +++ b/src/index.css @@ -25,12 +25,6 @@ limitations under the License. @import "@vector-im/compound-web/dist/style.css"; :root { - --font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", - "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", - "Helvetica Neue", sans-serif; - --inter-unicode-range: U+0000-20e2, U+20e4-23ce, U+23d0-24c1, U+24c3-259f, - U+25c2-2664, U+2666-2763, U+2765-2b05, U+2b07-2b1b, U+2b1d-10FFFF; - --font-scale: 1; --font-size-micro: calc(10px * var(--font-scale)); --font-size-caption: calc(12px * var(--font-scale)); @@ -149,7 +143,6 @@ body { color: var(--cpd-color-text-primary); color-scheme: dark; margin: 0; - font-family: var(--font-family); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } @@ -157,7 +150,9 @@ body { html, body, #root { - height: 100%; + /* We use !important here to override vaul drawers, which have a side effect + of setting height: auto; on the body element and messing up our layouts */ + height: 100% !important; } #root { @@ -165,6 +160,21 @@ body, flex-direction: column; } +/* On Android and iOS, prefer native system fonts. The global.css file of +Compound Web is where these variables ultimately get consumed to set the page's +font-family. */ +body[data-platform="android"] { + --cpd-font-family-sans: "Roboto", "Noto", "Inter", sans-serif; +} + +body[data-platform="ios"] { + --cpd-font-family-sans: -apple-system, BlinkMacSystemFont, "Inter", sans-serif; +} + +body[data-platform="desktop"] { + --cpd-font-family-sans: "Inter", sans-serif; +} + h1, h2, h3, diff --git a/src/initializer.tsx b/src/initializer.tsx index 8e282c37..f4fc99e0 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -24,6 +24,7 @@ import * as Sentry from "@sentry/react"; import { getUrlParams } from "./UrlParams"; import { Config } from "./config/Config"; import { ElementCallOpenTelemetry } from "./otel/otel"; +import { platform } from "./Platform"; enum LoadState { None, @@ -107,6 +108,9 @@ export class Initializer { fonts.map((f) => `"${f}"`).join(", ") ); } + + // Add the platform to the DOM, so CSS can query it + document.body.setAttribute("data-platform", platform); } public static init(): Promise | null { diff --git a/src/livekit/MediaDevicesContext.tsx b/src/livekit/MediaDevicesContext.tsx index c99eada8..b109d3b9 100644 --- a/src/livekit/MediaDevicesContext.tsx +++ b/src/livekit/MediaDevicesContext.tsx @@ -200,8 +200,10 @@ export const useMediaDevices = () => useContext(MediaDevicesContext); * default because it may involve requesting additional permissions from the * user. */ -export const useMediaDeviceNames = (context: MediaDevices) => +export const useMediaDeviceNames = (context: MediaDevices, enabled = true) => useEffect(() => { - context.startUsingDeviceNames(); - return context.stopUsingDeviceNames; - }, [context]); + if (enabled) { + context.startUsingDeviceNames(); + return context.stopUsingDeviceNames; + } + }, [context, enabled]); diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index ac0ae17d..e7807bda 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -45,7 +45,6 @@ import { import { useEnableE2EE } from "../settings/useSetting"; import { useRoomAvatar } from "./useRoomAvatar"; import { useRoomName } from "./useRoomName"; -import { useModalTriggerState } from "../Modal"; import { useJoinRule } from "./useJoinRule"; import { ShareModal } from "./ShareModal"; @@ -286,12 +285,15 @@ export function GroupCallView({ const joinRule = useJoinRule(rtcSession.room); - const { modalState: shareModalState, modalProps: shareModalProps } = - useModalTriggerState(); + const [shareModalOpen, setShareModalOpen] = useState(false); + const onDismissShareModal = useCallback( + () => setShareModalOpen(false), + [setShareModalOpen] + ); const onShareClickFn = useCallback( - () => shareModalState.open(), - [shareModalState] + () => setShareModalOpen(true), + [setShareModalOpen] ); const onShareClick = joinRule === JoinRule.Public ? onShareClickFn : null; @@ -311,8 +313,12 @@ export function GroupCallView({ return ; } - const shareModal = shareModalState.isOpen && ( - + const shareModal = ( + ); if (isJoined) { diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 8e396841..707eb5d8 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -30,7 +30,6 @@ import { Room as MatrixRoom } from "matrix-js-sdk/src/models/room"; import { Ref, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import useMeasure from "react-use-measure"; -import { OverlayTriggerState } from "@react-stately/overlays"; import { logger } from "matrix-js-sdk/src/logger"; import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; @@ -51,7 +50,6 @@ import { VideoGrid, } from "../video-grid/VideoGrid"; import { useShowConnectionStats } from "../settings/useSetting"; -import { useModalTriggerState } from "../Modal"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; import { useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; @@ -313,25 +311,20 @@ export function InCallView({ ); }; - const { - modalState: rageshakeRequestModalState, - modalProps: rageshakeRequestModalProps, - } = useRageshakeRequestModal(rtcSession.room.roomId); + const rageshakeRequestModalProps = useRageshakeRequestModal( + rtcSession.room.roomId + ); - const { - modalState: settingsModalState, - modalProps: settingsModalProps, - }: { - modalState: OverlayTriggerState; - modalProps: { - isOpen: boolean; - onClose: () => void; - }; - } = useModalTriggerState(); + const [settingsModalOpen, setSettingsModalOpen] = useState(false); - const openSettings = useCallback(() => { - settingsModalState.open(); - }, [settingsModalState]); + const openSettings = useCallback( + () => setSettingsModalOpen(true), + [setSettingsModalOpen] + ); + const closeSettings = useCallback( + () => setSettingsModalOpen(false), + [setSettingsModalOpen] + ); const toggleScreensharing = useCallback(async () => { exitFullscreen(); @@ -442,19 +435,13 @@ export function InCallView({ show={showInspector} /> )*/} - {rageshakeRequestModalState.isOpen && !noControls && ( - - )} - {settingsModalState.isOpen && ( - - )} + {!noControls && } + ); } diff --git a/src/room/RageshakeRequestModal.tsx b/src/room/RageshakeRequestModal.tsx index 479ff280..ed9acbcb 100644 --- a/src/room/RageshakeRequestModal.tsx +++ b/src/room/RageshakeRequestModal.tsx @@ -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 { rageshakeRequestId: string; roomId: string; - onClose: () => void; + open: boolean; + onDismiss: () => void; } export const RageshakeRequestModal: FC = ({ 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 ( - - - - {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." - )} - - - - - {error && ( - - - + + + {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." )} - + + + + + {error && ( + + + + )} ); }; diff --git a/src/room/ShareModal.module.css b/src/room/ShareModal.module.css index 75ae26a9..dec0c304 100644 --- a/src/room/ShareModal.module.css +++ b/src/room/ShareModal.module.css @@ -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%; } diff --git a/src/room/ShareModal.tsx b/src/room/ShareModal.tsx index 520ab855..b9adc8e0 100644 --- a/src/room/ShareModal.tsx +++ b/src/room/ShareModal.tsx @@ -17,35 +17,30 @@ limitations under the License. import { FC } from "react"; import { useTranslation } from "react-i18next"; -import { Modal, ModalContent, ModalProps } from "../Modal"; +import { Modal } from "../Modal"; import { CopyButton } from "../button"; import { getRoomUrl } from "../matrix-utils"; import styles from "./ShareModal.module.css"; import { useRoomSharedKey } from "../e2ee/sharedKeyManagement"; -interface Props extends Omit { +interface Props { roomId: string; + open: boolean; + onDismiss: () => void; } -export const ShareModal: FC = ({ roomId, ...rest }) => { +export const ShareModal: FC = ({ roomId, open, onDismiss }) => { const { t } = useTranslation(); const roomSharedKey = useRoomSharedKey(roomId); return ( - - -

{t("Copy and share this call link")}

- -
+ +

{t("Copy and share this call link")}

+
); }; diff --git a/src/room/VideoPreview.tsx b/src/room/VideoPreview.tsx index 37e997dc..5385ee73 100644 --- a/src/room/VideoPreview.tsx +++ b/src/room/VideoPreview.tsx @@ -14,10 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useEffect, useCallback, useMemo, useRef, FC } from "react"; +import { useEffect, useCallback, useMemo, useRef, FC, useState } from "react"; import useMeasure from "react-use-measure"; import { ResizeObserver } from "@juggle/resize-observer"; -import { OverlayTriggerState } from "@react-stately/overlays"; import { usePreviewTracks } from "@livekit/components-react"; import { CreateLocalTracksOptions, @@ -28,7 +27,6 @@ import { import { MicButton, SettingsButton, VideoButton } from "../button"; import { Avatar } from "../Avatar"; import styles from "./VideoPreview.module.css"; -import { useModalTriggerState } from "../Modal"; import { SettingsModal } from "../settings/SettingsModal"; import { useClient } from "../ClientContext"; import { useMediaDevices } from "../livekit/MediaDevicesContext"; @@ -54,20 +52,16 @@ export const VideoPreview: FC = ({ matrixInfo, muteStates }) => { const { client } = useClient(); const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver }); - const { - modalState: settingsModalState, - modalProps: settingsModalProps, - }: { - modalState: OverlayTriggerState; - modalProps: { - isOpen: boolean; - onClose: () => void; - }; - } = useModalTriggerState(); + const [settingsModalOpen, setSettingsModalOpen] = useState(false); - const openSettings = useCallback(() => { - settingsModalState.open(); - }, [settingsModalState]); + const openSettings = useCallback( + () => setSettingsModalOpen(true), + [setSettingsModalOpen] + ); + const closeSettings = useCallback( + () => setSettingsModalOpen(false), + [setSettingsModalOpen] + ); const devices = useMediaDevices(); @@ -153,8 +147,12 @@ export const VideoPreview: FC = ({ matrixInfo, muteStates }) => { - {settingsModalState.isOpen && client && ( - + {client && ( + )} ); diff --git a/src/room/usePageUnload.ts b/src/room/usePageUnload.ts index a6db816d..bae58d27 100644 --- a/src/room/usePageUnload.ts +++ b/src/room/usePageUnload.ts @@ -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 diff --git a/src/settings/SettingsModal.module.css b/src/settings/SettingsModal.module.css index 692c48ae..d7b06f5a 100644 --- a/src/settings/SettingsModal.module.css +++ b/src/settings/SettingsModal.module.css @@ -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; } diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index 0d4983ed..39c3789a 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -51,11 +51,11 @@ import { } from "../livekit/MediaDevicesContext"; interface Props { - isOpen: boolean; + open: boolean; + onDismiss: () => void; client: MatrixClient; roomId?: string; defaultTab?: string; - onClose: () => void; } export const SettingsModal = (props: Props) => { @@ -119,7 +119,7 @@ export const SettingsModal = (props: Props) => { ); const devices = useMediaDevices(); - useMediaDeviceNames(devices); + useMediaDeviceNames(devices, props.open); const audioTab = ( { return ( { // 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 { + const [open, setOpen] = useState(false); + const onDismiss = useCallback(() => setOpen(false), [setOpen]); const { client } = useClient(); const [rageshakeRequestId, setRageshakeRequestId] = useState(); @@ -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, }; } diff --git a/src/tabs/Tabs.module.css b/src/tabs/Tabs.module.css index dfee8b3a..d0b37d50 100644 --- a/src/tabs/Tabs.module.css +++ b/src/tabs/Tabs.module.css @@ -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; - } -} diff --git a/src/video-grid/VideoTile.tsx b/src/video-grid/VideoTile.tsx index 04d1b297..5d8d1987 100644 --- a/src/video-grid/VideoTile.tsx +++ b/src/video-grid/VideoTile.tsx @@ -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( 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( key="localVolume" className={styles.button} volume={(sfuParticipant as RemoteParticipant).getVolume() ?? 0} - onPress={onOptionsPress} + onPress={openVideoTileSettingsModal} /> ); @@ -208,10 +218,11 @@ export const VideoTile = forwardRef( : Track.Source.ScreenShare } /> - {videoTileSettingsModalState.isOpen && !maximised && ( + {!maximised && ( )} diff --git a/src/video-grid/VideoTileSettingsModal.tsx b/src/video-grid/VideoTileSettingsModal.tsx index 0c77075a..06d1170d 100644 --- a/src/video-grid/VideoTileSettingsModal.tsx +++ b/src/video-grid/VideoTileSettingsModal.tsx @@ -66,23 +66,21 @@ const LocalVolume: React.FC = ({ ); }; -// 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 (