Implement the new invite modal designs

This commit is contained in:
Robin 2023-09-27 17:34:41 -04:00
parent 6865a13c3c
commit 8a14d60cb4
13 changed files with 427 additions and 106 deletions

View File

@ -87,6 +87,7 @@
"@storybook/react": "^6.5.0-alpha.5", "@storybook/react": "^6.5.0-alpha.5",
"@testing-library/jest-dom": "^6.0.0", "@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0", "@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.5.1",
"@types/content-type": "^1.1.5", "@types/content-type": "^1.1.5",
"@types/d3": "^7.4.0", "@types/d3": "^7.4.0",
"@types/dom-screen-wake-lock": "^1.0.1", "@types/dom-screen-wake-lock": "^1.0.1",

View File

@ -14,96 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License. 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 { .modal {
position: fixed;
z-index: 101;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.dialog { .dialog {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
box-sizing: border-box; box-sizing: border-box;
inline-size: 520px; inline-size: 520px;
max-inline-size: 90%; max-inline-size: 90%;
max-block-size: 600px; 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 { .content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -32,6 +32,7 @@ import classNames from "classnames";
import { Heading } from "@vector-im/compound-web"; import { Heading } from "@vector-im/compound-web";
import styles from "./Modal.module.css"; import styles from "./Modal.module.css";
import overlayStyles from "./Overlay.module.css";
import { useMediaQuery } from "./useMediaQuery"; import { useMediaQuery } from "./useMediaQuery";
import { Glass } from "./Glass"; import { Glass } from "./Glass";
@ -85,9 +86,14 @@ export function Modal({
dismissible={onDismiss !== undefined} dismissible={onDismiss !== undefined}
> >
<Drawer.Portal> <Drawer.Portal>
<Drawer.Overlay className={styles.overlay} /> <Drawer.Overlay className={classNames(overlayStyles.bg)} />
<Drawer.Content <Drawer.Content
className={classNames(className, styles.modal, styles.drawer)} className={classNames(
className,
overlayStyles.overlay,
styles.modal,
styles.drawer
)}
{...rest} {...rest}
> >
<div className={styles.content}> <div className={styles.content}>
@ -108,12 +114,18 @@ export function Modal({
<DialogRoot open={open} onOpenChange={onOpenChange}> <DialogRoot open={open} onOpenChange={onOpenChange}>
<DialogPortal> <DialogPortal>
<DialogOverlay <DialogOverlay
className={classNames(styles.overlay, styles.dialogOverlay)} className={classNames(overlayStyles.bg, overlayStyles.animate)}
/> />
<DialogContent asChild {...rest}> <DialogContent asChild {...rest}>
<Glass <Glass
frosted frosted
className={classNames(className, styles.modal, styles.dialog)} className={classNames(
className,
overlayStyles.overlay,
overlayStyles.animate,
styles.modal,
styles.dialog
)}
> >
<div className={styles.content}> <div className={styles.content}>
<div className={styles.header}> <div className={styles.header}>

99
src/Overlay.module.css Normal file
View File

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

38
src/Toast.module.css Normal file
View File

@ -0,0 +1,38 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.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));
}

108
src/Toast.tsx Normal file
View File

@ -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<SVGAttributes<SVGElement>>;
}
/**
* A temporary message shown in an overlay in the center of the screen.
*/
export const Toast: FC<Props> = ({
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 (
<DialogRoot open={open} onOpenChange={onOpenChange}>
<DialogPortal>
<DialogOverlay
className={classNames(overlayStyles.bg, overlayStyles.animate)}
/>
<DialogContent asChild>
<DialogClose
className={classNames(
overlayStyles.overlay,
overlayStyles.animate,
styles.toast
)}
>
<DialogTitle asChild>
<Text as="h3" size="sm" weight="semibold">
{children}
</Text>
</DialogTitle>
{Icon && <Icon width={20} height={20} aria-hidden />}
</DialogClose>
</DialogContent>
</DialogPortal>
</DialogRoot>
);
};

View File

@ -14,6 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License. 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%; width: 100%;
} }

View File

@ -14,15 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { FC } from "react"; import { FC, MouseEvent, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Room } from "matrix-js-sdk"; 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 { Modal } from "../Modal";
import { CopyButton } from "../button";
import { getAbsoluteRoomUrl } from "../matrix-utils"; import { getAbsoluteRoomUrl } from "../matrix-utils";
import styles from "./InviteModal.module.css"; import styles from "./InviteModal.module.css";
import { useRoomSharedKey } from "../e2ee/sharedKeyManagement"; import { useRoomSharedKey } from "../e2ee/sharedKeyManagement";
import { Toast } from "../Toast";
interface Props { interface Props {
room: Room; room: Room;
@ -33,19 +37,48 @@ interface Props {
export const InviteModal: FC<Props> = ({ room, open, onDismiss }) => { export const InviteModal: FC<Props> = ({ room, open, onDismiss }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const roomSharedKey = useRoomSharedKey(room.roomId); 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 ( return (
<Modal title={t("Invite to this call")} open={open} onDismiss={onDismiss}> <>
<p>{t("Copy and share this call link")}</p> <Modal title={t("Invite to this call")} open={open} onDismiss={onDismiss}>
<CopyButton <Text className={styles.url} size="sm" weight="semibold">
className={styles.copyButton} {url}
value={getAbsoluteRoomUrl( </Text>
room.roomId, <Button
room.name, className={styles.button}
roomSharedKey ?? undefined Icon={LinkIcon}
)} onClick={onButtonClick}
data-testid="modal_inviteLink" data-testid="modal_inviteLink"
/> >
</Modal> {t("Copy link")}
</Button>
</Modal>
<Toast
open={toastOpen}
onDismiss={onToastDismiss}
autoDismiss={2000}
Icon={CheckIcon}
>
{t("Link copied to clipboard")}
</Toast>
</>
); );
}; };

59
test/Toast-test.tsx Normal file
View File

@ -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(
<Toast open={false} onDismiss={() => {}}>
Hello world!
</Toast>
);
expect(screen.queryByRole("dialog")).toBe(null);
render(
<Toast open={true} onDismiss={() => {}}>
Hello world!
</Toast>
);
expect(screen.getByRole("dialog")).toMatchSnapshot();
});
test("Toast dismisses when clicked", async () => {
const onDismiss = jest.fn();
render(
<Toast open={true} onDismiss={onDismiss}>
Hello world!
</Toast>
);
await userEvent.click(screen.getByRole("dialog"));
expect(onDismiss).toHaveBeenCalled();
});
test("Toast dismisses itself after the specified timeout", async () => {
withFakeTimers(() => {
const onDismiss = jest.fn();
render(
<Toast open={true} onDismiss={onDismiss} autoDismiss={2000}>
Hello world!
</Toast>
);
jest.advanceTimersByTime(2000);
expect(onDismiss).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,22 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Toast renders 1`] = `
<button
aria-describedby="radix-:r5:"
aria-labelledby="radix-:r4:"
class="overlay animate toast"
data-state="open"
id="radix-:r3:"
role="dialog"
style="pointer-events: auto;"
tabindex="-1"
type="button"
>
<h3
class="_font-body-sm-semibold_1jx6b_45"
id="radix-:r4:"
>
Hello world!
</h3>
</button>
`;

View File

@ -18,15 +18,7 @@ import { Mocked, mocked } from "jest-mock";
import { RoomState } from "matrix-js-sdk/src/models/room-state"; import { RoomState } from "matrix-js-sdk/src/models/room-state";
import { PosthogAnalytics } from "../../src/analytics/PosthogAnalytics"; import { PosthogAnalytics } from "../../src/analytics/PosthogAnalytics";
import { checkForParallelCalls } from "../../src/room/checkForParallelCalls"; import { checkForParallelCalls } from "../../src/room/checkForParallelCalls";
import { withFakeTimers } from "../utils";
const withFakeTimers = (continuation: () => void) => {
jest.useFakeTimers();
try {
continuation();
} finally {
jest.useRealTimers();
}
};
const withMockedPosthog = ( const withMockedPosthog = (
continuation: (posthog: Mocked<PosthogAnalytics>) => void continuation: (posthog: Mocked<PosthogAnalytics>) => void

24
test/utils.ts Normal file
View File

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

View File

@ -4394,6 +4394,11 @@
"@testing-library/dom" "^9.0.0" "@testing-library/dom" "^9.0.0"
"@types/react-dom" "^18.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": "@tootallnate/once@2":
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"