diff --git a/playwright/e2e/share-dialog/share-by-url.spec.ts b/playwright/e2e/share-dialog/share-by-url.spec.ts new file mode 100644 index 00000000000..c5b9174782f --- /dev/null +++ b/playwright/e2e/share-dialog/share-by-url.spec.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { test, expect } from "../../element-web-test"; + +test.describe("share from URL", () => { + test.use({ + displayName: "Alice", + room: async ({ app }, use) => { + const roomId = await app.client.createRoom({ name: "A test room" }); + await use({ roomId }); + }, + }); + + test("should share message when users navigates to share URL", async ({ page, user, room, app }) => { + await page.goto("/#/share?msg=Hello+world"); + // The forward message dialog doesn't update as new infomation arrives via sync, which means sometimes + // this is just says, "Empty room". For the same reason, we can't reliably write a test for loading the + // app straight away with a /#/share url as the room doesn't appear until the client syncs.] + // Ideally we should fix the forward dialog to update and eliminate races, until then, there is only one + // room so we click the first button. + await page.getByRole("listitem" /*, { name: "A test room" }*/).getByRole("button", { name: "Send" }).click(); + await page.keyboard.press("Escape"); + await app.viewRoomByName("A test room"); + const lastMessage = page.locator(".mx_RoomView_MessageList .mx_EventTile_last"); + await expect(lastMessage).toBeVisible(); + const lastMessageText = await lastMessage.locator(".mx_EventTile_body").innerText(); + await expect(lastMessageText).toBe("Hello world"); + }); +}); diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 761b7cf0e0b..06992dff6c8 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -13,7 +13,8 @@ import { EventType, HttpApiEvent, type MatrixClient, - type MatrixEvent, + MatrixEvent, + MsgType, type RoomType, SyncState, type SyncStateData, @@ -24,9 +25,9 @@ import { logger } from "matrix-js-sdk/src/logger"; import { throttle } from "lodash"; import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; import { TooltipProvider } from "@vector-im/compound-web"; - // what-input helps improve keyboard accessibility import "what-input"; +import sanitizeHtml from "sanitize-html"; import PosthogTrackers from "../../PosthogTrackers"; import { DecryptionFailureTracker } from "../../DecryptionFailureTracker"; @@ -50,6 +51,7 @@ import ThemeController from "../../settings/controllers/ThemeController"; import { startAnyRegistrationFlow } from "../../Registration"; import ResizeNotifier from "../../utils/ResizeNotifier"; import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils"; +import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher"; import { FontWatcher } from "../../settings/watchers/FontWatcher"; import { storeRoomAliasInCache } from "../../RoomAliasCache"; @@ -94,7 +96,6 @@ import VerificationRequestToast from "../views/toasts/VerificationRequestToast"; import PerformanceMonitor, { PerformanceEntryNames } from "../../performance"; import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import SoftLogout from "./auth/SoftLogout"; -import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import { copyPlaintext } from "../../utils/strings"; import { PosthogAnalytics } from "../../PosthogAnalytics"; import { initSentry } from "../../sentry"; @@ -123,7 +124,7 @@ import { viewUserDeviceSettings } from "../../actions/handlers/viewUserDeviceSet import GenericToast from "../views/toasts/GenericToast"; import RovingSpotlightDialog from "../views/dialogs/spotlight/SpotlightDialog"; import { findDMForUser } from "../../utils/dm/findDMForUser"; -import { Linkify } from "../../HtmlUtils"; +import { getHtmlText, Linkify } from "../../HtmlUtils"; import { NotificationLevel } from "../../stores/notifications/NotificationLevel"; import { type UserTab } from "../views/dialogs/UserTab"; import { shouldSkipSetupEncryption } from "../../utils/crypto/shouldSkipSetupEncryption"; @@ -135,6 +136,10 @@ import { LoginSplashView } from "./auth/LoginSplashView"; import { cleanUpDraftsIfRequired } from "../../DraftCleaner"; import { InitialCryptoSetupStore } from "../../stores/InitialCryptoSetupStore"; import { setTheme } from "../../theme"; +import { type OpenForwardDialogPayload } from "../../dispatcher/payloads/OpenForwardDialogPayload"; +import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/SharePayload"; +import Markdown from "../../Markdown"; +import { sanitizeHtmlParams } from "../../Linkify"; // legacy export export { default as Views } from "../../Views"; @@ -779,6 +784,9 @@ export default class MatrixChat extends React.PureComponent { case Action.ViewHomePage: this.viewHome(payload.justRegistered); break; + case Action.Share: + this.viewShare(payload.format, payload.msg); + break; case Action.ViewStartChatOrReuse: this.chatCreateOrReuse(payload.user_id); break; @@ -1114,6 +1122,58 @@ export default class MatrixChat extends React.PureComponent { }); } + private viewShare(format: ShareFormat, msg: string): void { + // Wait for the first sync so we can present possible rooms to share into + this.firstSyncPromise.promise.then(() => { + this.notifyNewScreen("share"); + let rawEvent; + switch (format) { + case ShareFormat.Html: { + rawEvent = { + type: "m.room.message", + content: { + msgtype: MsgType.Text, + body: getHtmlText(msg), + format: "org.matrix.custom.html", + formatted_body: sanitizeHtml(msg, sanitizeHtmlParams), + }, + origin_server_ts: Date.now(), + }; + break; + } + case ShareFormat.Markdown: { + const html = new Markdown(msg).toHTML({ externalLinks: true }); + rawEvent = { + type: "m.room.message", + content: { + msgtype: MsgType.Text, + body: msg, + format: "org.matrix.custom.html", + formatted_body: html, + }, + origin_server_ts: Date.now(), + }; + break; + } + default: + rawEvent = { + type: "m.room.message", + content: { + msgtype: MsgType.Text, + body: msg, + }, + origin_server_ts: Date.now(), + }; + } + const event = new MatrixEvent(rawEvent); + dis.dispatch({ + action: Action.OpenForwardDialog, + event: event, + permalinkCreator: null, + }); + }); + } + private async createRoom(defaultPublic = false, defaultName?: string, type?: RoomType): Promise { const modal = Modal.createDialog(CreateRoomDialog, { type, @@ -1739,6 +1799,20 @@ export default class MatrixChat extends React.PureComponent { dis.dispatch({ action: Action.CreateChat, }); + } else if (screen === "share") { + if (params && params["msg"] !== undefined) { + dis.dispatch({ + action: Action.Share, + msg: params["msg"], + format: params["format"], + }); + } + // if we weren't already coming at this from an existing screen + // and we're logged in, then explicitly default to home. + // if we're not logged in, then the login flow will do the right thing. + if (!this.state.currentRoomId && !this.state.currentUserId) { + this.viewHome(); + } } else if (screen === "settings") { dis.fire(Action.ViewUserSettings); } else if (screen === "welcome") { diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 41021f956a3..b39f546ef29 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -26,6 +26,11 @@ export enum Action { */ ViewUser = "view_user", + /** + * Share a text message by forwarding it to a room selected by the user + */ + Share = "share", + /** * Open the user settings. No additional payload information required. * Optionally can include an OpenToTabPayload. diff --git a/src/dispatcher/payloads/SharePayload.ts b/src/dispatcher/payloads/SharePayload.ts new file mode 100644 index 00000000000..0aa44fe12f7 --- /dev/null +++ b/src/dispatcher/payloads/SharePayload.ts @@ -0,0 +1,29 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type ActionPayload } from "../payloads"; +import { type Action } from "../actions"; + +export enum ShareFormat { + Text = "text", + Html = "html", + Markdown = "md", +} + +export interface SharePayload extends ActionPayload { + action: Action.Share; + + /** + * The format of message to be shared (optional) + */ + format: ShareFormat; + + /** + * The message to be shared. + */ + msg: string; +} diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index 5c10e6b465a..a5f97eef0e8 100644 --- a/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -68,6 +68,7 @@ import AutoDiscoveryUtils from "../../../../src/utils/AutoDiscoveryUtils"; import { type ValidatedServerConfig } from "../../../../src/utils/ValidatedServerConfig"; import Modal from "../../../../src/Modal.tsx"; import { SetupEncryptionStore } from "../../../../src/stores/SetupEncryptionStore.ts"; +import { ShareFormat } from "../../../../src/dispatcher/payloads/SharePayload.ts"; import { clearStorage } from "../../../../src/Lifecycle"; jest.mock("matrix-js-sdk/src/oidc/authorize", () => ({ @@ -783,6 +784,108 @@ describe("", () => { }); }); }); + + it("should open forward dialog when text message shared", async () => { + await getComponentAndWaitForReady(); + defaultDispatcher.dispatch({ action: Action.Share, format: ShareFormat.Text, msg: "Hello world" }); + await waitFor(() => { + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ + action: Action.OpenForwardDialog, + event: expect.any(MatrixEvent), + permalinkCreator: null, + }); + }); + const forwardCall = mocked(defaultDispatcher.dispatch).mock.calls.find( + ([call]) => call.action === Action.OpenForwardDialog, + ); + + const payload = forwardCall?.[0]; + + expect(payload!.event.getContent()).toEqual({ + msgtype: MatrixJs.MsgType.Text, + body: "Hello world", + }); + }); + + it("should open forward dialog when html message shared", async () => { + await getComponentAndWaitForReady(); + defaultDispatcher.dispatch({ action: Action.Share, format: ShareFormat.Html, msg: "Hello world" }); + await waitFor(() => { + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ + action: Action.OpenForwardDialog, + event: expect.any(MatrixEvent), + permalinkCreator: null, + }); + }); + const forwardCall = mocked(defaultDispatcher.dispatch).mock.calls.find( + ([call]) => call.action === Action.OpenForwardDialog, + ); + + const payload = forwardCall?.[0]; + + expect(payload!.event.getContent()).toEqual({ + msgtype: MatrixJs.MsgType.Text, + format: "org.matrix.custom.html", + body: expect.stringContaining("Hello world"), + formatted_body: expect.stringContaining("Hello world"), + }); + }); + + it("should open forward dialog when markdown message shared", async () => { + await getComponentAndWaitForReady(); + defaultDispatcher.dispatch({ + action: Action.Share, + format: ShareFormat.Markdown, + msg: "Hello *world*", + }); + await waitFor(() => { + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ + action: Action.OpenForwardDialog, + event: expect.any(MatrixEvent), + permalinkCreator: null, + }); + }); + const forwardCall = mocked(defaultDispatcher.dispatch).mock.calls.find( + ([call]) => call.action === Action.OpenForwardDialog, + ); + + const payload = forwardCall?.[0]; + + expect(payload!.event.getContent()).toEqual({ + msgtype: MatrixJs.MsgType.Text, + format: "org.matrix.custom.html", + body: "Hello *world*", + formatted_body: "Hello world", + }); + }); + + it("should strip malicious tags from shared html message", async () => { + await getComponentAndWaitForReady(); + defaultDispatcher.dispatch({ + action: Action.Share, + format: ShareFormat.Html, + msg: `evil