diff --git a/playwright/e2e/share-dialog/share-dialog.spec.ts b/playwright/e2e/share-dialog/share-dialog.spec.ts new file mode 100644 index 00000000000..2999b74ca03 --- /dev/null +++ b/playwright/e2e/share-dialog/share-dialog.spec.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only + * Please see LICENSE files in the repository root for full details. + */ + +import { test, expect } from "../../element-web-test"; + +test.describe("Share dialog", () => { + test.use({ + displayName: "Alice", + room: async ({ app, user, bot }, use) => { + const roomId = await app.client.createRoom({ name: "Alice room" }); + await use({ roomId }); + }, + }); + + test("should share a room", async ({ page, app, room }) => { + await app.viewRoomById(room.roomId); + await app.toggleRoomInfoPanel(); + await page.getByRole("menuitem", { name: "Copy link" }).click(); + + const dialog = page.getByRole("dialog", { name: "Share room" }); + await expect(dialog.getByText(`https://matrix.to/#/${room.roomId}`)).toBeVisible(); + expect(dialog).toMatchScreenshot("share-dialog-room.png", { + // QRCode and url changes at every run + mask: [page.locator(".mx_QRCode"), page.locator(".mx_ShareDialog_top > span")], + }); + }); + + test("should share a room member", async ({ page, app, room, user }) => { + await app.viewRoomById(room.roomId); + await app.client.sendMessage(room.roomId, { body: "hello", msgtype: "m.text" }); + + const rightPanel = await app.toggleRoomInfoPanel(); + await rightPanel.getByRole("menuitem", { name: "People" }).click(); + await rightPanel.getByRole("button", { name: `${user.userId} (power 100)` }).click(); + await rightPanel.getByRole("button", { name: "Share profile" }).click(); + + const dialog = page.getByRole("dialog", { name: "Share User" }); + await expect(dialog.getByText(`https://matrix.to/#/${user.userId}`)).toBeVisible(); + expect(dialog).toMatchScreenshot("share-dialog-user.png", { + // QRCode changes at every run + mask: [page.locator(".mx_QRCode")], + }); + }); + + test("should share an event", async ({ page, app, room }) => { + await app.viewRoomById(room.roomId); + await app.client.sendMessage(room.roomId, { body: "hello", msgtype: "m.text" }); + + const timelineMessage = page.locator(".mx_MTextBody", { hasText: "hello" }); + await timelineMessage.hover(); + await page.getByRole("button", { name: "Options", exact: true }).click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + + const dialog = page.getByRole("dialog", { name: "Share Room Message" }); + await expect(dialog.getByRole("checkbox", { name: "Link to selected message" })).toBeChecked(); + expect(dialog).toMatchScreenshot("share-dialog-event.png", { + // QRCode and url changes at every run + mask: [page.locator(".mx_QRCode"), page.locator(".mx_ShareDialog_top > span")], + }); + await dialog.getByRole("checkbox", { name: "Link to selected message" }).click(); + await expect(dialog.getByRole("checkbox", { name: "Link to selected message" })).not.toBeChecked(); + }); +}); diff --git a/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-event-linux.png b/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-event-linux.png new file mode 100644 index 00000000000..2f703cfc8ac Binary files /dev/null and b/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-event-linux.png differ diff --git a/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-room-linux.png b/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-room-linux.png new file mode 100644 index 00000000000..725e095f6f9 Binary files /dev/null and b/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-room-linux.png differ diff --git a/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-user-linux.png b/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-user-linux.png new file mode 100644 index 00000000000..98167cc7fbc Binary files /dev/null and b/playwright/snapshots/share-dialog/share-dialog.spec.ts/share-dialog-user-linux.png differ diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 15ba02b6b88..74328af39b2 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -596,7 +596,7 @@ legend { .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button), + ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button), .mx_Dialog input[type="submit"], .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), .mx_Dialog_buttons input[type="submit"] { @@ -616,14 +616,16 @@ legend { .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):last-child { + ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not( + .mx_ShareDialog button + ):last-child { margin-right: 0px; } .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):focus, + ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):focus, .mx_Dialog input[type="submit"]:focus, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus, .mx_Dialog_buttons input[type="submit"]:focus { @@ -635,7 +637,7 @@ legend { .mx_Dialog_buttons button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button), + ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button), .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary { color: var(--cpd-color-text-on-solid-primary); background-color: var(--cpd-color-bg-action-primary-rest); @@ -648,7 +650,7 @@ legend { .mx_Dialog_buttons button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not( .mx_ThemeChoicePanel_CustomTheme button - ):not(.mx_UnpinAllDialog button), + ):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button), .mx_Dialog_buttons input[type="submit"].danger { background-color: var(--cpd-color-bg-critical-primary); border: solid 1px var(--cpd-color-bg-critical-primary); @@ -664,7 +666,7 @@ legend { .mx_Dialog button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( .mx_UserProfileSettings button - ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):disabled, + ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):disabled, .mx_Dialog input[type="submit"]:disabled, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled, .mx_Dialog_buttons input[type="submit"]:disabled { diff --git a/res/css/views/dialogs/_ShareDialog.pcss b/res/css/views/dialogs/_ShareDialog.pcss index 086222af31b..cfede43aae7 100644 --- a/res/css/views/dialogs/_ShareDialog.pcss +++ b/res/css/views/dialogs/_ShareDialog.pcss @@ -5,50 +5,73 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -.mx_ShareDialog hr { - margin-top: 25px; - margin-bottom: 25px; - border-color: $light-fg-color; -} +.mx_ShareDialog { + /* Value from figma design */ + width: 416px; + + .mx_Dialog_header { + text-align: center; + margin-bottom: var(--cpd-space-6x); + /* Override dialog header padding to able to center it */ + padding-inline-end: 0; + } -.mx_ShareDialog .mx_ShareDialog_content { - margin: 10px 0; + .mx_ShareDialog_content { + display: flex; + flex-direction: column; + gap: var(--cpd-space-6x); + align-items: center; - .mx_CopyableText { - width: unset; /* full width */ + .mx_ShareDialog_top { + display: flex; + flex-direction: column; + gap: var(--cpd-space-4x); + align-items: center; + width: 100%; - > a { - text-decoration: none; - flex-shrink: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + span { + text-align: center; + font: var(--cpd-font-body-sm-semibold); + color: var(--cpd-color-text-secondary); + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + width: 100%; + } } - } -} -.mx_ShareDialog_split { - display: flex; - flex-wrap: wrap; -} + label { + display: inline-flex; + gap: var(--cpd-space-3x); + justify-content: center; + align-items: center; + font: var(--cpd-font-body-md-medium); + } -.mx_ShareDialog_qrcode_container { - float: left; - height: 256px; - width: 256px; - margin-right: 64px; -} + button { + width: 100%; + } -.mx_ShareDialog_qrcode_container + .mx_ShareDialog_social_container { - width: 299px; -} + .mx_ShareDialog_social { + display: flex; + gap: var(--cpd-space-3x); + justify-content: center; -.mx_ShareDialog_social_container { - display: inline-block; -} + a { + width: 48px; + height: 48px; + border-radius: 99px; + box-sizing: border-box; + border: 1px solid var(--cpd-color-border-interactive-secondary); + display: flex; + justify-content: center; + align-items: center; -.mx_ShareDialog_social_icon { - display: inline-grid; - margin-right: 10px; - margin-bottom: 10px; + img { + width: 24px; + height: 24px; + } + } + } + } } diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 2b559aa74c3..711ffbe70f9 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -38,7 +38,7 @@ import ContextMenu, { toRightOf, MenuProps } from "../../structures/ContextMenu" import ReactionPicker from "../emojipicker/ReactionPicker"; import ViewSource from "../../structures/ViewSource"; import { createRedactEventDialog } from "../dialogs/ConfirmRedactDialog"; -import ShareDialog from "../dialogs/ShareDialog"; +import { ShareDialog } from "../dialogs/ShareDialog"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import EndPollDialog from "../dialogs/EndPollDialog"; import { isPollEnded } from "../messages/MPollBody"; diff --git a/src/components/views/dialogs/ShareDialog.tsx b/src/components/views/dialogs/ShareDialog.tsx index f9382227e4e..1796b79239e 100644 --- a/src/components/views/dialogs/ShareDialog.tsx +++ b/src/components/views/dialogs/ShareDialog.tsx @@ -7,22 +7,23 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import * as React from "react"; +import React, { JSX, useMemo, useRef, useState } from "react"; import { Room, RoomMember, MatrixEvent, User } from "matrix-js-sdk/src/matrix"; +import { Checkbox, Button } from "@vector-im/compound-web"; +import LinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link"; +import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check"; import { _t } from "../../../languageHandler"; import QRCode from "../elements/QRCode"; import { RoomPermalinkCreator, makeUserPermalink } from "../../../utils/permalinks/Permalinks"; -import { selectText } from "../../../utils/strings"; -import StyledCheckbox from "../elements/StyledCheckbox"; -import SettingsStore from "../../../settings/SettingsStore"; +import { copyPlaintext } from "../../../utils/strings"; import { UIFeature } from "../../../settings/UIFeature"; import BaseDialog from "./BaseDialog"; -import CopyableText from "../elements/CopyableText"; import { XOR } from "../../../@types/common"; +import { useSettingValue } from "../../../hooks/useSettings.ts"; /* eslint-disable @typescript-eslint/no-require-imports */ -const socials = [ +const SOCIALS = [ { name: "Facebook", img: require("../../../../res/img/social/facebook.png"), @@ -33,11 +34,7 @@ const socials = [ img: require("../../../../res/img/social/twitter-2.png"), url: (url: string) => `https://twitter.com/home?status=${url}`, }, - /* // icon missing - name: 'Google Plus', - img: 'img/social/', - url: (url) => `https://plus.google.com/share?url=${url}`, - },*/ { + { name: "LinkedIn", img: require("../../../../res/img/social/linkedin.png"), url: (url: string) => `https://www.linkedin.com/shareArticle?mini=true&url=${url}`, @@ -78,160 +75,153 @@ interface Props extends BaseProps { * A matrix.to link will be generated out of it if it's not already a url. */ target: Room | User | RoomMember | URL; + /** + * Optional when the target is a Room, User, RoomMember or a URL. + * Mandatory when the target is a MatrixEvent. + */ permalinkCreator?: RoomPermalinkCreator; } interface EventProps extends BaseProps { + /** + * The target to link to. + */ target: MatrixEvent; + /** + * Optional when the target is a Room, User, RoomMember or a URL. + * Mandatory when the target is a MatrixEvent. + */ permalinkCreator: RoomPermalinkCreator; } -interface IState { - linkSpecificEvent: boolean; - permalinkCreator: RoomPermalinkCreator | null; +type ShareDialogProps = XOR; + +/** + * A dialog to share a link to a room, user, room member or a matrix event. + */ +export function ShareDialog({ target, customTitle, onFinished, permalinkCreator }: ShareDialogProps): JSX.Element { + const showQrCode = useSettingValue(UIFeature.ShareQRCode); + const showSocials = useSettingValue(UIFeature.ShareSocial); + + const timeoutIdRef = useRef(); + const [isCopied, setIsCopied] = useState(false); + + const [linkToSpecificEvent, setLinkToSpecificEvent] = useState(target instanceof MatrixEvent); + const { title, url, checkboxLabel } = useTargetValues(target, linkToSpecificEvent, permalinkCreator); + const newTitle = customTitle ?? title; + + return ( + + + + {showQrCode && } + {url} + + {checkboxLabel && ( + + setLinkToSpecificEvent(evt.target.checked)} + /> + {checkboxLabel} + + )} + { + clearTimeout(timeoutIdRef.current); + await copyPlaintext(url); + setIsCopied(true); + timeoutIdRef.current = setTimeout(() => setIsCopied(false), 2000); + }} + > + {isCopied ? _t("share|link_copied") : _t("action|copy_link")} + + {showSocials && } + + + ); } -export default class ShareDialog extends React.PureComponent, IState> { - public constructor(props: XOR) { - super(props); - - let permalinkCreator: RoomPermalinkCreator | null = null; - if (props.target instanceof Room) { - permalinkCreator = new RoomPermalinkCreator(props.target); - permalinkCreator.load(); - } +/** + * Social links to share the link on different platforms. + */ +interface SocialLinksProps { + /** + * The URL to share. + */ + url: string; +} - this.state = { - // MatrixEvent defaults to share linkSpecificEvent - linkSpecificEvent: this.props.target instanceof MatrixEvent, - permalinkCreator, - }; - } - - public static onLinkClick(e: React.MouseEvent): void { - e.preventDefault(); - selectText(e.currentTarget); - } - - private onLinkSpecificEventCheckboxClick = (): void => { - this.setState({ - linkSpecificEvent: !this.state.linkSpecificEvent, - }); - }; - - private getUrl(): string { - if (this.props.target instanceof URL) { - return this.props.target.toString(); - } else if (this.props.target instanceof Room) { - if (this.state.linkSpecificEvent) { - const events = this.props.target.getLiveTimeline().getEvents(); - return this.state.permalinkCreator!.forEvent(events[events.length - 1].getId()!); - } else { - return this.state.permalinkCreator!.forShareableRoom(); - } - } else if (this.props.target instanceof User || this.props.target instanceof RoomMember) { - return makeUserPermalink(this.props.target.userId); - } else if (this.state.linkSpecificEvent) { - return this.props.permalinkCreator!.forEvent(this.props.target.getId()!); - } else { - return this.props.permalinkCreator!.forShareableRoom(); - } - } - - public render(): React.ReactNode { - let title: string | undefined; - let checkbox: JSX.Element | undefined; - - if (this.props.target instanceof URL) { - title = this.props.customTitle ?? _t("share|title_link"); - } else if (this.props.target instanceof Room) { - title = this.props.customTitle ?? _t("share|title_room"); - - const events = this.props.target.getLiveTimeline().getEvents(); - if (events.length > 0) { - checkbox = ( - - - {_t("share|permalink_most_recent")} - - - ); - } - } else if (this.props.target instanceof User || this.props.target instanceof RoomMember) { - title = this.props.customTitle ?? _t("share|title_user"); - } else if (this.props.target instanceof MatrixEvent) { - title = this.props.customTitle ?? _t("share|title_message"); - checkbox = ( - - - {_t("share|permalink_message")} - - - ); - } +/** + * The socials to share the link on. + */ +function SocialLinks({ url }: SocialLinksProps): JSX.Element { + return ( + + {SOCIALS.map((social) => ( + + + + ))} + + ); +} - const matrixToUrl = this.getUrl(); - const encodedUrl = encodeURIComponent(matrixToUrl); - - const showQrCode = SettingsStore.getValue(UIFeature.ShareQRCode); - const showSocials = SettingsStore.getValue(UIFeature.ShareSocial); - - let qrSocialSection; - if (showQrCode || showSocials) { - qrSocialSection = ( - <> - - - {showQrCode && ( - - - - )} - {showSocials && ( - - {socials.map((social) => ( - - - - ))} - - )} - - > - ); +/** + * Get the title, url and checkbox label for the dialog based on the target. + * @param target + * @param linkToSpecificEvent + * @param permalinkCreator + */ +function useTargetValues( + target: ShareDialogProps["target"], + linkToSpecificEvent: boolean, + permalinkCreator?: RoomPermalinkCreator, +): { title: string; url: string; checkboxLabel?: string } { + return useMemo(() => { + if (target instanceof URL) return { title: _t("share|title_link"), url: target.toString() }; + if (target instanceof User || target instanceof RoomMember) + return { + title: _t("share|title_user"), + url: makeUserPermalink(target.userId), + }; + + if (target instanceof Room) { + const title = _t("share|title_room"); + const newPermalinkCreator = new RoomPermalinkCreator(target); + newPermalinkCreator.load(); + + const events = target.getLiveTimeline().getEvents(); + return { + title, + url: linkToSpecificEvent + ? newPermalinkCreator.forEvent(events[events.length - 1].getId()!) + : newPermalinkCreator.forShareableRoom(), + ...(events.length > 0 && { checkboxLabel: _t("share|permalink_most_recent") }), + }; } - return ( - - {this.props.subtitle && {this.props.subtitle}} - - matrixToUrl}> - - {matrixToUrl} - - - {checkbox} - {qrSocialSection} - - - ); - } + // MatrixEvent is remaining and should have a permalinkCreator + const url = linkToSpecificEvent + ? permalinkCreator!.forEvent(target.getId()!) + : permalinkCreator!.forShareableRoom(); + return { + title: _t("share|title_message"), + url, + checkboxLabel: _t("share|permalink_message"), + }; + }, [target, linkToSpecificEvent, permalinkCreator]); } diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index d0e3694ea59..c8dd0b97387 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -47,7 +47,7 @@ import RoomAvatar from "../avatars/RoomAvatar"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; import Modal from "../../../Modal"; -import ShareDialog from "../dialogs/ShareDialog"; +import { ShareDialog } from "../dialogs/ShareDialog"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { E2EStatus } from "../../../utils/ShieldUtils"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index b4a775367ce..591e2327ae4 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -63,7 +63,7 @@ import PowerSelector from "../elements/PowerSelector"; import MemberAvatar from "../avatars/MemberAvatar"; import PresenceLabel from "../rooms/PresenceLabel"; import BulkRedactDialog from "../dialogs/BulkRedactDialog"; -import ShareDialog from "../dialogs/ShareDialog"; +import { ShareDialog } from "../dialogs/ShareDialog"; import ErrorDialog from "../dialogs/ErrorDialog"; import QuestionDialog from "../dialogs/QuestionDialog"; import ConfirmUserActionDialog from "../dialogs/ConfirmUserActionDialog"; diff --git a/src/components/views/rooms/RoomHeader/CallGuestLinkButton.tsx b/src/components/views/rooms/RoomHeader/CallGuestLinkButton.tsx index ae8e7be16bf..8c000bdf3bc 100644 --- a/src/components/views/rooms/RoomHeader/CallGuestLinkButton.tsx +++ b/src/components/views/rooms/RoomHeader/CallGuestLinkButton.tsx @@ -12,7 +12,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { EventType, JoinRule, Room } from "matrix-js-sdk/src/matrix"; import Modal from "../../../../Modal"; -import ShareDialog from "../../dialogs/ShareDialog"; +import { ShareDialog } from "../../dialogs/ShareDialog"; import { _t } from "../../../../languageHandler"; import SettingsStore from "../../../../settings/SettingsStore"; import { calculateRoomVia } from "../../../../utils/permalinks/Permalinks"; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 31a6c71c1ac..e601a7ecd53 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2944,7 +2944,7 @@ "warning": "WARNING: " }, "share": { - "link_title": "Link to room", + "link_copied": "Link copied", "permalink_message": "Link to selected message", "permalink_most_recent": "Link to most recent message", "share_call": "Conference invite link", diff --git a/test/unit-tests/components/views/dialogs/ShareDialog-test.tsx b/test/unit-tests/components/views/dialogs/ShareDialog-test.tsx index cb7d556235c..c1d9883b7f6 100644 --- a/test/unit-tests/components/views/dialogs/ShareDialog-test.tsx +++ b/test/unit-tests/components/views/dialogs/ShareDialog-test.tsx @@ -7,111 +7,139 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { EventTimeline, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix"; -import { render, RenderOptions } from "jest-matrix-react"; +import { MatrixClient, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix"; +import { render, screen, act } from "jest-matrix-react"; +import userEvent from "@testing-library/user-event"; +import { waitFor } from "@testing-library/dom"; -import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import SettingsStore from "../../../../../src/settings/SettingsStore"; -import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import { _t } from "../../../../../src/languageHandler"; -import ShareDialog from "../../../../../src/components/views/dialogs/ShareDialog"; +import { ShareDialog } from "../../../../../src/components/views/dialogs/ShareDialog"; import { UIFeature } from "../../../../../src/settings/UIFeature"; -import { stubClient } from "../../../../test-utils"; -jest.mock("../../../../../src/utils/ShieldUtils"); - -function getWrapper(): RenderOptions { - return { - wrapper: ({ children }) => ( - {children} - ), - }; -} +import { stubClient, withClientContextRenderOptions } from "../../../../test-utils"; +import * as StringsModule from "../../../../../src/utils/strings"; +import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks.ts"; describe("ShareDialog", () => { + let client: MatrixClient; let room: Room; - - const ROOM_ID = "!1:example.org"; + const copyTextFunc = jest.fn(); beforeEach(async () => { - stubClient(); - room = new Room(ROOM_ID, MatrixClientPeg.get()!, "@alice:example.org"); + client = stubClient(); + room = new Room("!1:example.org", client, "@alice:example.org"); + jest.spyOn(StringsModule, "copyPlaintext").mockImplementation(copyTextFunc); }); afterEach(() => { jest.restoreAllMocks(); + copyTextFunc.mockClear(); }); - it("renders room share dialog", () => { - const { container: withoutEvents } = render(, getWrapper()); - expect(withoutEvents).toHaveTextContent(_t("share|title_room")); + function renderComponent(target: Room | RoomMember | URL) { + return render(, withClientContextRenderOptions(client)); + } + + const getUrl = () => new URL("https://matrix.org/"); + const getRoomMember = () => new RoomMember(room.roomId, "@alice:example.org"); + + test.each([ + { name: "an URL", title: "Share Link", url: "https://matrix.org/", getTarget: getUrl }, + { + name: "a room member", + title: "Share User", + url: "https://matrix.to/#/@alice:example.org", + getTarget: getRoomMember, + }, + ])("should render a share dialog for $name", async ({ title, url, getTarget }) => { + const { asFragment } = renderComponent(getTarget()); + + expect(screen.getByRole("heading", { name: title })).toBeInTheDocument(); + expect(screen.getByText(url)).toBeInTheDocument(); + expect(asFragment()).toMatchSnapshot(); - jest.spyOn(room, "getLiveTimeline").mockReturnValue({ getEvents: () => [{} as MatrixEvent] } as EventTimeline); - const { container: withEvents } = render(, getWrapper()); - expect(withEvents).toHaveTextContent(_t("share|permalink_most_recent")); + await userEvent.click(screen.getByRole("button", { name: "Copy link" })); + expect(copyTextFunc).toHaveBeenCalledWith(url); }); - it("renders user share dialog", () => { - mockRoomMembers(room, 1); - const { container } = render( - , - getWrapper(), - ); - expect(container).toHaveTextContent(_t("share|title_user")); + it("should render a share dialog for a room", async () => { + const expectedURL = "https://matrix.to/#/!1:example.org"; + jest.spyOn(room.getLiveTimeline(), "getEvents").mockReturnValue([new MatrixEvent({ event_id: "!eventId" })]); + + const { asFragment } = renderComponent(room); + expect(screen.getByRole("heading", { name: "Share Room" })).toBeInTheDocument(); + expect(screen.getByText(expectedURL)).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Link to most recent message" })).toBeInTheDocument(); + expect(asFragment()).toMatchSnapshot(); + + await userEvent.click(screen.getByRole("button", { name: "Copy link" })); + expect(copyTextFunc).toHaveBeenCalledWith(expectedURL); + + // Click on the checkbox to link to the most recent message + await userEvent.click(screen.getByRole("checkbox", { name: "Link to most recent message" })); + const newExpectedURL = "https://matrix.to/#/!1:example.org/!eventId"; + expect(screen.getByText(newExpectedURL)).toBeInTheDocument(); }); - it("renders link share dialog", () => { - mockRoomMembers(room, 1); - const { container } = render( - , - getWrapper(), + it("should render a share dialog for a matrix event", async () => { + const matrixEvent = new MatrixEvent({ event_id: "!eventId" }); + const permalinkCreator = new RoomPermalinkCreator(room); + const expectedURL = "https://matrix.to/#/!1:example.org/!eventId"; + + const { asFragment } = render( + , + withClientContextRenderOptions(client), ); - expect(container).toHaveTextContent(_t("share|title_link")); + expect(screen.getByRole("heading", { name: "Share Room Message" })).toBeInTheDocument(); + expect(screen.getByText(expectedURL)).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Link to selected message" })).toBeChecked(); + expect(asFragment()).toMatchSnapshot(); + + await userEvent.click(screen.getByRole("button", { name: "Copy link" })); + expect(copyTextFunc).toHaveBeenCalledWith(expectedURL); + + // Click on the checkbox to link to the room + await userEvent.click(screen.getByRole("checkbox", { name: "Link to selected message" })); + expect(screen.getByText("https://matrix.to/#/!1:example.org")).toBeInTheDocument(); + }); + + it("should change the copy button text when clicked", async () => { + jest.useFakeTimers(); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + // To not be bother with rtl warnings about QR code state update + jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); + + renderComponent(room); + await user.click(screen.getByRole("button", { name: "Copy link" })); + // Move after `copyPlaintext` + await jest.advanceTimersToNextTimerAsync(); + expect(screen.getByRole("button", { name: "Link copied" })).toBeInTheDocument(); + + // 2 sec after the button should be back to normal + act(() => jest.advanceTimersByTime(2000)); + await waitFor(() => expect(screen.getByRole("button", { name: "Copy link" })).toBeInTheDocument()); }); - it("renders the QR code if configured", () => { + it("should not render the QR code if disabled", () => { const originalGetValue = SettingsStore.getValue; jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => { - if (feature === UIFeature.ShareQRCode) return true; + if (feature === UIFeature.ShareQRCode) return false; return originalGetValue(feature); }); - const { container } = render(, getWrapper()); - const qrCodesVisible = container.getElementsByClassName("mx_ShareDialog_qrcode_container").length > 0; - expect(qrCodesVisible).toBe(true); + + const { asFragment } = renderComponent(room); + expect(screen.queryByRole("img", { name: "QR code" })).toBeNull(); + expect(asFragment()).toMatchSnapshot(); }); - it("renders the social button if configured", () => { + it("should not render the socials if disabled", () => { const originalGetValue = SettingsStore.getValue; jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => { - if (feature === UIFeature.ShareSocial) return true; + if (feature === UIFeature.ShareSocial) return false; return originalGetValue(feature); }); - const { container } = render(, getWrapper()); - const qrCodesVisible = container.getElementsByClassName("mx_ShareDialog_social_container").length > 0; - expect(qrCodesVisible).toBe(true); - }); - it("renders custom title and subtitle", () => { - const { container } = render( - , - getWrapper(), - ); - expect(container).toHaveTextContent("test_title_123"); - expect(container).toHaveTextContent("custom_subtitle_1234"); + + const { asFragment } = renderComponent(room); + expect(screen.queryByRole("link", { name: "Reddit" })).toBeNull(); + expect(asFragment()).toMatchSnapshot(); }); }); -/** - * - * @param count the number of users to create - */ -function mockRoomMembers(room: Room, count: number) { - const members = Array(count) - .fill(0) - .map((_, index) => new RoomMember(room.roomId, "@alice:example.org")); - - room.currentState.setJoinedMemberCount(members.length); - room.getJoinedMembers = jest.fn().mockReturnValue(members); -} diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/ShareDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/ShareDialog-test.tsx.snap new file mode 100644 index 00000000000..ab8b8ffb582 --- /dev/null +++ b/test/unit-tests/components/views/dialogs/__snapshots__/ShareDialog-test.tsx.snap @@ -0,0 +1,852 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ShareDialog should not render the QR code if disabled 1`] = ` + + + + + + Share Room + + + + + + https://matrix.to/#/!1:example.org + + + + + + + Copy link + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`ShareDialog should not render the socials if disabled 1`] = ` + + + + + + Share Room + + + + + + + + + + + https://matrix.to/#/!1:example.org + + + + + + + Copy link + + + + + + +`; + +exports[`ShareDialog should render a share dialog for a matrix event 1`] = ` + + + + + + Share Room Message + + + + + + + + + + + https://matrix.to/#/!1:example.org/!eventId + + + + + + + + + + + + Link to selected message + + + + + + Copy link + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`ShareDialog should render a share dialog for a room 1`] = ` + + + + + + Share Room + + + + + + + + + + + https://matrix.to/#/!1:example.org + + + + + + + + + + + + Link to most recent message + + + + + + Copy link + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`ShareDialog should render a share dialog for a room member 1`] = ` + + + + + + Share User + + + + + + + + + + + https://matrix.to/#/@alice:example.org + + + + + + + Copy link + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`ShareDialog should render a share dialog for an URL 1`] = ` + + + + + + Share Link + + + + + + + + + + + https://matrix.org/ + + + + + + + Copy link + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/test/unit-tests/components/views/right_panel/RoomSummaryCard-test.tsx b/test/unit-tests/components/views/right_panel/RoomSummaryCard-test.tsx index 9eeec43dbac..80d2609577e 100644 --- a/test/unit-tests/components/views/right_panel/RoomSummaryCard-test.tsx +++ b/test/unit-tests/components/views/right_panel/RoomSummaryCard-test.tsx @@ -15,7 +15,7 @@ import userEvent from "@testing-library/user-event"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; import RoomSummaryCard from "../../../../../src/components/views/right_panel/RoomSummaryCard"; -import ShareDialog from "../../../../../src/components/views/dialogs/ShareDialog"; +import { ShareDialog } from "../../../../../src/components/views/dialogs/ShareDialog"; import ExportDialog from "../../../../../src/components/views/dialogs/ExportDialog"; import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; diff --git a/test/unit-tests/components/views/right_panel/UserInfo-test.tsx b/test/unit-tests/components/views/right_panel/UserInfo-test.tsx index 38d38e2a328..172b04a77c7 100644 --- a/test/unit-tests/components/views/right_panel/UserInfo-test.tsx +++ b/test/unit-tests/components/views/right_panel/UserInfo-test.tsx @@ -49,7 +49,7 @@ import ErrorDialog from "../../../../../src/components/views/dialogs/ErrorDialog import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents"; import { UIComponent } from "../../../../../src/settings/UIFeature"; import { Action } from "../../../../../src/dispatcher/actions"; -import ShareDialog from "../../../../../src/components/views/dialogs/ShareDialog"; +import { ShareDialog } from "../../../../../src/components/views/dialogs/ShareDialog"; import BulkRedactDialog from "../../../../../src/components/views/dialogs/BulkRedactDialog"; jest.mock("../../../../../src/utils/direct-messages", () => ({ diff --git a/test/unit-tests/components/views/rooms/RoomHeader/CallGuestLinkButton-test.tsx b/test/unit-tests/components/views/rooms/RoomHeader/CallGuestLinkButton-test.tsx index cc28a4ec083..c77114fa96e 100644 --- a/test/unit-tests/components/views/rooms/RoomHeader/CallGuestLinkButton-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomHeader/CallGuestLinkButton-test.tsx @@ -19,7 +19,7 @@ import { } from "../../../../../../src/components/views/rooms/RoomHeader/CallGuestLinkButton"; import Modal from "../../../../../../src/Modal"; import SdkConfig from "../../../../../../src/SdkConfig"; -import ShareDialog from "../../../../../../src/components/views/dialogs/ShareDialog"; +import { ShareDialog } from "../../../../../../src/components/views/dialogs/ShareDialog"; import { _t } from "../../../../../../src/languageHandler"; import SettingsStore from "../../../../../../src/settings/SettingsStore";
{this.props.subtitle}