feat: implement command handling and custom rendering in editor and message components

This commit is contained in:
2026-04-21 07:25:11 +10:00
parent 8060d50a6f
commit e5f74ec13e
11 changed files with 270 additions and 74 deletions

View File

@@ -25,6 +25,7 @@ import {
PopOut,
Scroll,
Text,
color,
config,
toRem,
} from 'folds';
@@ -53,6 +54,7 @@ import {
getBeginCommand,
trimCommand,
getMentions,
setCommandValidator,
} from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider';
@@ -186,9 +188,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [autocompleteQuery, setAutocompleteQuery] =
useState<AutocompleteQuery<AutocompletePrefix>>();
const [commandHint, setCommandHint] = useState<{
command: string;
description: string;
args?: string[];
}>();
const sendTypingStatus = useTypingStatusUpdater(mx, roomId);
useEffect(() => {
setCommandValidator((commandName: string) => {
const isBuiltInCommand = commands[commandName as Command];
const isPluginCommand = pluginRegistry.getCommands().some(cmd => cmd.name === commandName);
return !!(isBuiltInCommand || isPluginCommand);
});
}, [commands]);
const handleFiles = useCallback(
async (files: File[]) => {
setUploadBoard(true);
@@ -312,7 +327,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const submit = useCallback(async () => {
uploadBoardHandlers.current?.handleSend();
const commandName = getBeginCommand(editor);
let commandName = getBeginCommand(editor);
let plainText = toPlainText(editor.children, isMarkdown).trim();
let customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, {
@@ -323,6 +338,21 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
);
let msgType = MsgType.Text;
// If no CommandElement, check if text starts with a slash command
if (!commandName && plainText.startsWith('/')) {
const match = plainText.match(/^\/(\w+)(\s|$)/);
if (match) {
const potentialCommand = match[1];
// Check if it's a valid command (built-in or plugin)
const isBuiltInCommand = commands[potentialCommand as Command];
const isPluginCommand = pluginRegistry.getCommands().some(cmd => cmd.name === potentialCommand);
if (isBuiltInCommand || isPluginCommand) {
commandName = potentialCommand;
}
}
}
if (commandName) {
plainText = trimCommand(commandName, plainText);
customHtml = trimCommand(commandName, customHtml);
@@ -347,13 +377,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
commandContent.exe(plainText);
resetEditor(editor);
resetEditorHistory(editor);
setCommandHint(undefined);
sendTypingStatus(false);
return;
}
// Check plugin commands
try {
const result = await pluginRegistry.executeCommand(commandName, plainText);
const result = await pluginRegistry.executeCommand(commandName, plainText, roomId);
if (result) {
// If command returns text, send it as a message
const content: IContent = {
@@ -368,6 +399,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}
resetEditor(editor);
resetEditorHistory(editor);
setCommandHint(undefined);
sendTypingStatus(false);
return;
}
@@ -445,6 +477,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
resetEditor(editor);
resetEditorHistory(editor);
setReplyDraft(undefined);
setCommandHint(undefined);
sendTypingStatus(false);
}, [mx, roomId, threadRootId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons]);
@@ -463,10 +496,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setAutocompleteQuery(undefined);
return;
}
if (commandHint) {
setCommandHint(undefined);
return;
}
setReplyDraft(undefined);
}
},
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing]
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing, commandHint]
);
const handleKeyUp: KeyboardEventHandler = useCallback(
@@ -485,8 +522,35 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
: undefined;
setAutocompleteQuery(query);
// Check if user is typing a command and show hint
const plainText = toPlainText(editor.children, false).trim();
const commandMatch = plainText.match(/^\/(\w+)(\s|$)/);
if (commandMatch) {
const commandName = commandMatch[1];
const builtInCmd = commands[commandName as Command];
const pluginCmd = pluginRegistry.getCommands().find(cmd => cmd.name === commandName);
if (builtInCmd) {
setCommandHint({
command: commandName,
description: builtInCmd.description,
});
} else if (pluginCmd) {
setCommandHint({
command: commandName,
description: pluginCmd.command.description || '',
args: pluginCmd.command.args,
});
} else {
setCommandHint(undefined);
}
} else {
setCommandHint(undefined);
}
},
[editor, sendTypingStatus, hideActivity]
[editor, sendTypingStatus, hideActivity, commands]
);
const handleCloseAutocomplete = useCallback(() => {
@@ -597,7 +661,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
requestClose={handleCloseAutocomplete}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.Command && (
{autocompleteQuery?.prefix === AutocompletePrefix.Command && !commandHint && (
<CommandAutocomplete
room={room}
editor={editor}
@@ -614,43 +678,72 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
onKeyUp={handleKeyUp}
onPaste={handlePaste}
top={
replyDraft && (
<div>
<>
{commandHint && (
<Box
alignItems="Center"
gap="300"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
style={{
padding: `${config.space.S200} ${config.space.S300}`,
backgroundColor: color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderBottom: 'none',
borderTopLeftRadius: config.radii.R400,
borderTopRightRadius: config.radii.R400,
marginBottom: config.space.S100,
}}
direction="Column"
gap="100"
>
<IconButton
onClick={() => setReplyDraft(undefined)}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
<Box direction="Row" gap="200" alignItems="Center">
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
<ReplyLayout
userColor={replyUsernameColor}
username={
<Text size="T300" truncate>
<b>
{getMemberDisplayName(room, replyDraft.userId) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text>
}
>
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
</ReplyLayout>
</Box>
<Text size="T200" priority="400">
<b>/{commandHint.command}</b>
{commandHint.args && commandHint.args.length > 0 && (
<span style={{ opacity: 0.6 }}> {commandHint.args.map(arg => `[${arg}]`).join(' ')}</span>
)}
</Text>
{commandHint.description && (
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
{commandHint.description}
</Text>
)}
</Box>
</div>
)
)}
{replyDraft && (
<div>
<Box
alignItems="Center"
gap="300"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<IconButton
onClick={() => setReplyDraft(undefined)}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
<Box direction="Row" gap="200" alignItems="Center">
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
<ReplyLayout
userColor={replyUsernameColor}
username={
<Text size="T300" truncate>
<b>
{getMemberDisplayName(room, replyDraft.userId) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text>
}
>
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
</ReplyLayout>
</Box>
</Box>
</div>
)}
</>
}
before={
<IconButton