Compare commits

...

13 Commits

Author SHA1 Message Date
David Baker
47d12136b7 Fix flake
by not relying on the name being synced as soon as we load
2025-05-22 13:46:37 +01:00
David Baker
e121377bcf Add playwright test 2025-05-22 13:23:31 +01:00
David Baker
5b9944474c Test nasty tags stripped out 2025-05-19 16:29:10 +01:00
David Baker
2f72104302 Use one of the typed strings 2025-05-19 16:26:39 +01:00
David Baker
a713b5c9b6 Test for showScreen 2025-05-15 11:13:01 +01:00
David Baker
a597cd0d6f More tests 2025-05-14 15:16:44 +01:00
David Baker
1f9ee07b02 Merge branch 'develop' into matthew/share 2025-05-14 15:00:13 +01:00
David Baker
46dbb8159d Add unit test 2025-05-14 14:54:49 +01:00
David Baker
0bee6ef187 lint 2025-05-14 14:05:34 +01:00
Matthew Hodgson
0bee060dbc remove whitespace from imports to appease linter 2025-05-14 13:28:07 +01:00
Matthew Hodgson
302baa061f add sharing html & md while we're at it 2025-05-14 13:15:17 +01:00
Matthew Hodgson
21e79710c6 SharePayload 2025-05-02 20:50:42 +01:00
Matthew Hodgson
0948475192 basic implementation of an /share?msg=foo endpoint 2025-05-02 20:48:13 +01:00
5 changed files with 265 additions and 4 deletions

View File

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

View File

@@ -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<IProps, IState> {
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<IProps, IState> {
});
}
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<OpenForwardDialogPayload>({
action: Action.OpenForwardDialog,
event: event,
permalinkCreator: null,
});
});
}
private async createRoom(defaultPublic = false, defaultName?: string, type?: RoomType): Promise<void> {
const modal = Modal.createDialog(CreateRoomDialog, {
type,
@@ -1739,6 +1799,20 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
dis.dispatch({
action: Action.CreateChat,
});
} else if (screen === "share") {
if (params && params["msg"] !== undefined) {
dis.dispatch<SharePayload>({
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") {

View File

@@ -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.

View File

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

View File

@@ -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("<MatrixChat />", () => {
});
});
});
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 <em>world</em>",
});
});
it("should strip malicious tags from shared html message", async () => {
await getComponentAndWaitForReady();
defaultDispatcher.dispatch({
action: Action.Share,
format: ShareFormat.Html,
msg: `evil<script src="http://evil.dummy/bad.js" />`,
});
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: "evil",
formatted_body: "evil",
});
});
});
describe("logout", () => {
@@ -974,6 +1077,22 @@ describe("<MatrixChat />", () => {
} as any;
}
});
describe("showScreen", () => {
it("should show the 'share' screen", async () => {
await getComponent({
initialScreenAfterLogin: { screen: "share", params: { msg: "Hello", format: ShareFormat.Text } },
});
await waitFor(() => {
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({
action: "share",
msg: "Hello",
format: ShareFormat.Text,
});
});
});
});
});
describe("with a soft-logged-out session", () => {