Skip to content

Commit c6c9ce0

Browse files
ara4ndbkr
authored andcommitted
/share?msg=foo endpoint using forward message dialog (element-hq#29874)
* basic implementation of an /share?msg=foo endpoint * SharePayload * add sharing html & md while we're at it * remove whitespace from imports to appease linter * lint * Add unit test * More tests * Test for showScreen * Use one of the typed strings * Test nasty tags stripped out * Add playwright test * Fix flake by not relying on the name being synced as soon as we load --------- Co-authored-by: David Baker <dbkr@users.noreply.github.com>
1 parent 43cd06f commit c6c9ce0

File tree

5 files changed

+265
-4
lines changed

5 files changed

+265
-4
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { test, expect } from "../../element-web-test";
9+
10+
test.describe("share from URL", () => {
11+
test.use({
12+
displayName: "Alice",
13+
room: async ({ app }, use) => {
14+
const roomId = await app.client.createRoom({ name: "A test room" });
15+
await use({ roomId });
16+
},
17+
});
18+
19+
test("should share message when users navigates to share URL", async ({ page, user, room, app }) => {
20+
await page.goto("/#/share?msg=Hello+world");
21+
// The forward message dialog doesn't update as new infomation arrives via sync, which means sometimes
22+
// this is just says, "Empty room". For the same reason, we can't reliably write a test for loading the
23+
// app straight away with a /#/share url as the room doesn't appear until the client syncs.]
24+
// Ideally we should fix the forward dialog to update and eliminate races, until then, there is only one
25+
// room so we click the first button.
26+
await page.getByRole("listitem" /*, { name: "A test room" }*/).getByRole("button", { name: "Send" }).click();
27+
await page.keyboard.press("Escape");
28+
await app.viewRoomByName("A test room");
29+
const lastMessage = page.locator(".mx_RoomView_MessageList .mx_EventTile_last");
30+
await expect(lastMessage).toBeVisible();
31+
const lastMessageText = await lastMessage.locator(".mx_EventTile_body").innerText();
32+
await expect(lastMessageText).toBe("Hello world");
33+
});
34+
});

src/components/structures/MatrixChat.tsx

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import {
1313
EventType,
1414
HttpApiEvent,
1515
type MatrixClient,
16-
type MatrixEvent,
16+
MatrixEvent,
17+
MsgType,
1718
type RoomType,
1819
SyncState,
1920
type SyncStateData,
@@ -24,9 +25,9 @@ import { logger } from "matrix-js-sdk/src/logger";
2425
import { throttle } from "lodash";
2526
import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
2627
import { TooltipProvider } from "@vector-im/compound-web";
27-
2828
// what-input helps improve keyboard accessibility
2929
import "what-input";
30+
import sanitizeHtml from "sanitize-html";
3031

3132
import PosthogTrackers from "../../PosthogTrackers";
3233
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
@@ -50,6 +51,7 @@ import ThemeController from "../../settings/controllers/ThemeController";
5051
import { startAnyRegistrationFlow } from "../../Registration";
5152
import ResizeNotifier from "../../utils/ResizeNotifier";
5253
import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils";
54+
import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
5355
import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher";
5456
import { FontWatcher } from "../../settings/watchers/FontWatcher";
5557
import { storeRoomAliasInCache } from "../../RoomAliasCache";
@@ -94,7 +96,6 @@ import VerificationRequestToast from "../views/toasts/VerificationRequestToast";
9496
import PerformanceMonitor, { PerformanceEntryNames } from "../../performance";
9597
import UIStore, { UI_EVENTS } from "../../stores/UIStore";
9698
import SoftLogout from "./auth/SoftLogout";
97-
import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
9899
import { copyPlaintext } from "../../utils/strings";
99100
import { PosthogAnalytics } from "../../PosthogAnalytics";
100101
import { initSentry } from "../../sentry";
@@ -124,7 +125,7 @@ import { viewUserDeviceSettings } from "../../actions/handlers/viewUserDeviceSet
124125
import GenericToast from "../views/toasts/GenericToast";
125126
import RovingSpotlightDialog from "../views/dialogs/spotlight/SpotlightDialog";
126127
import { findDMForUser } from "../../utils/dm/findDMForUser";
127-
import { Linkify } from "../../HtmlUtils";
128+
import { getHtmlText, Linkify } from "../../HtmlUtils";
128129
import { NotificationLevel } from "../../stores/notifications/NotificationLevel";
129130
import { type UserTab } from "../views/dialogs/UserTab";
130131
import { shouldSkipSetupEncryption } from "../../utils/crypto/shouldSkipSetupEncryption";
@@ -136,6 +137,10 @@ import { LoginSplashView } from "./auth/LoginSplashView";
136137
import { cleanUpDraftsIfRequired } from "../../DraftCleaner";
137138
import { InitialCryptoSetupStore } from "../../stores/InitialCryptoSetupStore";
138139
import { setTheme } from "../../theme";
140+
import { type OpenForwardDialogPayload } from "../../dispatcher/payloads/OpenForwardDialogPayload";
141+
import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/SharePayload";
142+
import Markdown from "../../Markdown";
143+
import { sanitizeHtmlParams } from "../../Linkify";
139144

140145
// legacy export
141146
export { default as Views } from "../../Views";
@@ -780,6 +785,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
780785
case Action.ViewHomePage:
781786
this.viewHome(payload.justRegistered);
782787
break;
788+
case Action.Share:
789+
this.viewShare(payload.format, payload.msg);
790+
break;
783791
case Action.ViewStartChatOrReuse:
784792
this.chatCreateOrReuse(payload.user_id);
785793
break;
@@ -1115,6 +1123,58 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
11151123
});
11161124
}
11171125

