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

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
t3chguy/dedup-icons-17oct
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> }>;
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: "<message>",
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>`));
},
category: CommandCategories.messages,
@ -241,7 +247,7 @@ export const Commands = [
command: "shrug",
args: "<message>",
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: "<message>",
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: "<message>",
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: "<message>",
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: "<message>",
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: "<message>",
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: "<new_version>",
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: "<YYYY-MM-DD>",
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<void> => {
@ -387,7 +393,7 @@ export const Commands = [
command: "nick",
args: "<display_name>",
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: "<display_name>",
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: "[<mxc_url>]",
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: "[<mxc_url>]",
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: "[<mxc_url>]",
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: "[<topic>]",
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: "<name>",
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: "<room-address>",
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: "<user-id> [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: "<user-id> [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: "<user-id>",
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: "<user-id>",
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: "<user-id>",
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: "<user-id> [<power-level>]",
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: "<user-id>",
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: "<user-id> <device-id> <device-signing-key>",
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: "<message>",
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: "<message>",
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: "<user-id>",
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: "<description>",
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: "<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
// (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: "<user-id> [<message>]",
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: "<message>",
runFn: function (cli, roomId, args) {
runFn: function (cli, roomId, threadId, args) {
let content: IContent;
if (!args) {
content = ContentHelpers.makeEmoteMessage(effect.fallbackMessage());

View File

@ -65,12 +65,13 @@ const Tools: Record<Category, [label: string, tool: Tool][]> = {
interface IProps {
roomId: string;
threadRootId?: string | null;
onFinished(finished?: boolean): void;
}
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);
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}>
{_t("Room ID: %(roomId)s", { roomId })}
</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" />
{cli.getRoom(roomId) && (
<DevtoolsContext.Provider value={{ room: cli.getRoom(roomId)! }}>
<DevtoolsContext.Provider value={{ room: cli.getRoom(roomId)!, threadRootId }}>
{body}
</DevtoolsContext.Provider>
)}

View File

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

View File

@ -204,6 +204,13 @@ export const TimelineEventEditor: React.FC<IEditorProps> = ({ mxEvent, onBack })
};
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} />;

View File

@ -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",

View File

@ -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(
<MatrixClientContext.Provider value={cli}>
<DevtoolsDialog roomId={roomId} onFinished={onFinished} />
<DevtoolsDialog roomId={roomId} threadRootId={threadRootId} onFinished={onFinished} />
</MatrixClientContext.Provider>,
);
}
@ -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);
});
});

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>
`;