Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ function Button({ label, keyCombo, onClick, actionState, icon }: ButtonProps): J
element="button"
onClick={onClick as (e: ButtonEvent) => void}
aria-label={label}
disabled={actionState === "disabled"}
className={classNames("mx_FormattingButtons_Button", {
mx_FormattingButtons_active: actionState === "reversed",
mx_FormattingButtons_Button_hover: actionState === "enabled",
Expand All @@ -64,89 +65,93 @@ function Button({ label, keyCombo, onClick, actionState, icon }: ButtonProps): J
interface FormattingButtonsProps {
composer: FormattingFunctions;
actionStates: AllActionStates;
/**
* Whether all buttons should be disabled
*/
disabled?: boolean;
}

export function FormattingButtons({ composer, actionStates }: FormattingButtonsProps): JSX.Element {
export function FormattingButtons({ composer, actionStates, disabled }: FormattingButtonsProps): JSX.Element {
const composerContext = useComposerContext();
const isInList = actionStates.unorderedList === "reversed" || actionStates.orderedList === "reversed";
return (
<div className="mx_FormattingButtons">
<Button
actionState={actionStates.bold}
actionState={disabled ? "disabled" : actionStates.bold}
label={_t("composer|format_bold")}
keyCombo={{ ctrlOrCmdKey: true, key: "b" }}
onClick={() => composer.bold()}
icon={<BoldIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.italic}
actionState={disabled ? "disabled" : actionStates.italic}
label={_t("composer|format_italic")}
keyCombo={{ ctrlOrCmdKey: true, key: "i" }}
onClick={() => composer.italic()}
icon={<ItalicIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.underline}
actionState={disabled ? "disabled" : actionStates.underline}
label={_t("composer|format_underline")}
keyCombo={{ ctrlOrCmdKey: true, key: "u" }}
onClick={() => composer.underline()}
icon={<UnderlineIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.strikeThrough}
actionState={disabled ? "disabled" : actionStates.strikeThrough}
label={_t("composer|format_strikethrough")}
onClick={() => composer.strikeThrough()}
icon={<StrikeThroughIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.unorderedList}
actionState={disabled ? "disabled" : actionStates.unorderedList}
label={_t("composer|format_unordered_list")}
onClick={() => composer.unorderedList()}
icon={<BulletedListIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.orderedList}
actionState={disabled ? "disabled" : actionStates.orderedList}
label={_t("composer|format_ordered_list")}
onClick={() => composer.orderedList()}
icon={<NumberedListIcon className="mx_FormattingButtons_Icon" />}
/>
{isInList && (
<Button
actionState={actionStates.indent}
actionState={disabled ? "disabled" : actionStates.indent}
label={_t("composer|format_increase_indent")}
onClick={() => composer.indent()}
icon={<IndentIcon className="mx_FormattingButtons_Icon" />}
/>
)}
{isInList && (
<Button
actionState={actionStates.unindent}
actionState={disabled ? "disabled" : actionStates.unindent}
label={_t("composer|format_decrease_indent")}
onClick={() => composer.unindent()}
icon={<UnIndentIcon className="mx_FormattingButtons_Icon" />}
/>
)}
<Button
actionState={actionStates.quote}
actionState={disabled ? "disabled" : actionStates.quote}
label={_t("action|quote")}
onClick={() => composer.quote()}
icon={<QuoteIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.inlineCode}
actionState={disabled ? "disabled" : actionStates.inlineCode}
label={_t("composer|format_inline_code")}
keyCombo={{ ctrlOrCmdKey: true, key: "e" }}
onClick={() => composer.inlineCode()}
icon={<InlineCodeIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.codeBlock}
actionState={disabled ? "disabled" : actionStates.codeBlock}
label={_t("composer|format_code_block")}
onClick={() => composer.codeBlock()}
icon={<CodeBlockIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.link}
actionState={disabled ? "disabled" : actionStates.link}
label={_t("composer|format_link")}
onClick={() => openLinkModal(composer, composerContext, actionStates.link === "reversed")}
icon={<LinkIcon className="mx_FormattingButtons_Icon" />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { parsePermalink } from "../../../../../utils/permalinks/Permalinks";
import { isNotNull } from "../../../../../Typeguards";
import { useSettingValue } from "../../../../../hooks/useSettings";
import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.tsx";
import { useContainsCommand } from "../hooks/useContainsCommand.ts";

interface WysiwygComposerProps {
disabled?: boolean;
Expand Down Expand Up @@ -83,6 +84,9 @@ export const WysiwygComposer = memo(function WysiwygComposer({
}
}, [onChange, messageContent, disabled]);

// Disable formatting buttons if the message content contains a slash command
const disableFormatting = useContainsCommand(content, room);

useEffect(() => {
function handleClick(e: Event): void {
e.preventDefault();
Expand Down Expand Up @@ -124,7 +128,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({
handleAtRoomMention={wysiwyg.mentionAtRoom}
handleCommand={wysiwyg.command}
/>
<FormattingButtons composer={wysiwyg} actionStates={actionStates} />
<FormattingButtons composer={wysiwyg} actionStates={actionStates} disabled={disableFormatting} />
<Editor
ref={ref}
disabled={!isReady}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
Copyright 2025 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import { type Room } from "matrix-js-sdk/src/matrix";
import { useEffect, useState, useRef } from "react";

import CommandProvider from "../../../../../autocomplete/CommandProvider";

/**
* A hook which determines if the given content contains a slash command.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* A hook which determines if the given content contains a slash command.
* A hook which determines if the given content contains a slash command.

* @returns true if the content contains a slash command, false otherwise.
* @param content The content to check for commands.
* @param room The current room.
*/
export function useContainsCommand(content: string | null, room: Room | undefined): boolean {
const [contentContainsCommands, setContentContainsCommands] = useState(false);
const providerRef = useRef<CommandProvider | null>(null);
const currentRoomIdRef = useRef<string | null>(null);

useEffect(() => {
if (!room || !content) {
setContentContainsCommands(false);
return;
}

// Create or reuse CommandProvider for the current room
if (!providerRef.current || currentRoomIdRef.current !== room.roomId) {
providerRef.current = new CommandProvider(room);
currentRoomIdRef.current = room.roomId;
}

const provider = providerRef.current;
provider
.getCompletions(content, { start: 0, end: 0 })
.then((results) => {
if (results.length > 0) {
setContentContainsCommands(true);
} else {
setContentContainsCommands(false);
}
})
.catch(() => {
// If there's an error getting completions, assume no commands
setContentContainsCommands(false);
});
}, [content, room]);

return contentContainsCommands;
}
Original file line number Diff line number Diff line change
Expand Up @@ -194,4 +194,14 @@ describe("FormattingButtons", () => {
expect(screen.getByLabelText("Indent increase")).toBeInTheDocument();
expect(screen.getByLabelText("Indent decrease")).toBeInTheDocument();
});

it("Every button should when disabled the component is disabled", () => {
renderComponent({ disabled: true });

Object.values(testCases).forEach((testCase) => {
const { label } = testCase;
expect(screen.getByLabelText(label)).toHaveClass(classes.disabled);
expect(screen.getByLabelText(label)).toBeDisabled();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,25 @@ describe("WysiwygComposer", () => {
// Then it sends a message
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(0));
});

it("Should disable formatting buttons when a slash command is entered", async () => {
// When
fireEvent.input(screen.getByRole("textbox"), {
data: "/rainbow",
inputType: "insertText",
});

// Then - wait for all buttons to be rendered and have the disabled class
await waitFor(() => {
const container = screen.getByTestId("WysiwygComposer");
const formattingButtons = container.querySelectorAll(".mx_FormattingButtons_Button");
expect(formattingButtons.length).toBeGreaterThan(0);

formattingButtons.forEach((btn) => {
expect(btn).toHaveClass("mx_FormattingButtons_disabled");
});
});
});
});

describe("Mentions and commands", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
Copyright 2025 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import { renderHook, waitFor } from "jest-matrix-react";
import { Room } from "matrix-js-sdk/src/matrix";

import { useContainsCommand } from "../../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useContainsCommand";
import { stubClient } from "../../../../../../test-utils";

// Mock CommandProvider
const mockGetCompletions = jest.fn();
jest.mock("../../../../../../../src/autocomplete/CommandProvider", () => {
return jest.fn().mockImplementation(() => ({
getCompletions: mockGetCompletions,
}));
});

describe("useContainsCommand", () => {
let room: Room;

beforeEach(() => {
const client = stubClient();
room = new Room("!room:example.com", client, "@user:example.com");
mockGetCompletions.mockClear();
// Default mock to return empty promise
mockGetCompletions.mockResolvedValue([]);
});

afterEach(() => {
jest.clearAllMocks();
});

it("should return false when content is null", async () => {
mockGetCompletions.mockResolvedValue([]);

const { result } = renderHook(() => useContainsCommand(null, room));

await waitFor(() => {
expect(result.current).toBe(false);
});
expect(mockGetCompletions).not.toHaveBeenCalled();
});

it("should return false when content is empty string", async () => {
mockGetCompletions.mockResolvedValue([]);

const { result } = renderHook(() => useContainsCommand("", room));

await waitFor(() => {
expect(result.current).toBe(false);
});
expect(mockGetCompletions).not.toHaveBeenCalled();
});

it("should return true when content contains a valid command", async () => {
mockGetCompletions.mockResolvedValue([{ type: "command", completion: "/spoiler" }]);

const { result } = renderHook(() => useContainsCommand("/spoiler test message", room));

await waitFor(() => {
expect(result.current).toBe(true);
});
expect(mockGetCompletions).toHaveBeenCalledWith("/spoiler test message", { start: 0, end: 0 });
});

it("should return false when content contains no valid commands", async () => {
mockGetCompletions.mockResolvedValue([]);

const { result } = renderHook(() => useContainsCommand("/invalidcommand", room));

await waitFor(() => {
expect(result.current).toBe(false);
});
expect(mockGetCompletions).toHaveBeenCalledWith("/invalidcommand", { start: 0, end: 0 });
});

it("should return true for partial command matches", async () => {
mockGetCompletions.mockResolvedValue([
{ type: "command", completion: "/spoiler" },
{ type: "command", completion: "/shrug" },
]);

const { result } = renderHook(() => useContainsCommand("/sp", room));

await waitFor(() => {
expect(result.current).toBe(true);
});
expect(mockGetCompletions).toHaveBeenCalledWith("/sp", { start: 0, end: 0 });
});

it("should update when content changes", async () => {
mockGetCompletions.mockResolvedValue([]);

const { result, rerender } = renderHook(({ content, room }) => useContainsCommand(content, room), {
initialProps: { content: "/invalid", room },
});

await waitFor(() => {
expect(result.current).toBe(false);
});

// Change to valid command
mockGetCompletions.mockResolvedValue([{ type: "command", completion: "/spoiler" }]);

rerender({ content: "/spoiler", room });

await waitFor(() => {
expect(result.current).toBe(true);
});
expect(mockGetCompletions).toHaveBeenCalledWith("/spoiler", { start: 0, end: 0 });
});

it("should handle CommandProvider errors gracefully", async () => {
mockGetCompletions.mockRejectedValueOnce(new Error("Provider error"));

const { result } = renderHook(() => useContainsCommand("/test", room));

// Should remain false even if promise rejects
await waitFor(() => {
expect(result.current).toBe(false);
});
});

it("should return false for non-command content", async () => {
mockGetCompletions.mockResolvedValue([]); // CommandProvider returns empty for non-commands

const { result } = renderHook(() => useContainsCommand("regular message", room));

await waitFor(() => {
expect(result.current).toBe(false);
});
expect(mockGetCompletions).toHaveBeenCalledWith("regular message", { start: 0, end: 0 });
});

it("should reset to false when switching to null content", async () => {
mockGetCompletions.mockResolvedValue([{ type: "command", completion: "/spoiler" }]);

const { result, rerender } = renderHook(
({ content, room }: { content: string | null; room: Room | undefined }) =>
useContainsCommand(content, room),
{
initialProps: { content: "/spoiler" as string | null, room: room as Room | undefined },
},
);

await waitFor(() => {
expect(result.current).toBe(true);
});

// Switch to null content
rerender({ content: null, room });

await waitFor(() => {
expect(result.current).toBe(false);
});
});
});
Loading