diff --git a/package.json b/package.json index 9b6eb301..ba8765e2 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "@storybook/react": "^6.5.0-alpha.5", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.5.1", "@types/content-type": "^1.1.5", "@types/d3": "^7.4.0", "@types/dom-screen-wake-lock": "^1.0.1", diff --git a/src/Modal.module.css b/src/Modal.module.css index e72f6496..d6af51df 100644 --- a/src/Modal.module.css +++ b/src/Modal.module.css @@ -14,96 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -.overlay { - position: fixed; - z-index: 100; - 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 { - position: fixed; - z-index: 101; display: flex; flex-direction: column; } .dialog { - left: 50%; - top: 50%; - transform: translate(-50%, -50%); box-sizing: border-box; inline-size: 520px; max-inline-size: 90%; max-block-size: 600px; } -@keyframes zoom-in { - from { - opacity: 0; - transform: translate(-50%, -50%) scale(80%); - } - to { - opacity: 1; - transform: translate(-50%, -50%) scale(100%); - } -} - -@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 { display: flex; flex-direction: column; diff --git a/src/Modal.tsx b/src/Modal.tsx index b644abe4..cacb24fe 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -32,6 +32,7 @@ import classNames from "classnames"; import { Heading } from "@vector-im/compound-web"; import styles from "./Modal.module.css"; +import overlayStyles from "./Overlay.module.css"; import { useMediaQuery } from "./useMediaQuery"; import { Glass } from "./Glass"; @@ -85,9 +86,14 @@ export function Modal({ dismissible={onDismiss !== undefined} > - +
@@ -108,12 +114,18 @@ export function Modal({
diff --git a/src/Overlay.module.css b/src/Overlay.module.css new file mode 100644 index 00000000..37bcf95d --- /dev/null +++ b/src/Overlay.module.css @@ -0,0 +1,99 @@ +/* +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. +*/ + +.bg { + position: fixed; + z-index: 100; + inset: 0; + background: rgba(3, 12, 27, 0.528); +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.bg.animate[data-state="open"] { + animation: fade-in 200ms; +} + +@keyframes fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +.bg.animate[data-state="closed"] { + animation: fade-out 130ms; +} + +.overlay { + position: fixed; + z-index: 101; +} + +.overlay.animate { + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +} + +@keyframes zoom-in { + from { + opacity: 0; + transform: translate(-50%, -50%) scale(80%); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(100%); + } +} + +@keyframes zoom-out { + from { + opacity: 1; + transform: translate(-50%, -50%) scale(100%); + } + to { + opacity: 0; + transform: translate(-50%, -50%) scale(80%); + } +} + +.overlay.animate[data-state="open"] { + animation: zoom-in 200ms; +} + +.overlay.animate[data-state="closed"] { + animation: zoom-out 130ms; +} + +@media (prefers-reduced-motion) { + .overlay.animate[data-state="open"] { + animation-name: fade-in; + } + + .overlay.animate[data-state="closed"] { + animation-name: fade-out; + } +} diff --git a/src/Toast.module.css b/src/Toast.module.css new file mode 100644 index 00000000..5f19b3b2 --- /dev/null +++ b/src/Toast.module.css @@ -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. +*/ + +.toast { + color: var(--cpd-color-text-on-solid-primary); + background: var(--cpd-color-alpha-gray-1200); + padding-inline: var(--cpd-space-3x); + padding-block: var(--cpd-space-1x); + border: none; + border-radius: var(--cpd-radius-pill-effect); + box-shadow: var(--small-drop-shadow); + display: flex; + align-items: center; + gap: var(--cpd-space-1x); +} + +.toast > h3 { + margin: 0; +} + +.toast > svg { + color: var(--cpd-color-icon-on-solid-primary); + flex-shrink: 0; + margin-inline-end: calc(-1 * var(--cpd-space-1x)); +} diff --git a/src/Toast.tsx b/src/Toast.tsx new file mode 100644 index 00000000..de532cde --- /dev/null +++ b/src/Toast.tsx @@ -0,0 +1,108 @@ +/* +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 { + ComponentType, + FC, + SVGAttributes, + useCallback, + useEffect, +} from "react"; +import { + Root as DialogRoot, + Portal as DialogPortal, + Overlay as DialogOverlay, + Content as DialogContent, + Close as DialogClose, + Title as DialogTitle, +} from "@radix-ui/react-dialog"; +import classNames from "classnames"; +import { Text } from "@vector-im/compound-web"; + +import styles from "./Toast.module.css"; +import overlayStyles from "./Overlay.module.css"; + +interface Props { + /** + * The controlled open state of the toast. + */ + open: boolean; + /** + * Callback for when the user dismisses the toast. + */ + onDismiss: () => void; + /** + * A number of milliseconds after which the toast should be automatically + * dismissed. + */ + autoDismiss?: number; + children: string; + /** + * A supporting icon to display within the toast. + */ + Icon?: ComponentType>; +} + +/** + * A temporary message shown in an overlay in the center of the screen. + */ +export const Toast: FC = ({ + open, + onDismiss, + autoDismiss, + children, + Icon, +}) => { + const onOpenChange = useCallback( + (open: boolean) => { + if (!open) onDismiss(); + }, + [onDismiss] + ); + + useEffect(() => { + if (open && autoDismiss !== undefined) { + const timeout = setTimeout(onDismiss, autoDismiss); + return () => clearTimeout(timeout); + } + }, [open, autoDismiss, onDismiss]); + + return ( + + + + + + + + {children} + + + {Icon && } + + + + + ); +}; diff --git a/src/room/InviteModal.module.css b/src/room/InviteModal.module.css index dec0c304..dd7aa755 100644 --- a/src/room/InviteModal.module.css +++ b/src/room/InviteModal.module.css @@ -14,6 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -.copyButton { +.url { + text-align: center; + color: var(--cpd-color-text-secondary); + margin-block-end: var(--cpd-space-8x); +} + +.button { width: 100%; } diff --git a/src/room/InviteModal.tsx b/src/room/InviteModal.tsx index 6a04e2ef..7c2a9e73 100644 --- a/src/room/InviteModal.tsx +++ b/src/room/InviteModal.tsx @@ -14,15 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { FC } from "react"; +import { FC, MouseEvent, useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Room } from "matrix-js-sdk"; +import { Button, Text } from "@vector-im/compound-web"; +import { ReactComponent as LinkIcon } from "@vector-im/compound-design-tokens/icons/link.svg"; +import { ReactComponent as CheckIcon } from "@vector-im/compound-design-tokens/icons/check.svg"; +import useClipboard from "react-use-clipboard"; import { Modal } from "../Modal"; -import { CopyButton } from "../button"; import { getAbsoluteRoomUrl } from "../matrix-utils"; import styles from "./InviteModal.module.css"; import { useRoomSharedKey } from "../e2ee/sharedKeyManagement"; +import { Toast } from "../Toast"; interface Props { room: Room; @@ -33,19 +37,48 @@ interface Props { export const InviteModal: FC = ({ room, open, onDismiss }) => { const { t } = useTranslation(); const roomSharedKey = useRoomSharedKey(room.roomId); + const url = useMemo( + () => + getAbsoluteRoomUrl(room.roomId, room.name, roomSharedKey ?? undefined), + [room, roomSharedKey] + ); + const [, setCopied] = useClipboard(url); + const [toastOpen, setToastOpen] = useState(false); + const onToastDismiss = useCallback(() => setToastOpen(false), [setToastOpen]); + + const onButtonClick = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + setCopied(); + onDismiss(); + setToastOpen(true); + }, + [setCopied, onDismiss] + ); return ( - -

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

- -
+ <> + + + {url} + + + + + {t("Link copied to clipboard")} + + ); }; diff --git a/test/Toast-test.tsx b/test/Toast-test.tsx new file mode 100644 index 00000000..605feaea --- /dev/null +++ b/test/Toast-test.tsx @@ -0,0 +1,59 @@ +/* +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 { screen, render } from "@testing-library/react"; +import { Toast } from "../src/Toast"; +import userEvent from "@testing-library/user-event"; +import { withFakeTimers } from "./utils"; + +test("Toast renders", () => { + render( + {}}> + Hello world! + + ); + expect(screen.queryByRole("dialog")).toBe(null); + render( + {}}> + Hello world! + + ); + expect(screen.getByRole("dialog")).toMatchSnapshot(); +}); + +test("Toast dismisses when clicked", async () => { + const onDismiss = jest.fn(); + render( + + Hello world! + + ); + await userEvent.click(screen.getByRole("dialog")); + expect(onDismiss).toHaveBeenCalled(); +}); + +test("Toast dismisses itself after the specified timeout", async () => { + withFakeTimers(() => { + const onDismiss = jest.fn(); + render( + + Hello world! + + ); + jest.advanceTimersByTime(2000); + expect(onDismiss).toHaveBeenCalled(); + }); +}); diff --git a/test/__snapshots__/Toast-test.tsx.snap b/test/__snapshots__/Toast-test.tsx.snap new file mode 100644 index 00000000..3391de02 --- /dev/null +++ b/test/__snapshots__/Toast-test.tsx.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Toast renders 1`] = ` + +`; diff --git a/test/room/checkForParallelCalls-test.ts b/test/room/checkForParallelCalls-test.ts index 6b5f0166..0a1344df 100644 --- a/test/room/checkForParallelCalls-test.ts +++ b/test/room/checkForParallelCalls-test.ts @@ -18,15 +18,7 @@ import { Mocked, mocked } from "jest-mock"; import { RoomState } from "matrix-js-sdk/src/models/room-state"; import { PosthogAnalytics } from "../../src/analytics/PosthogAnalytics"; import { checkForParallelCalls } from "../../src/room/checkForParallelCalls"; - -const withFakeTimers = (continuation: () => void) => { - jest.useFakeTimers(); - try { - continuation(); - } finally { - jest.useRealTimers(); - } -}; +import { withFakeTimers } from "../utils"; const withMockedPosthog = ( continuation: (posthog: Mocked) => void diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 00000000..9fb282ef --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,24 @@ +/* +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. +*/ + +export function withFakeTimers(continuation: () => void): void { + jest.useFakeTimers(); + try { + continuation(); + } finally { + jest.useRealTimers(); + } +} diff --git a/yarn.lock b/yarn.lock index f308df03..593a64af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4394,6 +4394,11 @@ "@testing-library/dom" "^9.0.0" "@types/react-dom" "^18.0.0" +"@testing-library/user-event@^14.5.1": + version "14.5.1" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.5.1.tgz#27337d72046d5236b32fd977edee3f74c71d332f" + integrity sha512-UCcUKrUYGj7ClomOo2SpNVvx4/fkd/2BbIHDCle8A0ax+P3bU7yJwDBDrS6ZwdTMARWTGODX1hEsCcO+7beJjg== + "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"