Expose and pre-populate thread ID in devtools dialog (#10953)

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Travis Ralston 2023-07-07 08:40:25 -06:00 committed by GitHub
parent cfd48b36aa
commit 8a97e5f351
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 286 additions and 48 deletions

View file

@ -113,7 +113,13 @@ export const CommandCategories = {
export type RunResult = XOR<{ error: Error }, { promise: Promise<IContent | undefined> }>; export type RunResult = XOR<{ error: Error }, { promise: Promise<IContent | undefined> }>;
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 { interface ICommandOpts {
command: string; 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 { public getUsage(): string {
@ -232,7 +238,7 @@ export const Commands = [
command: "spoiler", command: "spoiler",
args: "<message>", args: "<message>",
description: _td("Sends the given message as a spoiler"), 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, `<span data-mx-spoiler>${message}</span>`)); return successSync(ContentHelpers.makeHtmlMessage(message, `<span data-mx-spoiler>${message}</span>`));
}, },
category: CommandCategories.messages, category: CommandCategories.messages,
@ -241,7 +247,7 @@ export const Commands = [
command: "shrug", command: "shrug",
args: "<message>", args: "<message>",
description: _td("Prepends ¯\\_(ツ)_/¯ to a plain-text message"), description: _td("Prepends ¯\\_(ツ)_/¯ to a plain-text message"),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
let message = "¯\\_(ツ)_/¯"; let message = "¯\\_(ツ)_/¯";
if (args) { if (args) {
message = message + " " + args; message = message + " " + args;
@ -254,7 +260,7 @@ export const Commands = [
command: "tableflip", command: "tableflip",
args: "<message>", args: "<message>",
description: _td("Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message"), description: _td("Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message"),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
let message = "(╯°□°)╯︵ ┻━┻"; let message = "(╯°□°)╯︵ ┻━┻";
if (args) { if (args) {
message = message + " " + args; message = message + " " + args;
@ -267,7 +273,7 @@ export const Commands = [
command: "unflip", command: "unflip",
args: "<message>", args: "<message>",
description: _td("Prepends ┬──┬ ( ゜-゜ノ) to a plain-text message"), description: _td("Prepends ┬──┬ ( ゜-゜ノ) to a plain-text message"),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
let message = "┬──┬ ( ゜-゜ノ)"; let message = "┬──┬ ( ゜-゜ノ)";
if (args) { if (args) {
message = message + " " + args; message = message + " " + args;
@ -280,7 +286,7 @@ export const Commands = [
command: "lenny", command: "lenny",
args: "<message>", args: "<message>",
description: _td("Prepends ( ͡° ͜ʖ ͡°) to a plain-text message"), description: _td("Prepends ( ͡° ͜ʖ ͡°) to a plain-text message"),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
let message = "( ͡° ͜ʖ ͡°)"; let message = "( ͡° ͜ʖ ͡°)";
if (args) { if (args) {
message = message + " " + args; message = message + " " + args;
@ -293,7 +299,7 @@ export const Commands = [
command: "plain", command: "plain",
args: "<message>", args: "<message>",
description: _td("Sends a message as plain text, without interpreting it as markdown"), 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)); return successSync(ContentHelpers.makeTextMessage(messages));
}, },
category: CommandCategories.messages, category: CommandCategories.messages,
@ -302,7 +308,7 @@ export const Commands = [
command: "html", command: "html",
args: "<message>", args: "<message>",
description: _td("Sends a message as html, without interpreting it as markdown"), 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)); return successSync(ContentHelpers.makeHtmlMessage(messages, messages));
}, },
category: CommandCategories.messages, category: CommandCategories.messages,
@ -312,7 +318,7 @@ export const Commands = [
args: "<new_version>", args: "<new_version>",
description: _td("Upgrades a room to a new version"), description: _td("Upgrades a room to a new version"),
isEnabled: (cli) => !isCurrentLocalRoom(cli), isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (args) { if (args) {
const room = cli.getRoom(roomId); const room = cli.getRoom(roomId);
if (!room?.currentState.mayClientSendStateEvent("m.room.tombstone", cli)) { if (!room?.currentState.mayClientSendStateEvent("m.room.tombstone", cli)) {
@ -346,7 +352,7 @@ export const Commands = [
args: "<YYYY-MM-DD>", args: "<YYYY-MM-DD>",
description: _td("Jump to the given date in the timeline"), description: _td("Jump to the given date in the timeline"),
isEnabled: () => SettingsStore.getValue("feature_jump_to_date"), isEnabled: () => SettingsStore.getValue("feature_jump_to_date"),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (args) { if (args) {
return success( return success(
(async (): Promise<void> => { (async (): Promise<void> => {
@ -387,7 +393,7 @@ export const Commands = [
command: "nick", command: "nick",
args: "<display_name>", args: "<display_name>",
description: _td("Changes your display nickname"), description: _td("Changes your display nickname"),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (args) { if (args) {
return success(cli.setDisplayName(args)); return success(cli.setDisplayName(args));
} }
@ -402,7 +408,7 @@ export const Commands = [
args: "<display_name>", args: "<display_name>",
description: _td("Changes your display nickname in the current room only"), description: _td("Changes your display nickname in the current room only"),
isEnabled: (cli) => !isCurrentLocalRoom(cli), isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (args) { if (args) {
const ev = cli.getRoom(roomId)?.currentState.getStateEvents("m.room.member", cli.getSafeUserId()); const ev = cli.getRoom(roomId)?.currentState.getStateEvents("m.room.member", cli.getSafeUserId());
const content = { const content = {
@ -421,7 +427,7 @@ export const Commands = [
args: "[<mxc_url>]", args: "[<mxc_url>]",
description: _td("Changes the avatar of the current room"), description: _td("Changes the avatar of the current room"),
isEnabled: (cli) => !isCurrentLocalRoom(cli), isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
let promise = Promise.resolve(args ?? null); let promise = Promise.resolve(args ?? null);
if (!args) { if (!args) {
promise = singleMxcUpload(cli); promise = singleMxcUpload(cli);
@ -442,7 +448,7 @@ export const Commands = [
args: "[<mxc_url>]", args: "[<mxc_url>]",
description: _td("Changes your profile picture in this current room only"), description: _td("Changes your profile picture in this current room only"),
isEnabled: (cli) => !isCurrentLocalRoom(cli), isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
const room = cli.getRoom(roomId); const room = cli.getRoom(roomId);
const userId = cli.getSafeUserId(); const userId = cli.getSafeUserId();
@ -470,7 +476,7 @@ export const Commands = [
command: "myavatar", command: "myavatar",
args: "[<mxc_url>]", args: "[<mxc_url>]",
description: _td("Changes your profile picture in all rooms"), 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); let promise = Promise.resolve(args ?? null);
if (!args) { if (!args) {
promise = singleMxcUpload(cli); promise = singleMxcUpload(cli);
@ -491,7 +497,7 @@ export const Commands = [
args: "[<topic>]", args: "[<topic>]",
description: _td("Gets or sets the room topic"), description: _td("Gets or sets the room topic"),
isEnabled: (cli) => !isCurrentLocalRoom(cli), isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (args) { if (args) {
const html = htmlSerializeFromMdIfNeeded(args, { forceHTML: false }); const html = htmlSerializeFromMdIfNeeded(args, { forceHTML: false });
return success(cli.setRoomTopic(roomId, args, html)); return success(cli.setRoomTopic(roomId, args, html));
@ -529,7 +535,7 @@ export const Commands = [
args: "<name>", args: "<name>",
description: _td("Sets the room name"), description: _td("Sets the room name"),
isEnabled: (cli) => !isCurrentLocalRoom(cli), isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (args) { if (args) {
return success(cli.setRoomName(roomId, args)); return success(cli.setRoomName(roomId, args));
} }
@ -544,7 +550,7 @@ export const Commands = [
description: _td("Invites user with given id to current room"), description: _td("Invites user with given id to current room"),
analyticsName: "Invite", analyticsName: "Invite",
isEnabled: (cli) => !isCurrentLocalRoom(cli) && shouldShowComponent(UIComponent.InviteUsers), isEnabled: (cli) => !isCurrentLocalRoom(cli) && shouldShowComponent(UIComponent.InviteUsers),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (args) { if (args) {
const [address, reason] = args.split(/\s+(.+)/); const [address, reason] = args.split(/\s+(.+)/);
if (address) { if (address) {
@ -621,7 +627,7 @@ export const Commands = [
aliases: ["j", "goto"], aliases: ["j", "goto"],
args: "<room-address>", args: "<room-address>",
description: _td("Joins room with given address"), description: _td("Joins room with given address"),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (args) { if (args) {
// Note: we support 2 versions of this command. The first is // Note: we support 2 versions of this command. The first is
// the public-facing one for most users and the other is a // the public-facing one for most users and the other is a
@ -734,7 +740,7 @@ export const Commands = [
description: _td("Leave room"), description: _td("Leave room"),
analyticsName: "Part", analyticsName: "Part",
isEnabled: (cli) => !isCurrentLocalRoom(cli), isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
let targetRoomId: string | undefined; let targetRoomId: string | undefined;
if (args) { if (args) {
const matches = args.match(/^(\S+)$/); const matches = args.match(/^(\S+)$/);
@ -774,7 +780,7 @@ export const Commands = [
args: "<user-id> [reason]", args: "<user-id> [reason]",
description: _td("Removes user with given id from this room"), description: _td("Removes user with given id from this room"),
isEnabled: (cli) => !isCurrentLocalRoom(cli), isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (args) { if (args) {
const matches = args.match(/^(\S+?)( +(.*))?$/); const matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) { if (matches) {
@ -791,7 +797,7 @@ export const Commands = [
args: "<user-id> [reason]", args: "<user-id> [reason]",
description: _td("Bans user with given id"), description: _td("Bans user with given id"),
isEnabled: (cli) => !isCurrentLocalRoom(cli), isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (args) { if (args) {
const matches = args.match(/^(\S+?)( +(.*))?$/); const matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) { if (matches) {
@ -808,7 +814,7 @@ export const Commands = [
args: "<user-id>", args: "<user-id>",
description: _td("Unbans user with given ID"), description: _td("Unbans user with given ID"),
isEnabled: (cli) => !isCurrentLocalRoom(cli), isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (args) { if (args) {
const matches = args.match(/^(\S+)$/); const matches = args.match(/^(\S+)$/);
if (matches) { if (matches) {
@ -825,7 +831,7 @@ export const Commands = [
command: "ignore", command: "ignore",
args: "<user-id>", args: "<user-id>",
description: _td("Ignores a user, hiding their messages from you"), description: _td("Ignores a user, hiding their messages from you"),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (args) { if (args) {
const matches = args.match(/^(@[^:]+:\S+)$/); const matches = args.match(/^(@[^:]+:\S+)$/);
if (matches) { if (matches) {
@ -854,7 +860,7 @@ export const Commands = [
command: "unignore", command: "unignore",
args: "<user-id>", args: "<user-id>",
description: _td("Stops ignoring a user, showing their messages going forward"), description: _td("Stops ignoring a user, showing their messages going forward"),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (args) { if (args) {
const matches = args.match(/(^@[^:]+:\S+$)/); const matches = args.match(/(^@[^:]+:\S+$)/);
if (matches) { if (matches) {
@ -885,7 +891,7 @@ export const Commands = [
args: "<user-id> [<power-level>]", args: "<user-id> [<power-level>]",
description: _td("Define the power level of a user"), description: _td("Define the power level of a user"),
isEnabled: canAffectPowerlevels, isEnabled: canAffectPowerlevels,
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (args) { if (args) {
const matches = args.match(/^(\S+?)( +(-?\d+))?$/); const matches = args.match(/^(\S+?)( +(-?\d+))?$/);
let powerLevel = 50; // default power level for op let powerLevel = 50; // default power level for op
@ -926,7 +932,7 @@ export const Commands = [
args: "<user-id>", args: "<user-id>",
description: _td("Deops user with given id"), description: _td("Deops user with given id"),
isEnabled: canAffectPowerlevels, isEnabled: canAffectPowerlevels,
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (args) { if (args) {
const matches = args.match(/^(\S+)$/); const matches = args.match(/^(\S+)$/);
if (matches) { if (matches) {
@ -955,8 +961,8 @@ export const Commands = [
new Command({ new Command({
command: "devtools", command: "devtools",
description: _td("Opens the Developer Tools dialog"), description: _td("Opens the Developer Tools dialog"),
runFn: function (cli, roomId) { runFn: function (cli, roomId, threadRootId) {
Modal.createDialog(DevtoolsDialog, { roomId }, "mx_DevtoolsDialog_wrapper"); Modal.createDialog(DevtoolsDialog, { roomId, threadRootId }, "mx_DevtoolsDialog_wrapper");
return success(); return success();
}, },
category: CommandCategories.advanced, category: CommandCategories.advanced,
@ -969,7 +975,7 @@ export const Commands = [
SettingsStore.getValue(UIFeature.Widgets) && SettingsStore.getValue(UIFeature.Widgets) &&
shouldShowComponent(UIComponent.AddIntegrations) && shouldShowComponent(UIComponent.AddIntegrations) &&
!isCurrentLocalRoom(cli), !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, widgetUrl) { runFn: function (cli, roomId, threadId, widgetUrl) {
if (!widgetUrl) { if (!widgetUrl) {
return reject(new UserFriendlyError("Please supply a widget URL or embed code")); return reject(new UserFriendlyError("Please supply a widget URL or embed code"));
} }
@ -1022,7 +1028,7 @@ export const Commands = [
command: "verify", command: "verify",
args: "<user-id> <device-id> <device-signing-key>", args: "<user-id> <device-id> <device-signing-key>",
description: _td("Verifies a user, session, and pubkey tuple"), description: _td("Verifies a user, session, and pubkey tuple"),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (args) { if (args) {
const matches = args.match(/^(\S+) +(\S+) +(\S+)$/); const matches = args.match(/^(\S+) +(\S+) +(\S+)$/);
if (matches) { if (matches) {
@ -1144,7 +1150,7 @@ export const Commands = [
command: "rainbow", command: "rainbow",
description: _td("Sends the given message coloured as a rainbow"), description: _td("Sends the given message coloured as a rainbow"),
args: "<message>", args: "<message>",
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (!args) return reject(this.getUsage()); if (!args) return reject(this.getUsage());
return successSync(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args))); return successSync(ContentHelpers.makeHtmlMessage(args, textToHtmlRainbow(args)));
}, },
@ -1154,7 +1160,7 @@ export const Commands = [
command: "rainbowme", command: "rainbowme",
description: _td("Sends the given emote coloured as a rainbow"), description: _td("Sends the given emote coloured as a rainbow"),
args: "<message>", args: "<message>",
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (!args) return reject(this.getUsage()); if (!args) return reject(this.getUsage());
return successSync(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args))); return successSync(ContentHelpers.makeHtmlEmote(args, textToHtmlRainbow(args)));
}, },
@ -1174,7 +1180,7 @@ export const Commands = [
description: _td("Displays information about a user"), description: _td("Displays information about a user"),
args: "<user-id>", args: "<user-id>",
isEnabled: (cli) => !isCurrentLocalRoom(cli), isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, userId) { runFn: function (cli, roomId, threadId, userId) {
if (!userId || !userId.startsWith("@") || !userId.includes(":")) { if (!userId || !userId.startsWith("@") || !userId.includes(":")) {
return reject(this.getUsage()); return reject(this.getUsage());
} }
@ -1195,7 +1201,7 @@ export const Commands = [
description: _td("Send a bug report with logs"), description: _td("Send a bug report with logs"),
isEnabled: () => !!SdkConfig.get().bug_report_endpoint_url, isEnabled: () => !!SdkConfig.get().bug_report_endpoint_url,
args: "<description>", args: "<description>",
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
return success( return success(
Modal.createDialog(BugReportDialog, { Modal.createDialog(BugReportDialog, {
initialText: args, initialText: args,
@ -1230,7 +1236,7 @@ export const Commands = [
command: "query", command: "query",
description: _td("Opens chat with the given user"), description: _td("Opens chat with the given user"),
args: "<user-id>", args: "<user-id>",
runFn: function (cli, roomId, userId) { runFn: function (cli, roomId, threadId, userId) {
// easter-egg for now: look up phone numbers through the thirdparty API // easter-egg for now: look up phone numbers through the thirdparty API
// (very dumb phone number detection...) // (very dumb phone number detection...)
const isPhoneNumber = userId && /^\+?[0123456789]+$/.test(userId); const isPhoneNumber = userId && /^\+?[0123456789]+$/.test(userId);
@ -1266,7 +1272,7 @@ export const Commands = [
command: "msg", command: "msg",
description: _td("Sends a message to the given user"), description: _td("Sends a message to the given user"),
args: "<user-id> [<message>]", args: "<user-id> [<message>]",
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
if (args) { if (args) {
// matches the first whitespace delimited group and then the rest of the string // matches the first whitespace delimited group and then the rest of the string
const matches = args.match(/^(\S+?)(?: +(.*))?$/s); const matches = args.match(/^(\S+?)(?: +(.*))?$/s);
@ -1302,7 +1308,7 @@ export const Commands = [
description: _td("Places the call in the current room on hold"), description: _td("Places the call in the current room on hold"),
category: CommandCategories.other, category: CommandCategories.other,
isEnabled: (cli) => !isCurrentLocalRoom(cli), isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
const call = LegacyCallHandler.instance.getCallForRoom(roomId); const call = LegacyCallHandler.instance.getCallForRoom(roomId);
if (!call) { if (!call) {
return reject(new UserFriendlyError("No active call in this room")); 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"), description: _td("Takes the call in the current room off hold"),
category: CommandCategories.other, category: CommandCategories.other,
isEnabled: (cli) => !isCurrentLocalRoom(cli), isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
const call = LegacyCallHandler.instance.getCallForRoom(roomId); const call = LegacyCallHandler.instance.getCallForRoom(roomId);
if (!call) { if (!call) {
return reject(new UserFriendlyError("No active call in this room")); 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"), description: _td("Converts the room to a DM"),
category: CommandCategories.other, category: CommandCategories.other,
isEnabled: (cli) => !isCurrentLocalRoom(cli), isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
const room = cli.getRoom(roomId); const room = cli.getRoom(roomId);
if (!room) return reject(new UserFriendlyError("Could not find room")); if (!room) return reject(new UserFriendlyError("Could not find room"));
return success(guessAndSetDMRoom(room, true)); return success(guessAndSetDMRoom(room, true));
@ -1344,7 +1350,7 @@ export const Commands = [
description: _td("Converts the DM to a room"), description: _td("Converts the DM to a room"),
category: CommandCategories.other, category: CommandCategories.other,
isEnabled: (cli) => !isCurrentLocalRoom(cli), isEnabled: (cli) => !isCurrentLocalRoom(cli),
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
const room = cli.getRoom(roomId); const room = cli.getRoom(roomId);
if (!room) return reject(new UserFriendlyError("Could not find room")); if (!room) return reject(new UserFriendlyError("Could not find room"));
return success(guessAndSetDMRoom(room, false)); return success(guessAndSetDMRoom(room, false));
@ -1367,7 +1373,7 @@ export const Commands = [
command: effect.command, command: effect.command,
description: effect.description(), description: effect.description(),
args: "<message>", args: "<message>",
runFn: function (cli, roomId, args) { runFn: function (cli, roomId, threadId, args) {
let content: IContent; let content: IContent;
if (!args) { if (!args) {
content = ContentHelpers.makeEmoteMessage(effect.fallbackMessage()); content = ContentHelpers.makeEmoteMessage(effect.fallbackMessage());

View file

@ -65,12 +65,13 @@ const Tools: Record<Category, [label: string, tool: Tool][]> = {
interface IProps { interface IProps {
roomId: string; roomId: string;
threadRootId?: string | null;
onFinished(finished?: boolean): void; onFinished(finished?: boolean): void;
} }
type ToolInfo = [label: string, tool: Tool]; type ToolInfo = [label: string, tool: Tool];
const DevtoolsDialog: React.FC<IProps> = ({ roomId, onFinished }) => { const DevtoolsDialog: React.FC<IProps> = ({ roomId, threadRootId, onFinished }) => {
const [tool, setTool] = useState<ToolInfo | null>(null); const [tool, setTool] = useState<ToolInfo | null>(null);
let body: JSX.Element; let body: JSX.Element;
@ -125,9 +126,18 @@ const DevtoolsDialog: React.FC<IProps> = ({ roomId, onFinished }) => {
<CopyableText className="mx_DevTools_label_right" getTextToCopy={() => roomId} border={false}> <CopyableText className="mx_DevTools_label_right" getTextToCopy={() => roomId} border={false}>
{_t("Room ID: %(roomId)s", { roomId })} {_t("Room ID: %(roomId)s", { roomId })}
</CopyableText> </CopyableText>
{!threadRootId ? null : (
<CopyableText
className="mx_DevTools_label_right"
getTextToCopy={() => threadRootId}
border={false}
>
{_t("Thread Root ID: %(threadRootId)s", { threadRootId })}
</CopyableText>
)}
<div className="mx_DevTools_label_bottom" /> <div className="mx_DevTools_label_bottom" />
{cli.getRoom(roomId) && ( {cli.getRoom(roomId) && (
<DevtoolsContext.Provider value={{ room: cli.getRoom(roomId)! }}> <DevtoolsContext.Provider value={{ room: cli.getRoom(roomId)!, threadRootId }}>
{body} {body}
</DevtoolsContext.Provider> </DevtoolsContext.Provider>
)} )}

View file

@ -88,6 +88,7 @@ export default BaseTool;
interface IContext { interface IContext {
room: Room; room: Room;
threadRootId?: string | null;
} }
export const DevtoolsContext = createContext<IContext>({} as IContext); export const DevtoolsContext = createContext<IContext>({} as IContext);

View file

@ -204,6 +204,13 @@ export const TimelineEventEditor: React.FC<IEditorProps> = ({ mxEvent, onBack })
}; };
defaultContent = stringify(newContent); defaultContent = stringify(newContent);
} else if (context.threadRootId) {
defaultContent = stringify({
"m.relates_to": {
rel_type: "m.thread",
event_id: context.threadRootId,
},
});
} }
return <EventEditor fieldDefs={fields} defaultContent={defaultContent} onSend={onSend} onBack={onBack} />; return <EventEditor fieldDefs={fields} defaultContent={defaultContent} onSend={onSend} onBack={onBack} />;

View file

@ -2841,6 +2841,7 @@
"Toolbox": "Toolbox", "Toolbox": "Toolbox",
"Developer Tools": "Developer Tools", "Developer Tools": "Developer Tools",
"Room ID: %(roomId)s": "Room ID: %(roomId)s", "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. 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", "The poll has ended. Top answer: %(topAnswer)s": "The poll has ended. Top answer: %(topAnswer)s",
"Failed to end poll": "Failed to end poll", "Failed to end poll": "Failed to end poll",

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import React from "react"; 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 { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
@ -29,10 +29,10 @@ describe("DevtoolsDialog", () => {
let cli: MatrixClient; let cli: MatrixClient;
let room: Room; let room: Room;
function getComponent(roomId: string, onFinished = () => true) { function getComponent(roomId: string, threadRootId: string | null = null, onFinished = () => true) {
return render( return render(
<MatrixClientContext.Provider value={cli}> <MatrixClientContext.Provider value={cli}>
<DevtoolsDialog roomId={roomId} onFinished={onFinished} /> <DevtoolsDialog roomId={roomId} threadRootId={threadRootId} onFinished={onFinished} />
</MatrixClientContext.Provider>, </MatrixClientContext.Provider>,
); );
} }
@ -68,4 +68,20 @@ describe("DevtoolsDialog", () => {
expect(navigator.clipboard.writeText).toHaveBeenCalled(); expect(navigator.clipboard.writeText).toHaveBeenCalled();
expect(navigator.clipboard.readText()).resolves.toBe(room.roomId); 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);
});
}); });

View file

@ -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("<EventEditor />", () => {
beforeEach(() => {
stubClient();
});
it("should render", () => {
const cli = MatrixClientPeg.safeGet();
const { asFragment } = render(
<MatrixClientContext.Provider value={cli}>
<DevtoolsContext.Provider
value={{
room: new Room("!roomId", cli, "@alice:example.com", {
pendingEventOrdering: PendingEventOrdering.Detached,
}),
}}
>
<TimelineEventEditor onBack={() => {}} />
</DevtoolsContext.Provider>
</MatrixClientContext.Provider>,
);
expect(asFragment()).toMatchSnapshot();
});
describe("thread context", () => {
it("should pre-populate a thread relationship", () => {
const cli = MatrixClientPeg.safeGet();
const { asFragment } = render(
<MatrixClientContext.Provider value={cli}>
<DevtoolsContext.Provider
value={{
room: new Room("!roomId", cli, "@alice:example.com", {
pendingEventOrdering: PendingEventOrdering.Detached,
}),
threadRootId: "$this_is_a_thread_id",
}}
>
<TimelineEventEditor onBack={() => {}} />
</DevtoolsContext.Provider>
</MatrixClientContext.Provider>,
);
expect(asFragment()).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,126 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<EventEditor /> should render 1`] = `
<DocumentFragment>
<div
class="mx_DevTools_content"
>
<div
class="mx_DevTools_eventTypeStateKeyGroup"
>
<div
class="mx_Field mx_Field_input"
>
<input
autocomplete="on"
id="eventType"
label="Event Type"
placeholder="Event Type"
size="42"
type="text"
value=""
/>
<label
for="eventType"
>
Event Type
</label>
</div>
</div>
<div
class="mx_Field mx_Field_textarea mx_DevTools_textarea"
>
<textarea
autocomplete="off"
id="evContent"
label="Event Content"
placeholder="Event Content"
type="text"
>
{
}
</textarea>
<label
for="evContent"
>
Event Content
</label>
</div>
</div>
<div
class="mx_Dialog_buttons"
>
<button>
Back
</button>
<button>
Send
</button>
</div>
</DocumentFragment>
`;
exports[`<EventEditor /> thread context should pre-populate a thread relationship 1`] = `
<DocumentFragment>
<div
class="mx_DevTools_content"
>
<div
class="mx_DevTools_eventTypeStateKeyGroup"
>
<div
class="mx_Field mx_Field_input"
>
<input
autocomplete="on"
id="eventType"
label="Event Type"
placeholder="Event Type"
size="42"
type="text"
value=""
/>
<label
for="eventType"
>
Event Type
</label>
</div>
</div>
<div
class="mx_Field mx_Field_textarea mx_DevTools_textarea"
>
<textarea
autocomplete="off"
id="evContent"
label="Event Content"
placeholder="Event Content"
type="text"
>
{
"m.relates_to": {
"rel_type": "m.thread",
"event_id": "$this_is_a_thread_id"
}
}
</textarea>
<label
for="evContent"
>
Event Content
</label>
</div>
</div>
<div
class="mx_Dialog_buttons"
>
<button>
Back
</button>
<button>
Send
</button>
</div>
</DocumentFragment>
`;