Skip to content

Commit db2e958

Browse files
authored
Disable RTE formatting buttons when the content contains a slash command (#30802)
* Add ability to disable all formatting buttons * Create hook to check if the content contains a slash command * Disable the formatting buttons if the message content contains a slash command * lint * typo
1 parent 25a8591 commit db2e958

File tree

6 files changed

+266
-14
lines changed

6 files changed

+266
-14
lines changed

src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function Button({ label, keyCombo, onClick, actionState, icon }: ButtonProps): J
4343
element="button"
4444
onClick={onClick as (e: ButtonEvent) => void}
4545
aria-label={label}
46+
disabled={actionState === "disabled"}
4647
className={classNames("mx_FormattingButtons_Button", {
4748
mx_FormattingButtons_active: actionState === "reversed",
4849
mx_FormattingButtons_Button_hover: actionState === "enabled",
@@ -64,89 +65,93 @@ function Button({ label, keyCombo, onClick, actionState, icon }: ButtonProps): J
6465
interface FormattingButtonsProps {
6566
composer: FormattingFunctions;
6667
actionStates: AllActionStates;
68+
/**
69+
* Whether all buttons should be disabled
70+
*/
71+
disabled?: boolean;
6772
}
6873

69-
export function FormattingButtons({ composer, actionStates }: FormattingButtonsProps): JSX.Element {
74+
export function FormattingButtons({ composer, actionStates, disabled }: FormattingButtonsProps): JSX.Element {
7075
const composerContext = useComposerContext();
7176
const isInList = actionStates.unorderedList === "reversed" || actionStates.orderedList === "reversed";
7277
return (
7378
<div className="mx_FormattingButtons">
7479
<Button
75-
actionState={actionStates.bold}
80+
actionState={disabled ? "disabled" : actionStates.bold}
7681
label={_t("composer|format_bold")}
7782
keyCombo={{ ctrlOrCmdKey: true, key: "b" }}
7883
onClick={() => composer.bold()}
7984
icon={<BoldIcon className="mx_FormattingButtons_Icon" />}
8085
/>
8186
<Button
82-
actionState={actionStates.italic}
87+
actionState={disabled ? "disabled" : actionStates.italic}
8388
label={_t("composer|format_italic")}
8489
keyCombo={{ ctrlOrCmdKey: true, key: "i" }}
8590
onClick={() => composer.italic()}
8691
icon={<ItalicIcon className="mx_FormattingButtons_Icon" />}
8792
/>
8893
<Button
89-
actionState={actionStates.underline}
94+
actionState={disabled ? "disabled" : actionStates.underline}
9095
label={_t("composer|format_underline")}
9196
keyCombo={{ ctrlOrCmdKey: true, key: "u" }}
9297
onClick={() => composer.underline()}
9398
icon={<UnderlineIcon className="mx_FormattingButtons_Icon" />}
9499
/>
95100
<Button
96-
actionState={actionStates.strikeThrough}
101+
actionState={disabled ? "disabled" : actionStates.strikeThrough}
97102
label={_t("composer|format_strikethrough")}
98103
onClick={() => composer.strikeThrough()}
99104
icon={<StrikeThroughIcon className="mx_FormattingButtons_Icon" />}
100105
/>
101106
<Button
102-
actionState={actionStates.unorderedList}
107+
actionState={disabled ? "disabled" : actionStates.unorderedList}
103108
label={_t("composer|format_unordered_list")}
104109
onClick={() => composer.unorderedList()}
105110
icon={<BulletedListIcon className="mx_FormattingButtons_Icon" />}
106111
/>
107112
<Button
108-
actionState={actionStates.orderedList}
113+
actionState={disabled ? "disabled" : actionStates.orderedList}
109114
label={_t("composer|format_ordered_list")}
110115
onClick={() => composer.orderedList()}
111116
icon={<NumberedListIcon className="mx_FormattingButtons_Icon" />}
112117
/>
113118
{isInList && (
114119
<Button
115-
actionState={actionStates.indent}
120+
actionState={disabled ? "disabled" : actionStates.indent}
116121
label={_t("composer|format_increase_indent")}
117122
onClick={() => composer.indent()}
118123
icon={<IndentIcon className="mx_FormattingButtons_Icon" />}
119124
/>
120125
)}
121126
{isInList && (
122127
<Button
123-
actionState={actionStates.unindent}
128+
actionState={disabled ? "disabled" : actionStates.unindent}
124129
label={_t("composer|format_decrease_indent")}
125130
onClick={() => composer.unindent()}
126131
icon={<UnIndentIcon className="mx_FormattingButtons_Icon" />}
127132
/>
128133
)}
129134
<Button
130-
actionState={actionStates.quote}
135+
actionState={disabled ? "disabled" : actionStates.quote}
131136
label={_t("action|quote")}
132137
onClick={() => composer.quote()}
133138
icon={<QuoteIcon className="mx_FormattingButtons_Icon" />}
134139
/>
135140
<Button
136-
actionState={actionStates.inlineCode}
141+
actionState={disabled ? "disabled" : actionStates.inlineCode}
137142
label={_t("composer|format_inline_code")}
138143
keyCombo={{ ctrlOrCmdKey: true, key: "e" }}
139144
onClick={() => composer.inlineCode()}
140145
icon={<InlineCodeIcon className="mx_FormattingButtons_Icon" />}
141146
/>
142147
<Button
143-
actionState={actionStates.codeBlock}
148+
actionState={disabled ? "disabled" : actionStates.codeBlock}
144149
label={_t("composer|format_code_block")}
145150
onClick={() => composer.codeBlock()}
146151
icon={<CodeBlockIcon className="mx_FormattingButtons_Icon" />}
147152
/>
148153
<Button
149-
actionState={actionStates.link}
154+
actionState={disabled ? "disabled" : actionStates.link}
150155
label={_t("composer|format_link")}
151156
onClick={() => openLinkModal(composer, composerContext, actionStates.link === "reversed")}
152157
icon={<LinkIcon className="mx_FormattingButtons_Icon" />}

src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { parsePermalink } from "../../../../../utils/permalinks/Permalinks";
2525
import { isNotNull } from "../../../../../Typeguards";
2626
import { useSettingValue } from "../../../../../hooks/useSettings";
2727
import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx";
28+
import { useContainsCommand } from "../hooks/useContainsCommand.ts";
2829

2930
interface WysiwygComposerProps {
3031
disabled?: boolean;
@@ -83,6 +84,9 @@ export const WysiwygComposer = memo(function WysiwygComposer({
8384
}
8485
}, [onChange, messageContent, disabled]);
8586

87+
// Disable formatting buttons if the message content contains a slash command
88+
const disableFormatting = useContainsCommand(content, room);
89+
8690
useEffect(() => {
8791
function handleClick(e: Event): void {
8892
e.preventDefault();
@@ -124,7 +128,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({
124128
handleAtRoomMention={wysiwyg.mentionAtRoom}
125129
handleCommand={wysiwyg.command}
126130
/>
127-
<FormattingButtons composer={wysiwyg} actionStates={actionStates} />
131+
<FormattingButtons composer={wysiwyg} actionStates={actionStates} disabled={disableFormatting} />
128132
<Editor
129133
ref={ref}
130134
disabled={!isReady}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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 Room } from "matrix-js-sdk/src/matrix";
9+
import { useEffect, useState, useRef } from "react";
10+
11+
import CommandProvider from "../../../../../autocomplete/CommandProvider";
12+
13+
/**
14+
* A hook which determines if the given content contains a slash command.
15+
* @returns true if the content contains a slash command, false otherwise.
16+
* @param content The content to check for commands.
17+
* @param room The current room.
18+
*/
19+
export function useContainsCommand(content: string | null, room: Room | undefined): boolean {
20+
const [contentContainsCommands, setContentContainsCommands] = useState(false);
21+
const providerRef = useRef<CommandProvider | null>(null);
22+
const currentRoomIdRef = useRef<string | null>(null);
23+
24+
useEffect(() => {
25+
if (!room || !content) {
26+
setContentContainsCommands(false);
27+
return;
28+
}
29+
30+
// Create or reuse CommandProvider for the current room
31+
if (!providerRef.current || currentRoomIdRef.current !== room.roomId) {
32+
providerRef.current = new CommandProvider(room);
33+
currentRoomIdRef.current = room.roomId;
34+
}
35+
36+
const provider = providerRef.current;
37+
provider
38+
.getCompletions(content, { start: 0, end: 0 })
39+
.then((results) => {
40+
if (results.length > 0) {
41+
setContentContainsCommands(true);
42+
} else {
43+
setContentContainsCommands(false);
44+
}
45+
})
46+
.catch(() => {
47+
// If there's an error getting completions, assume no commands
48+
setContentContainsCommands(false);
49+
});
50+
}, [content, room]);
51+
52+
return contentContainsCommands;
53+
}

test/unit-tests/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,4 +194,14 @@ describe("FormattingButtons", () => {
194194
expect(screen.getByLabelText("Indent increase")).toBeInTheDocument();
195195
expect(screen.getByLabelText("Indent decrease")).toBeInTheDocument();
196196
});
197+
198+
it("Every button should when disabled the component is disabled", () => {
199+
renderComponent({ disabled: true });
200+
201+
Object.values(testCases).forEach((testCase) => {
202+
const { label } = testCase;
203+
expect(screen.getByLabelText(label)).toHaveClass(classes.disabled);
204+
expect(screen.getByLabelText(label)).toBeDisabled();
205+
});
206+
});
197207
});

test/unit-tests/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,25 @@ describe("WysiwygComposer", () => {
151151
// Then it sends a message
152152
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(0));
153153
});
154+
155+
it("Should disable formatting buttons when a slash command is entered", async () => {
156+
// When
157+
fireEvent.input(screen.getByRole("textbox"), {
158+
data: "/rainbow",
159+
inputType: "insertText",
160+
});
161+
162+
// Then - wait for all buttons to be rendered and have the disabled class
163+
await waitFor(() => {
164+
const container = screen.getByTestId("WysiwygComposer");
165+
const formattingButtons = container.querySelectorAll(".mx_FormattingButtons_Button");
166+
expect(formattingButtons.length).toBeGreaterThan(0);
167+
168+
formattingButtons.forEach((btn) => {
169+
expect(btn).toHaveClass("mx_FormattingButtons_disabled");
170+
});
171+
});
172+
});
154173
});
155174

156175
describe("Mentions and commands", () => {
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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 { renderHook, waitFor } from "jest-matrix-react";
9+
import { Room } from "matrix-js-sdk/src/matrix";
10+
11+
import { useContainsCommand } from "../../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useContainsCommand";
12+
import { stubClient } from "../../../../../../test-utils";
13+
14+
// Mock CommandProvider
15+
const mockGetCompletions = jest.fn();
16+
jest.mock("../../../../../../../src/autocomplete/CommandProvider", () => {
17+
return jest.fn().mockImplementation(() => ({
18+
getCompletions: mockGetCompletions,
19+
}));
20+
});
21+
22+
describe("useContainsCommand", () => {
23+
let room: Room;
24+
25+
beforeEach(() => {
26+
const client = stubClient();
27+
room = new Room("!room:example.com", client, "@user:example.com");
28+
mockGetCompletions.mockClear();
29+
// Default mock to return empty promise
30+
mockGetCompletions.mockResolvedValue([]);
31+
});
32+
33+
afterEach(() => {
34+
jest.clearAllMocks();
35+
});
36+
37+
it("should return false when content is null", async () => {
38+
mockGetCompletions.mockResolvedValue([]);
39+
40+
const { result } = renderHook(() => useContainsCommand(null, room));
41+
42+
await waitFor(() => {
43+
expect(result.current).toBe(false);
44+
});
45+
expect(mockGetCompletions).not.toHaveBeenCalled();
46+
});
47+
48+
it("should return false when content is empty string", async () => {
49+
mockGetCompletions.mockResolvedValue([]);
50+
51+
const { result } = renderHook(() => useContainsCommand("", room));
52+
53+
await waitFor(() => {
54+
expect(result.current).toBe(false);
55+
});
56+
expect(mockGetCompletions).not.toHaveBeenCalled();
57+
});
58+
59+
it("should return true when content contains a valid command", async () => {
60+
mockGetCompletions.mockResolvedValue([{ type: "command", completion: "/spoiler" }]);
61+
62+
const { result } = renderHook(() => useContainsCommand("/spoiler test message", room));
63+
64+
await waitFor(() => {
65+
expect(result.current).toBe(true);
66+
});
67+
expect(mockGetCompletions).toHaveBeenCalledWith("/spoiler test message", { start: 0, end: 0 });
68+
});
69+
70+
it("should return false when content contains no valid commands", async () => {
71+
mockGetCompletions.mockResolvedValue([]);
72+
73+
const { result } = renderHook(() => useContainsCommand("/invalidcommand", room));
74+
75+
await waitFor(() => {
76+
expect(result.current).toBe(false);
77+
});
78+
expect(mockGetCompletions).toHaveBeenCalledWith("/invalidcommand", { start: 0, end: 0 });
79+
});
80+
81+
it("should return true for partial command matches", async () => {
82+
mockGetCompletions.mockResolvedValue([
83+
{ type: "command", completion: "/spoiler" },
84+
{ type: "command", completion: "/shrug" },
85+
]);
86+
87+
const { result } = renderHook(() => useContainsCommand("/sp", room));
88+
89+
await waitFor(() => {
90+
expect(result.current).toBe(true);
91+
});
92+
expect(mockGetCompletions).toHaveBeenCalledWith("/sp", { start: 0, end: 0 });
93+
});
94+
95+
it("should update when content changes", async () => {
96+
mockGetCompletions.mockResolvedValue([]);
97+
98+
const { result, rerender } = renderHook(({ content, room }) => useContainsCommand(content, room), {
99+
initialProps: { content: "/invalid", room },
100+
});
101+
102+
await waitFor(() => {
103+
expect(result.current).toBe(false);
104+
});
105+
106+
// Change to valid command
107+
mockGetCompletions.mockResolvedValue([{ type: "command", completion: "/spoiler" }]);
108+
109+
rerender({ content: "/spoiler", room });
110+
111+
await waitFor(() => {
112+
expect(result.current).toBe(true);
113+
});
114+
expect(mockGetCompletions).toHaveBeenCalledWith("/spoiler", { start: 0, end: 0 });
115+
});
116+
117+
it("should handle CommandProvider errors gracefully", async () => {
118+
mockGetCompletions.mockRejectedValueOnce(new Error("Provider error"));
119+
120+
const { result } = renderHook(() => useContainsCommand("/test", room));
121+
122+
// Should remain false even if promise rejects
123+
await waitFor(() => {
124+
expect(result.current).toBe(false);
125+
});
126+
});
127+
128+
it("should return false for non-command content", async () => {
129+
mockGetCompletions.mockResolvedValue([]); // CommandProvider returns empty for non-commands
130+
131+
const { result } = renderHook(() => useContainsCommand("regular message", room));
132+
133+
await waitFor(() => {
134+
expect(result.current).toBe(false);
135+
});
136+
expect(mockGetCompletions).toHaveBeenCalledWith("regular message", { start: 0, end: 0 });
137+
});
138+
139+
it("should reset to false when switching to null content", async () => {
140+
mockGetCompletions.mockResolvedValue([{ type: "command", completion: "/spoiler" }]);
141+
142+
const { result, rerender } = renderHook(
143+
({ content, room }: { content: string | null; room: Room | undefined }) =>
144+
useContainsCommand(content, room),
145+
{
146+
initialProps: { content: "/spoiler" as string | null, room: room as Room | undefined },
147+
},
148+
);
149+
150+
await waitFor(() => {
151+
expect(result.current).toBe(true);
152+
});
153+
154+
// Switch to null content
155+
rerender({ content: null, room });
156+
157+
await waitFor(() => {
158+
expect(result.current).toBe(false);
159+
});
160+
});
161+
});

0 commit comments

Comments
 (0)