From 8a97e5f35176c1a2b659b72f90a60d7a317875f3 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 7 Jul 2023 08:40:25 -0600 Subject: [PATCH] Expose and pre-populate thread ID in devtools dialog (#10953) Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- src/SlashCommands.tsx | 92 +++++++------ .../views/dialogs/DevtoolsDialog.tsx | 14 +- .../views/dialogs/devtools/BaseTool.tsx | 1 + .../views/dialogs/devtools/Event.tsx | 7 + src/i18n/strings/en_EN.json | 1 + .../views/dialogs/DevtoolsDialog-test.tsx | 22 ++- .../views/dialogs/devtools/Event-test.tsx | 71 ++++++++++ .../__snapshots__/Event-test.tsx.snap | 126 ++++++++++++++++++ 8 files changed, 286 insertions(+), 48 deletions(-) create mode 100644 test/components/views/dialogs/devtools/Event-test.tsx create mode 100644 test/components/views/dialogs/devtools/__snapshots__/Event-test.tsx.snap diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 52eadd3822..de2ec6adbc 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -113,7 +113,13 @@ export const CommandCategories = { export type RunResult = XOR<{ error: Error }, { promise: Promise }>; -type RunFn = (this: Command, matrixClient: MatrixClient, roomId: string, args?: string) => RunResult; +type RunFn = ( + this: Command, + matrixClient: MatrixClient, + roomId: string, + threadId: string | null, + args?: string, +) => RunResult; interface ICommandOpts { command: string; @@ -184,7 +190,7 @@ export class Command { }); } - return this.runFn(matrixClient, roomId, args); + return this.runFn(matrixClient, roomId, threadId, args); } public getUsage(): string { @@ -232,7 +238,7 @@ export const Commands = [ command: "spoiler", args: "", description: _td("Sends the given message as a spoiler"), - runFn: function (cli, roomId, message = "") { + runFn: function (cli, roomId, threadId, message = "") { return successSync(ContentHelpers.makeHtmlMessage(message, `${message}`)); }, category: CommandCategories.messages, @@ -241,7 +247,7 @@ export const Commands = [ command: "shrug", args: "", description: _td("Prepends ¯\\_(ツ)_/¯ to a plain-text message"), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { let message = "¯\\_(ツ)_/¯"; if (args) { message = message + " " + args; @@ -254,7 +260,7 @@ export const Commands = [ command: "tableflip", args: "", description: _td("Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message"), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { let message = "(╯°□°)╯︵ ┻━┻"; if (args) { message = message + " " + args; @@ -267,7 +273,7 @@ export const Commands = [ command: "unflip", args: "", description: _td("Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message"), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { let message = "┬──┬ ノ( ゜-゜ノ)"; if (args) { message = message + " " + args; @@ -280,7 +286,7 @@ export const Commands = [ command: "lenny", args: "", description: _td("Prepends ( ͡° ͜ʖ ͡°) to a plain-text message"), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { let message = "( ͡° ͜ʖ ͡°)"; if (args) { message = message + " " + args; @@ -293,7 +299,7 @@ export const Commands = [ command: "plain", args: "", description: _td("Sends a message as plain text, without interpreting it as markdown"), - runFn: function (cli, roomId, messages = "") { + runFn: function (cli, roomId, threadId, messages = "") { return successSync(ContentHelpers.makeTextMessage(messages)); }, category: CommandCategories.messages, @@ -302,7 +308,7 @@ export const Commands = [ command: "html", args: "", description: _td("Sends a message as html, without interpreting it as markdown"), - runFn: function (cli, roomId, messages = "") { + runFn: function (cli, roomId, threadId, messages = "") { return successSync(ContentHelpers.makeHtmlMessage(messages, messages)); }, category: CommandCategories.messages, @@ -312,7 +318,7 @@ export const Commands = [ args: "", description: _td("Upgrades a room to a new version"), isEnabled: (cli) => !isCurrentLocalRoom(cli), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (args) { const room = cli.getRoom(roomId); if (!room?.currentState.mayClientSendStateEvent("m.room.tombstone", cli)) { @@ -346,7 +352,7 @@ export const Commands = [ args: "", description: _td("Jump to the given date in the timeline"), isEnabled: () => SettingsStore.getValue("feature_jump_to_date"), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (args) { return success( (async (): Promise => { @@ -387,7 +393,7 @@ export const Commands = [ command: "nick", args: "", description: _td("Changes your display nickname"), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (args) { return success(cli.setDisplayName(args)); } @@ -402,7 +408,7 @@ export const Commands = [ args: "", description: _td("Changes your display nickname in the current room only"), isEnabled: (cli) => !isCurrentLocalRoom(cli), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (args) { const ev = cli.getRoom(roomId)?.currentState.getStateEvents("m.room.member", cli.getSafeUserId()); const content = { @@ -421,7 +427,7 @@ export const Commands = [ args: "[]", description: _td("Changes the avatar of the current room"), isEnabled: (cli) => !isCurrentLocalRoom(cli), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { let promise = Promise.resolve(args ?? null); if (!args) { promise = singleMxcUpload(cli); @@ -442,7 +448,7 @@ export const Commands = [ args: "[]", description: _td("Changes your profile picture in this current room only"), isEnabled: (cli) => !isCurrentLocalRoom(cli), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { const room = cli.getRoom(roomId); const userId = cli.getSafeUserId(); @@ -470,7 +476,7 @@ export const Commands = [ command: "myavatar", args: "[]", description: _td("Changes your profile picture in all rooms"), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { let promise = Promise.resolve(args ?? null); if (!args) { promise = singleMxcUpload(cli); @@ -491,7 +497,7 @@ export const Commands = [ args: "[]", description: _td("Gets or sets the room topic"), isEnabled: (cli) => !isCurrentLocalRoom(cli), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (args) { const html = htmlSerializeFromMdIfNeeded(args, { forceHTML: false }); return success(cli.setRoomTopic(roomId, args, html)); @@ -529,7 +535,7 @@ export const Commands = [ args: "", description: _td("Sets the room name"), isEnabled: (cli) => !isCurrentLocalRoom(cli), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (args) { return success(cli.setRoomName(roomId, args)); } @@ -544,7 +550,7 @@ export const Commands = [ description: _td("Invites user with given id to current room"), analyticsName: "Invite", isEnabled: (cli) => !isCurrentLocalRoom(cli) && shouldShowComponent(UIComponent.InviteUsers), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (args) { const [address, reason] = args.split(/\s+(.+)/); if (address) { @@ -621,7 +627,7 @@ export const Commands = [ aliases: ["j", "goto"], args: "", description: _td("Joins room with given address"), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (args) { // Note: we support 2 versions of this command. The first is // the public-facing one for most users and the other is a @@ -734,7 +740,7 @@ export const Commands = [ description: _td("Leave room"), analyticsName: "Part", isEnabled: (cli) => !isCurrentLocalRoom(cli), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { let targetRoomId: string | undefined; if (args) { const matches = args.match(/^(\S+)$/); @@ -774,7 +780,7 @@ export const Commands = [ args: " [reason]", description: _td("Removes user with given id from this room"), isEnabled: (cli) => !isCurrentLocalRoom(cli), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (args) { const matches = args.match(/^(\S+?)( +(.*))?$/); if (matches) { @@ -791,7 +797,7 @@ export const Commands = [ args: " [reason]", description: _td("Bans user with given id"), isEnabled: (cli) => !isCurrentLocalRoom(cli), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (args) { const matches = args.match(/^(\S+?)( +(.*))?$/); if (matches) { @@ -808,7 +814,7 @@ export const Commands = [ args: "", description: _td("Unbans user with given ID"), isEnabled: (cli) => !isCurrentLocalRoom(cli), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (args) { const matches = args.match(/^(\S+)$/); if (matches) { @@ -825,7 +831,7 @@ export const Commands = [ command: "ignore", args: "", description: _td("Ignores a user, hiding their messages from you"), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (args) { const matches = args.match(/^(@[^:]+:\S+)$/); if (matches) { @@ -854,7 +860,7 @@ export const Commands = [ command: "unignore", args: "", description: _td("Stops ignoring a user, showing their messages going forward"), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (args) { const matches = args.match(/(^@[^:]+:\S+$)/); if (matches) { @@ -885,7 +891,7 @@ export const Commands = [ args: " []", description: _td("Define the power level of a user"), isEnabled: canAffectPowerlevels, - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (args) { const matches = args.match(/^(\S+?)( +(-?\d+))?$/); let powerLevel = 50; // default power level for op @@ -926,7 +932,7 @@ export const Commands = [ args: "", description: _td("Deops user with given id"), isEnabled: canAffectPowerlevels, - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (args) { const matches = args.match(/^(\S+)$/); if (matches) { @@ -955,8 +961,8 @@ export const Commands = [ new Command({ command: "devtools", description: _td("Opens the Developer Tools dialog"), - runFn: function (cli, roomId) { - Modal.createDialog(DevtoolsDialog, { roomId }, "mx_DevtoolsDialog_wrapper"); + runFn: function (cli, roomId, threadRootId) { + Modal.createDialog(DevtoolsDialog, { roomId, threadRootId }, "mx_DevtoolsDialog_wrapper"); return success(); }, category: CommandCategories.advanced, @@ -969,7 +975,7 @@ export const Commands = [ SettingsStore.getValue(UIFeature.Widgets) && shouldShowComponent(UIComponent.AddIntegrations) && !isCurrentLocalRoom(cli), - runFn: function (cli, roomId, widgetUrl) { + runFn: function (cli, roomId, threadId, widgetUrl) { if (!widgetUrl) { return reject(new UserFriendlyError("Please supply a widget URL or embed code")); } @@ -1022,7 +1028,7 @@ export const Commands = [ command: "verify", args: " ", description: _td("Verifies a user, session, and pubkey tuple"), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (args) { const matches = args.match(/^(\S+) +(\S+) +(\S+)$/); if (matches) { @@ -1144,7 +1150,7 @@ export const Commands = [ command: "rainbow", description: _td("Sends the given message coloured as a rainbow"), args: "", - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (!args) return reject(this.getUsage()); return successSync(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args))); }, @@ -1154,7 +1160,7 @@ export const Commands = [ command: "rainbowme", description: _td("Sends the given emote coloured as a rainbow"), args: "", - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (!args) return reject(this.getUsage()); return successSync(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args))); }, @@ -1174,7 +1180,7 @@ export const Commands = [ description: _td("Displays information about a user"), args: "", isEnabled: (cli) => !isCurrentLocalRoom(cli), - runFn: function (cli, roomId, userId) { + runFn: function (cli, roomId, threadId, userId) { if (!userId || !userId.startsWith("@") || !userId.includes(":")) { return reject(this.getUsage()); } @@ -1195,7 +1201,7 @@ export const Commands = [ description: _td("Send a bug report with logs"), isEnabled: () => !!SdkConfig.get().bug_report_endpoint_url, args: "", - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { return success( Modal.createDialog(BugReportDialog, { initialText: args, @@ -1230,7 +1236,7 @@ export const Commands = [ command: "query", description: _td("Opens chat with the given user"), args: "", - runFn: function (cli, roomId, userId) { + runFn: function (cli, roomId, threadId, userId) { // easter-egg for now: look up phone numbers through the thirdparty API // (very dumb phone number detection...) const isPhoneNumber = userId && /^\+?[0123456789]+$/.test(userId); @@ -1266,7 +1272,7 @@ export const Commands = [ command: "msg", description: _td("Sends a message to the given user"), args: " []", - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { if (args) { // matches the first whitespace delimited group and then the rest of the string const matches = args.match(/^(\S+?)(?: +(.*))?$/s); @@ -1302,7 +1308,7 @@ export const Commands = [ description: _td("Places the call in the current room on hold"), category: CommandCategories.other, isEnabled: (cli) => !isCurrentLocalRoom(cli), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { const call = LegacyCallHandler.instance.getCallForRoom(roomId); if (!call) { return reject(new UserFriendlyError("No active call in this room")); @@ -1317,7 +1323,7 @@ export const Commands = [ description: _td("Takes the call in the current room off hold"), category: CommandCategories.other, isEnabled: (cli) => !isCurrentLocalRoom(cli), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { const call = LegacyCallHandler.instance.getCallForRoom(roomId); if (!call) { return reject(new UserFriendlyError("No active call in this room")); @@ -1332,7 +1338,7 @@ export const Commands = [ description: _td("Converts the room to a DM"), category: CommandCategories.other, isEnabled: (cli) => !isCurrentLocalRoom(cli), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { const room = cli.getRoom(roomId); if (!room) return reject(new UserFriendlyError("Could not find room")); return success(guessAndSetDMRoom(room, true)); @@ -1344,7 +1350,7 @@ export const Commands = [ description: _td("Converts the DM to a room"), category: CommandCategories.other, isEnabled: (cli) => !isCurrentLocalRoom(cli), - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { const room = cli.getRoom(roomId); if (!room) return reject(new UserFriendlyError("Could not find room")); return success(guessAndSetDMRoom(room, false)); @@ -1367,7 +1373,7 @@ export const Commands = [ command: effect.command, description: effect.description(), args: "", - runFn: function (cli, roomId, args) { + runFn: function (cli, roomId, threadId, args) { let content: IContent; if (!args) { content = ContentHelpers.makeEmoteMessage(effect.fallbackMessage()); diff --git a/src/components/views/dialogs/DevtoolsDialog.tsx b/src/components/views/dialogs/DevtoolsDialog.tsx index 655b175edf..7553666425 100644 --- a/src/components/views/dialogs/DevtoolsDialog.tsx +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -65,12 +65,13 @@ const Tools: Record = { interface IProps { roomId: string; + threadRootId?: string | null; onFinished(finished?: boolean): void; } type ToolInfo = [label: string, tool: Tool]; -const DevtoolsDialog: React.FC = ({ roomId, onFinished }) => { +const DevtoolsDialog: React.FC = ({ roomId, threadRootId, onFinished }) => { const [tool, setTool] = useState(null); let body: JSX.Element; @@ -125,9 +126,18 @@ const DevtoolsDialog: React.FC = ({ roomId, onFinished }) => { roomId} border={false}> {_t("Room ID: %(roomId)s", { roomId })} + {!threadRootId ? null : ( + threadRootId} + border={false} + > + {_t("Thread Root ID: %(threadRootId)s", { threadRootId })} + + )}
{cli.getRoom(roomId) && ( - + {body} )} diff --git a/src/components/views/dialogs/devtools/BaseTool.tsx b/src/components/views/dialogs/devtools/BaseTool.tsx index 1cbcd89f40..6aa95e138b 100644 --- a/src/components/views/dialogs/devtools/BaseTool.tsx +++ b/src/components/views/dialogs/devtools/BaseTool.tsx @@ -88,6 +88,7 @@ export default BaseTool; interface IContext { room: Room; + threadRootId?: string | null; } export const DevtoolsContext = createContext({} as IContext); diff --git a/src/components/views/dialogs/devtools/Event.tsx b/src/components/views/dialogs/devtools/Event.tsx index 4fa0403e9d..30f0abaf0f 100644 --- a/src/components/views/dialogs/devtools/Event.tsx +++ b/src/components/views/dialogs/devtools/Event.tsx @@ -204,6 +204,13 @@ export const TimelineEventEditor: React.FC = ({ mxEvent, onBack }) }; defaultContent = stringify(newContent); + } else if (context.threadRootId) { + defaultContent = stringify({ + "m.relates_to": { + rel_type: "m.thread", + event_id: context.threadRootId, + }, + }); } return ; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a6cba4d95f..634f0d61f4 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2841,6 +2841,7 @@ "Toolbox": "Toolbox", "Developer Tools": "Developer Tools", "Room ID: %(roomId)s": "Room ID: %(roomId)s", + "Thread Root ID: %(threadRootId)s": "Thread Root ID: %(threadRootId)s", "The poll has ended. No votes were cast.": "The poll has ended. No votes were cast.", "The poll has ended. Top answer: %(topAnswer)s": "The poll has ended. Top answer: %(topAnswer)s", "Failed to end poll": "Failed to end poll", diff --git a/test/components/views/dialogs/DevtoolsDialog-test.tsx b/test/components/views/dialogs/DevtoolsDialog-test.tsx index ae337695a4..27f1206f92 100644 --- a/test/components/views/dialogs/DevtoolsDialog-test.tsx +++ b/test/components/views/dialogs/DevtoolsDialog-test.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React from "react"; -import { getByLabelText, render } from "@testing-library/react"; +import { getByLabelText, getAllByLabelText, render } from "@testing-library/react"; import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixClient } from "matrix-js-sdk/src/client"; import userEvent from "@testing-library/user-event"; @@ -29,10 +29,10 @@ describe("DevtoolsDialog", () => { let cli: MatrixClient; let room: Room; - function getComponent(roomId: string, onFinished = () => true) { + function getComponent(roomId: string, threadRootId: string | null = null, onFinished = () => true) { return render( - + , ); } @@ -68,4 +68,20 @@ describe("DevtoolsDialog", () => { expect(navigator.clipboard.writeText).toHaveBeenCalled(); expect(navigator.clipboard.readText()).resolves.toBe(room.roomId); }); + + it("copies the thread root id when provided", async () => { + const user = userEvent.setup(); + jest.spyOn(navigator.clipboard, "writeText"); + + const threadRootId = "$test_event_id_goes_here"; + const { container } = getComponent(room.roomId, threadRootId); + + const copyBtn = getAllByLabelText(container, "Copy")[1]; + await user.click(copyBtn); + const copiedBtn = getByLabelText(container, "Copied!"); + + expect(copiedBtn).toBeInTheDocument(); + expect(navigator.clipboard.writeText).toHaveBeenCalled(); + expect(navigator.clipboard.readText()).resolves.toBe(threadRootId); + }); }); diff --git a/test/components/views/dialogs/devtools/Event-test.tsx b/test/components/views/dialogs/devtools/Event-test.tsx new file mode 100644 index 0000000000..ee9d84f29c --- /dev/null +++ b/test/components/views/dialogs/devtools/Event-test.tsx @@ -0,0 +1,71 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +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 React from "react"; +import { render } from "@testing-library/react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { PendingEventOrdering } from "matrix-js-sdk/src/client"; + +import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; +import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; +import { stubClient } from "../../../../test-utils"; +import { DevtoolsContext } from "../../../../../src/components/views/dialogs/devtools/BaseTool"; +import { TimelineEventEditor } from "../../../../../src/components/views/dialogs/devtools/Event"; + +describe("", () => { + beforeEach(() => { + stubClient(); + }); + + it("should render", () => { + const cli = MatrixClientPeg.safeGet(); + const { asFragment } = render( + + + {}} /> + + , + ); + expect(asFragment()).toMatchSnapshot(); + }); + + describe("thread context", () => { + it("should pre-populate a thread relationship", () => { + const cli = MatrixClientPeg.safeGet(); + const { asFragment } = render( + + + {}} /> + + , + ); + expect(asFragment()).toMatchSnapshot(); + }); + }); +}); diff --git a/test/components/views/dialogs/devtools/__snapshots__/Event-test.tsx.snap b/test/components/views/dialogs/devtools/__snapshots__/Event-test.tsx.snap new file mode 100644 index 0000000000..d224b791fa --- /dev/null +++ b/test/components/views/dialogs/devtools/__snapshots__/Event-test.tsx.snap @@ -0,0 +1,126 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render 1`] = ` + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+`; + +exports[` thread context should pre-populate a thread relationship 1`] = ` + +
+
+
+ + +
+
+
+ + +
+
+
+ + +
+
+`;