1126+
private viewShare(format: ShareFormat, msg: string): void {
1127+
// Wait for the first sync so we can present possible rooms to share into
1128+
this.firstSyncPromise.promise.then(() => {
1129+
this.notifyNewScreen("share");
1130+
let rawEvent;
1131+
switch (format) {
1132+
case ShareFormat.Html: {
1133+
rawEvent = {
1134+
type: "m.room.message",
1135+
content: {
1136+
msgtype: MsgType.Text,
1137+
body: getHtmlText(msg),
1138+
format: "org.matrix.custom.html",
1139+
formatted_body: sanitizeHtml(msg, sanitizeHtmlParams),
1140+
},
1141+
origin_server_ts: Date.now(),
1142+
};
1143+
break;
1144+
}
1145+
case ShareFormat.Markdown: {
1146+
const html = new Markdown(msg).toHTML({ externalLinks: true });
1147+
rawEvent = {
1148+
type: "m.room.message",
1149+
content: {
1150+
msgtype: MsgType.Text,
1151+
body: msg,
1152+
format: "org.matrix.custom.html",
1153+
formatted_body: html,
1154+
},
1155+
origin_server_ts: Date.now(),
1156+
};
1157+
break;
1158+
}
1159+
default:
1160+
rawEvent = {
1161+
type: "m.room.message",
1162+
content: {
1163+
msgtype: MsgType.Text,
1164+
body: msg,
1165+
},
1166+
origin_server_ts: Date.now(),
1167+
};
1168+
}
1169+
const event = new MatrixEvent(rawEvent);
1170+
dis.dispatch<OpenForwardDialogPayload>({
1171+
action: Action.OpenForwardDialog,
1172+
event: event,
1173+
permalinkCreator: null,
1174+
});
1175+
});
1176+
}
1177+
11181178
private async createRoom(defaultPublic = false, defaultName?: string, type?: RoomType): Promise<void> {
11191179
const modal = Modal.createDialog(CreateRoomDialog, {
11201180
type,
@@ -1742,6 +1802,20 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
17421802
dis.dispatch({
17431803
action: Action.CreateChat,
17441804
});
1805+
} else if (screen === "share") {
1806+
if (params && params["msg"] !== undefined) {
1807+
dis.dispatch<SharePayload>({
1808+
action: Action.Share,
1809+
msg: params["msg"],
1810+
format: params["format"],
1811+
});
1812+
}
1813+
// if we weren't already coming at this from an existing screen
1814+
// and we're logged in, then explicitly default to home.
1815+
// if we're not logged in, then the login flow will do the right thing.
1816+
if (!this.state.currentRoomId && !this.state.currentUserId) {
1817+
this.viewHome();
1818+
}
17451819
} else if (screen === "settings") {
17461820
dis.fire(Action.ViewUserSettings);
17471821
} else if (screen === "welcome") {

src/dispatcher/actions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ export enum Action {
2626
*/
2727
ViewUser = "view_user",
2828

29+
/**
30+
* Share a text message by forwarding it to a room selected by the user
31+
*/
32+
Share = "share",
33+
2934
/**
3035
* Open the user settings. No additional payload information required.
3136
* Optionally can include an OpenToTabPayload.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { type ActionPayload } from "../payloads";
9+
import { type Action } from "../actions";
10+
11+
export enum ShareFormat {
12+
Text = "text",
13+
Html = "html",
14+
Markdown = "md",
15+
}
16+
17+
export interface SharePayload extends ActionPayload {
18+
action: Action.Share;
19+
20+
/**
21+
* The format of message to be shared (optional)
22+
*/
23+
format: ShareFormat;
24+
25+
/**
26+
* The message to be shared.
27+
*/
28+
msg: string;
29+
}

test/unit-tests/components/structures/MatrixChat-test.tsx

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import AutoDiscoveryUtils from "../../../../src/utils/AutoDiscoveryUtils";
6868
import { type ValidatedServerConfig } from "../../../../src/utils/ValidatedServerConfig";
6969
import Modal from "../../../../src/Modal.tsx";
7070
import { SetupEncryptionStore } from "../../../../src/stores/SetupEncryptionStore.ts";
71+
import { ShareFormat } from "../../../../src/dispatcher/payloads/SharePayload.ts";
7172
import { clearStorage } from "../../../../src/Lifecycle";
7273
import RoomListStore from "../../../../src/stores/room-list/RoomListStore.ts";
7374

@@ -813,6 +814,108 @@ describe("<MatrixChat />", () => {
813814
});
814815
});
815816
});
817+
818+
it("should open forward dialog when text message shared", async () => {
819+
await getComponentAndWaitForReady();
820+
defaultDispatcher.dispatch({ action: Action.Share, format: ShareFormat.Text, msg: "Hello world" });
821+
await waitFor(() => {
822+
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({
823+
action: Action.OpenForwardDialog,
824+
event: expect.any(MatrixEvent),
825+
permalinkCreator: null,
826+
});
827+
});
828+
const forwardCall = mocked(defaultDispatcher.dispatch).mock.calls.find(
829+
([call]) => call.action === Action.OpenForwardDialog,
830+
);
831+
832+
const payload = forwardCall?.[0];
833+
834+
expect(payload!.event.getContent()).toEqual({
835+
msgtype: MatrixJs.MsgType.Text,
836+
body: "Hello world",
837+
});
838+
});
839+
840+
it("should open forward dialog when html message shared", async () => {
841+
await getComponentAndWaitForReady();
842+
defaultDispatcher.dispatch({ action: Action.Share, format: ShareFormat.Html, msg: "Hello world" });
843+
await waitFor(() => {
844+
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({
845+
action: Action.OpenForwardDialog,
846+
event: expect.any(MatrixEvent),
847+
permalinkCreator: null,
848+
});
849+
});
850+
const forwardCall = mocked(defaultDispatcher.dispatch).mock.calls.find(
851+
([call]) => call.action === Action.OpenForwardDialog,
852+
);
853+
854+
const payload = forwardCall?.[0];
855+
856+
expect(payload!.event.getContent()).toEqual({
857+
msgtype: MatrixJs.MsgType.Text,
858+
format: "org.matrix.custom.html",
859+
body: expect.stringContaining("Hello world"),
860+
formatted_body: expect.stringContaining("Hello world"),
861+
});
862+
});
863+
864+
it("should open forward dialog when markdown message shared", async () => {
865+
await getComponentAndWaitForReady();
866+
defaultDispatcher.dispatch({
867+
action: Action.Share,
868+
format: ShareFormat.Markdown,
869+
msg: "Hello *world*",
870+
});
871+
await waitFor(() => {
872+
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({
873+
action: Action.OpenForwardDialog,
874+
event: expect.any(MatrixEvent),
875+
permalinkCreator: null,
876+
});
877+
});
878+
const forwardCall = mocked(defaultDispatcher.dispatch).mock.calls.find(
879+
([call]) => call.action === Action.OpenForwardDialog,
880+
);
881+
882+
const payload = forwardCall?.[0];
883+
884+
expect(payload!.event.getContent()).toEqual({
885+
msgtype: MatrixJs.MsgType.Text,
886+
format: "org.matrix.custom.html",
887+
body: "Hello *world*",
888+
formatted_body: "Hello <em>world</em>",
889+
});
890+
});
891+
892+
it("should strip malicious tags from shared html message", async () => {
893+
await getComponentAndWaitForReady();
894+
defaultDispatcher.dispatch({
895+
action: Action.Share,
896+
format: ShareFormat.Html,
897+
msg: `evil<script src="http://evil.dummy/bad.js" />`,
898+
});
899+
await waitFor(() => {
900+
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({
901+
action: Action.OpenForwardDialog,
902+
event: expect.any(MatrixEvent),
903+
permalinkCreator: null,
904+
});
905+
});
906+
const forwardCall = mocked(defaultDispatcher.dispatch).mock.calls.find(
907+
([call]) => call.action === Action.OpenForwardDialog,
908+
);
909+
910+
const payload = forwardCall?.[0];
911+
912+
expect(payload!.event.getContent()).toEqual({
913+
msgtype: MatrixJs.MsgType.Text,
914+
format: "org.matrix.custom.html",
915+
body: "evil",
916+
formatted_body: "evil",
917+
});
918+
});
816919
});
817920

818921
describe("logout", () => {
@@ -1004,6 +1107,22 @@ describe("<MatrixChat />", () => {
10041107
} as any;
10051108
}
10061109
});
1110+
1111+
describe("showScreen", () => {
1112+
it("should show the 'share' screen", async () => {
1113+
await getComponent({
1114+
initialScreenAfterLogin: { screen: "share", params: { msg: "Hello", format: ShareFormat.Text } },
1115+
});
1116+
1117+
await waitFor(() => {
1118+
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({
1119+
action: "share",
1120+
msg: "Hello",
1121+
format: ShareFormat.Text,
1122+
});
1123+
});
1124+
});
1125+
});
10071126
});
10081127

10091128
describe("with a soft-logged-out session", () => {

0 commit comments

Comments
 (0)