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/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index f6a7ed65..f769c7e0 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -22,11 +22,11 @@ "By clicking \"Join call now\", you agree to our <2>End User Licensing Agreement (EULA)": "By clicking \"Join call now\", you agree to our <2>End User Licensing Agreement (EULA)", "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy and our <5>Cookie Policy.": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy and our <5>Cookie Policy.", "Call link copied": "Call link copied", - "Call type menu": "Call type menu", "Camera": "Camera", "Close": "Close", "Confirm password": "Confirm password", "Connectivity to the server has been lost.": "Connectivity to the server has been lost.", + "Continue in browser": "Continue in browser", "Copied!": "Copied!", "Copy": "Copy", "Copy and share this call link": "Copy and share this call link", @@ -66,13 +66,17 @@ "Microphone off": "Microphone off", "Microphone on": "Microphone on", "More": "More", + "Mute microphone": "Mute microphone", + "Name of call": "Name of call", "No": "No", "Not encrypted": "Not encrypted", "Not now, return to home screen": "Not now, return to home screen", "Not registered yet? <2>Create an account": "Not registered yet? <2>Create an account", + "Open in the app": "Open in the app", "Password": "Password", "Passwords must match": "Passwords must match", "Profile": "Profile", + "Ready to join?": "Ready to join?", "Recaptcha dismissed": "Recaptcha dismissed", "Recaptcha not loaded": "Recaptcha not loaded", "Reconnect": "Reconnect", @@ -82,6 +86,7 @@ "Retry sending logs": "Retry sending logs", "Return to home screen": "Return to home screen", "Select an option": "Select an option", + "Select app": "Select app", "Send debug logs": "Send debug logs", "Sending debug logs…": "Sending debug logs…", "Sending…": "Sending…", @@ -96,6 +101,9 @@ "Sign out": "Sign out", "Speaker": "Speaker", "Spotlight": "Spotlight", + "Start new call": "Start new call", + "Start video": "Start video", + "Stop video": "Stop video", "Submit": "Submit", "Submit feedback": "Submit feedback", "Submitting…": "Submitting…", @@ -104,20 +112,14 @@ "Thanks!": "Thanks!", "This call already exists, would you like to join?": "This call already exists, would you like to join?", "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy and <6>Terms of Service apply.<9>By clicking \"Register\", you agree to our <12>End User Licensing Agreement (EULA)": "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy and <6>Terms of Service apply.<9>By clicking \"Register\", you agree to our <12>End User Licensing Agreement (EULA)", + "Unmute microphone": "Unmute microphone", "User menu": "User menu", "Username": "Username", "Version: {{version}}": "Version: {{version}}", "Video": "Video", - "Video call": "Video call", - "Video call name": "Video call name", - "Video off": "Video off", - "Video on": "Video on", "Waiting for other participants…": "Waiting for other participants…", - "Walkie-talkie call": "Walkie-talkie call", - "Walkie-talkie call name": "Walkie-talkie call name", "Yes, join call": "Yes, join call", "You": "You", "You were disconnected from the call": "You were disconnected from the call", - "Your feedback": "Your feedback", - "Your recent calls": "Your recent calls" + "Your feedback": "Your feedback" } 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..9fe68122 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 { 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/UrlParams.ts b/src/UrlParams.ts index 275e1fff..91a292a3 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -54,10 +54,6 @@ interface UrlParams { * Whether to hide the screen-sharing button. */ hideScreensharing: boolean; - /** - * Whether to start a walkie-talkie call instead of a video call. - */ - isPtt: boolean; /** * Whether to use end-to-end encryption. */ @@ -105,6 +101,22 @@ interface UrlParams { password: string | null; } +export function editFragmentQuery( + hash: string, + edit: (params: URLSearchParams) => URLSearchParams +): string { + const fragmentQueryStart = hash.indexOf("?"); + const fragmentParams = edit( + new URLSearchParams( + fragmentQueryStart === -1 ? "" : hash.substring(fragmentQueryStart) + ) + ); + return `${hash.substring( + 0, + fragmentQueryStart + )}?${fragmentParams.toString()}`; +} + class ParamParser { private fragmentParams: URLSearchParams; private queryParams: URLSearchParams; @@ -161,7 +173,6 @@ export const getUrlParams = ( preload: parser.hasParam("preload"), hideHeader: parser.hasParam("hideHeader"), hideScreensharing: parser.hasParam("hideScreensharing"), - isPtt: parser.hasParam("ptt"), e2eEnabled: parser.getParam("enableE2e") !== "false", // Defaults to true userId: parser.getParam("userId"), displayName: parser.getParam("displayName"), 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/button/Button.tsx b/src/button/Button.tsx index 2b8049e5..3d269dc2 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -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 ( - @@ -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 ( - diff --git a/src/home/CallTypeDropdown.tsx b/src/home/CallTypeDropdown.tsx deleted file mode 100644 index f24d3d48..00000000 --- a/src/home/CallTypeDropdown.tsx +++ /dev/null @@ -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 = ({ callType, setCallType }) => { - const { t } = useTranslation(); - - const onAction = (key: React.Key) => { - setCallType(key.toString() as CallType); - }; - - const onClose = () => {}; - - return ( - - - {(props: JSX.IntrinsicAttributes) => ( - - - - {t("Video call")} - {callType === CallType.Video && ( - - )} - - - - {t("Walkie-talkie call")} - {callType === CallType.Radio && ( - - )} - - - )} - - ); -}; diff --git a/src/home/HomePage.tsx b/src/home/HomePage.tsx index 018e0512..a78cb8bd 100644 --- a/src/home/HomePage.tsx +++ b/src/home/HomePage.tsx @@ -34,10 +34,7 @@ export function HomePage() { return ; } else { return clientState.authenticated ? ( - + ) : ( ); 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 0f8cc93c..921ee0da 100644 --- a/src/home/RegisteredView.tsx +++ b/src/home/RegisteredView.tsx @@ -19,6 +19,7 @@ import { useHistory } from "react-router-dom"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { randomString } from "matrix-js-sdk/src/randomstring"; import { useTranslation } from "react-i18next"; +import { Heading } from "@vector-im/compound-web"; import { createRoom, @@ -33,11 +34,9 @@ import { FieldRow, InputField, ErrorMessage } from "../input/Input"; import { Button } from "../button"; import { CallList } from "./CallList"; import { UserMenuContainer } from "../UserMenuContainer"; -import { useModalTriggerState } from "../Modal"; import { JoinExistingCallModal } from "./JoinExistingCallModal"; -import { Caption, Title } from "../typography/Typography"; +import { Caption } from "../typography/Typography"; import { Form } from "../form/Form"; -import { CallType, CallTypeDropdown } from "./CallTypeDropdown"; import { useEnableE2EE, useOptInAnalytics } from "../settings/useSetting"; import { AnalyticsNotice } from "../analytics/AnalyticsNotice"; import { E2EEBanner } from "../E2EEBanner"; @@ -46,17 +45,20 @@ import { getRoomSharedKeyLocalStorageKey } from "../e2ee/sharedKeyManagement"; interface Props { client: MatrixClient; - isPasswordlessUser: boolean; } -export function RegisteredView({ client, isPasswordlessUser }: Props) { - const [callType, setCallType] = useState(CallType.Video); +export function RegisteredView({ client }: Props) { const [loading, setLoading] = useState(false); const [error, setError] = useState(); 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( @@ -68,14 +70,13 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) { typeof roomNameData === "string" ? sanitiseRoomNameInput(roomNameData) : ""; - const ptt = callType === CallType.Radio; async function submit() { setError(undefined); setLoading(true); const roomId = ( - await createRoom(client, roomName, ptt, e2eeEnabled ?? false) + await createRoom(client, roomName, e2eeEnabled ?? false) )[1]; if (e2eeEnabled) { @@ -93,7 +94,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) { setExistingAlias(roomAliasLocalpartFromRoomName(roomName)); setLoading(false); setError(undefined); - modalState.open(); + setJoinExistingCallModalOpen(true); } else { console.error(error); setLoading(false); @@ -101,7 +102,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) { } }); }, - [client, history, modalState, callType, e2eeEnabled] + [client, history, setJoinExistingCallModalOpen, e2eeEnabled] ); const recentRooms = useGroupCallRooms(client); @@ -111,11 +112,6 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) { history.push(`/${existingAlias}`); }, [history, existingAlias]); - const callNameLabel = - callType === CallType.Video - ? t("Video call name") - : t("Walkie-talkie call name"); - return ( <>
@@ -129,14 +125,16 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
- + + {t("Start new call")} +
{recentRooms.length > 0 && ( - <> - - {t("Your recent calls")} - - - + )}
- {modalState.isOpen && ( - - )} + ); } diff --git a/src/home/UnauthenticatedView.tsx b/src/home/UnauthenticatedView.tsx index 721c56d1..82839ff7 100644 --- a/src/home/UnauthenticatedView.tsx +++ b/src/home/UnauthenticatedView.tsx @@ -18,6 +18,7 @@ import { FC, useCallback, useState, FormEventHandler } from "react"; import { useHistory } from "react-router-dom"; import { randomString } from "matrix-js-sdk/src/randomstring"; import { Trans, useTranslation } from "react-i18next"; +import { Heading } from "@vector-im/compound-web"; import { useClient } from "../ClientContext"; import { Header, HeaderLogo, LeftNav, RightNav } from "../Header"; @@ -30,12 +31,10 @@ import { sanitiseRoomNameInput, } from "../matrix-utils"; import { useInteractiveRegistration } from "../auth/useInteractiveRegistration"; -import { useModalTriggerState } from "../Modal"; import { JoinExistingCallModal } from "./JoinExistingCallModal"; import { useRecaptcha } from "../auth/useRecaptcha"; import { Body, Caption, Link } from "../typography/Typography"; import { Form } from "../form/Form"; -import { CallType, CallTypeDropdown } from "./CallTypeDropdown"; import styles from "./UnauthenticatedView.module.css"; import commonStyles from "./common.module.css"; import { generateRandomName } from "../auth/generateRandomName"; @@ -48,14 +47,18 @@ import { setLocalStorageItem } from "../useLocalStorage"; export const UnauthenticatedView: FC = () => { const { setClient } = useClient(); - const [callType, setCallType] = useState(CallType.Video); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [optInAnalytics] = useOptInAnalytics(); const { recaptchaKey, register } = useInteractiveRegistration(); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); - const { modalState, modalProps } = useModalTriggerState(); + const [joinExistingCallModalOpen, setJoinExistingCallModalOpen] = + useState(false); + const onDismissJoinExistingCallModal = useCallback( + () => setJoinExistingCallModalOpen(false), + [setJoinExistingCallModalOpen] + ); const [onFinished, setOnFinished] = useState<() => void>(); const history = useHistory(); const { t } = useTranslation(); @@ -68,7 +71,6 @@ export const UnauthenticatedView: FC = () => { const data = new FormData(e.target as HTMLFormElement); const roomName = sanitiseRoomNameInput(data.get("callName") as string); const displayName = data.get("displayName") as string; - const ptt = callType === CallType.Radio; async function submit() { setError(undefined); @@ -86,7 +88,7 @@ export const UnauthenticatedView: FC = () => { let roomId: string; try { roomId = ( - await createRoom(client, roomName, ptt, e2eeEnabled ?? false) + await createRoom(client, roomName, e2eeEnabled ?? false) )[1]; if (e2eeEnabled) { @@ -110,7 +112,7 @@ export const UnauthenticatedView: FC = () => { }); setLoading(false); - modalState.open(); + setJoinExistingCallModalOpen(true); return; } else { throw error; @@ -138,18 +140,12 @@ export const UnauthenticatedView: FC = () => { reset, execute, history, - callType, - modalState, + setJoinExistingCallModalOpen, setClient, e2eeEnabled, ] ); - const callNameLabel = - callType === CallType.Video - ? t("Video call name") - : t("Walkie-talkie call name"); - return ( <>
@@ -163,14 +159,16 @@ export const UnauthenticatedView: FC = () => {
- + + {t("Start new call")} + {
- {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 fff1ee13..9a8152dd 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/matrix-utils.ts b/src/matrix-utils.ts index aed343a5..a9f37011 100644 --- a/src/matrix-utils.ts +++ b/src/matrix-utils.ts @@ -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 ); diff --git a/src/home/CallTypeDropdown.module.css b/src/room/AppSelectionModal.module.css similarity index 62% rename from src/home/CallTypeDropdown.module.css rename to src/room/AppSelectionModal.module.css index 6641fea0..773df4fd 100644 --- a/src/home/CallTypeDropdown.module.css +++ b/src/room/AppSelectionModal.module.css @@ -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; } diff --git a/src/room/AppSelectionModal.tsx b/src/room/AppSelectionModal.tsx new file mode 100644 index 00000000..8d4a05f8 --- /dev/null +++ b/src/room/AppSelectionModal.tsx @@ -0,0 +1,80 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { FC, MouseEvent, useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Text } from "@vector-im/compound-web"; +import { ReactComponent as PopOutIcon } from "@vector-im/compound-design-tokens/icons/pop-out.svg"; + +import { Modal } from "../Modal"; +import { useRoomSharedKey } from "../e2ee/sharedKeyManagement"; +import { getRoomUrl } from "../matrix-utils"; +import styles from "./AppSelectionModal.module.css"; +import { editFragmentQuery } from "../UrlParams"; + +interface Props { + roomId: string | null; +} + +export const AppSelectionModal: FC = ({ roomId }) => { + const { t } = useTranslation(); + + const [open, setOpen] = useState(true); + const onBrowserClick = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setOpen(false); + }, + [setOpen] + ); + + const roomSharedKey = useRoomSharedKey(roomId ?? ""); + const appUrl = useMemo(() => { + // If the room ID is not known, fall back to the URL of the current page + const url = new URL( + roomId === null + ? window.location.href + : getRoomUrl(roomId, roomSharedKey ?? undefined) + ); + // Edit the URL so that it opens in embedded mode. We do this for two + // reasons: It causes the mobile app to limit the user to only visiting the + // room in question, and it prevents this app selection prompt from being + // shown a second time. + url.hash = editFragmentQuery(url.hash, (params) => { + params.set("isEmbedded", ""); + return params; + }); + + const result = new URL("element://call"); + result.searchParams.set("url", url.toString()); + return result.toString(); + }, [roomId, roomSharedKey]); + + return ( + + + {t("Ready to join?")} + + + + + ); +}; diff --git a/src/room/CallEndedView.module.css b/src/room/CallEndedView.module.css index 9952a784..12409d4e 100644 --- a/src/room/CallEndedView.module.css +++ b/src/room/CallEndedView.module.css @@ -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 { diff --git a/src/room/GroupCallLoader.tsx b/src/room/GroupCallLoader.tsx index 78fa780c..5f71f203 100644 --- a/src/room/GroupCallLoader.tsx +++ b/src/room/GroupCallLoader.tsx @@ -27,7 +27,6 @@ interface Props { roomIdOrAlias: string; viaServers: string[]; children: (rtcSession: MatrixRTCSession) => ReactNode; - createPtt: boolean; } export function GroupCallLoader({ @@ -35,15 +34,9 @@ export function GroupCallLoader({ roomIdOrAlias, viaServers, children, - createPtt, }: Props): JSX.Element { const { t } = useTranslation(); - const groupCallState = useLoadGroupCall( - client, - roomIdOrAlias, - viaServers, - createPtt - ); + const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers); switch (groupCallState.kind) { case "loading": 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/RoomPage.tsx b/src/room/RoomPage.tsx index dc651138..76302ca9 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { FC, useEffect, useState, useCallback } from "react"; +import { FC, useEffect, useState, useCallback, ReactNode } from "react"; import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { useClientLegacy } from "../ClientContext"; @@ -26,10 +26,11 @@ import { useRoomIdentifier, useUrlParams } from "../UrlParams"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { useOptInAnalytics } from "../settings/useSetting"; import { HomePage } from "../home/HomePage"; +import { platform } from "../Platform"; +import { AppSelectionModal } from "./AppSelectionModal"; export const RoomPage: FC = () => { - const { isEmbedded, preload, hideHeader, isPtt, displayName } = - useUrlParams(); + const { isEmbedded, preload, hideHeader, displayName } = useUrlParams(); const { roomAlias, roomId, viaServers } = useRoomIdentifier(); @@ -81,30 +82,36 @@ export const RoomPage: FC = () => { [client, passwordlessUser, isEmbedded, preload, hideHeader] ); + let content: ReactNode; if (loading || isRegistering) { - return ; - } - - if (error) { - return ; - } - - if (!client) { - return ; - } - - if (!roomIdOrAlias) { - return ; + content = ; + } else if (error) { + content = ; + } else if (!client) { + content = ; + } else if (!roomIdOrAlias) { + // TODO: This doesn't belong here, the app routes need to be reworked + content = ; + } else { + content = ( + + {groupCallView} + + ); } return ( - - {groupCallView} - + <> + {content} + {/* On mobile, show a prompt to launch the mobile app. If in embedded mode, + that means we *are* in the mobile app and should show no further prompt. */} + {(platform === "android" || platform === "ios") && !isEmbedded && ( + + )} + ); }; 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/useLoadGroupCall.ts b/src/room/useLoadGroupCall.ts index 9218bf32..e6f79ced 100644 --- a/src/room/useLoadGroupCall.ts +++ b/src/room/useLoadGroupCall.ts @@ -56,8 +56,7 @@ export interface GroupCallLoadState { export const useLoadGroupCall = ( client: MatrixClient, roomIdOrAlias: string, - viaServers: string[], - createPtt: boolean + viaServers: string[] ): GroupCallStatus => { const { t } = useTranslation(); const [state, setState] = useState({ kind: "loading" }); @@ -101,7 +100,6 @@ export const useLoadGroupCall = ( const [, roomId] = await createRoom( client, roomNameFromRoomId(roomIdOrAlias), - createPtt, e2eeEnabled ?? false ); @@ -151,7 +149,7 @@ export const useLoadGroupCall = ( .then(fetchOrCreateGroupCall) .then((rtcSession) => setState({ kind: "loaded", rtcSession })) .catch((error) => setState({ kind: "failed", error })); - }, [client, roomIdOrAlias, viaServers, createPtt, t, e2eeEnabled]); + }, [client, roomIdOrAlias, viaServers, t, e2eeEnabled]); return state; }; 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 (