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

@@ -33,6 +33,7 @@ import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to';
import { IImageContent } from '../../types/matrix/common';
import { filterDisabledUrls } from '../hooks/useRoomEmbedFilters';
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
type RenderMessageContentProps = {
displayName: string;
@@ -309,5 +310,36 @@ export function RenderMessageContent({
return <MBadEncrypted />;
}
// Check for custom renderer from plugins FIRST
const customRenderer = pluginRegistry.getRenderer('message');
console.log('[RenderMessageContent] customRenderer function:', typeof customRenderer);
if (customRenderer) {
try {
const messageData = {
msgtype: msgType,
...getContent(),
};
console.log('[RenderMessageContent] Calling customRenderer with msgtype:', msgType);
console.log('[RenderMessageContent] messageData:', messageData);
const customContent = customRenderer(
messageData,
() => {
console.log('[RenderMessageContent] defaultRenderer called');
return null;
}
);
console.log('[RenderMessageContent] customRenderer returned:', customContent);
console.log('[RenderMessageContent] customContent type:', typeof customContent);
if (customContent) {
console.log('[RenderMessageContent] Returning custom content');
return customContent as React.ReactElement;
}
} catch (error) {
console.error('[RenderMessageContent] Custom renderer error:', error);
}
} else {
console.log('[RenderMessageContent] No custom renderer registered');
}
return <UnsupportedContent />;
}

View File

@@ -8,7 +8,7 @@ import React, {
useState,
} from 'react';
import { Box, Scroll, Text } from 'folds';
import { Descendant, Editor, createEditor } from 'slate';
import { Descendant, Editor, createEditor, Transforms, Range, Element as SlateElement, Text as SlateText, Point } from 'slate';
import {
Slate,
Editable,
@@ -23,6 +23,7 @@ import { RenderElement, RenderLeaf } from './Elements';
import { CustomElement } from './slate';
import * as css from './Editor.css';
import { toggleKeyboardShortcut } from './keyboard';
import { createCommandElement } from './utils';
const initialValue: CustomElement[] = [
{
@@ -52,8 +53,25 @@ const withVoid = (editor: Editor): Editor => {
return editor;
};
type CommandValidatorFn = (commandName: string) => boolean;
let commandValidator: CommandValidatorFn | null = null;
export const setCommandValidator = (validator: CommandValidatorFn) => {
commandValidator = validator;
};
const withCommandAutoConvert = (editor: Editor): Editor => {
// Removed auto-conversion on space to prevent focus issues
// Commands are now highlighted visually via decorations
// and executed when Enter is pressed
return editor;
};
export const useEditor = (): Editor => {
const [editor] = useState(() => withInline(withVoid(withReact(withHistory(createEditor())))));
const [editor] = useState(() =>
withCommandAutoConvert(withInline(withVoid(withReact(withHistory(createEditor())))))
);
return editor;
};
@@ -97,6 +115,49 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
const decorate = useCallback(([node, path]: [any, number[]]) => {
const ranges: any[] = [];
// Only decorate text nodes in the first paragraph
if (
path.length === 2 &&
path[0] === 0 &&
path[1] === 0 &&
SlateText.isText(node)
) {
const firstChild = editor.children[0];
if (SlateElement.isElement(firstChild) && firstChild.type === BlockType.Paragraph) {
const [firstInline, secondInline] = firstChild.children;
// Don't decorate if we already have a CommandElement
if (SlateElement.isElement(secondInline) && secondInline.type === BlockType.Command) {
return ranges;
}
// Check if the text matches /command pattern
const text = node.text;
const match = text.match(/^(\s*\/\w+)/);
if (match && commandValidator) {
const commandText = match[1].trim();
const commandName = commandText.substring(1);
if (commandValidator(commandName)) {
// Create decoration for the command text
ranges.push({
anchor: { path, offset: 0 },
focus: { path, offset: match[1].length },
pendingCommand: true,
});
}
}
}
}
return ranges;
}, [editor]);
const handleKeydown: KeyboardEventHandler = useCallback(
(evt) => {
onKeyDown?.(evt);
@@ -143,6 +204,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
renderPlaceholder={renderPlaceholder}
renderElement={renderElement}
renderLeaf={renderLeaf}
decorate={decorate}
onKeyDown={handleKeydown}
onKeyUp={onKeyUp}
onPaste={onPaste}

View File

@@ -269,6 +269,13 @@ export function RenderLeaf({ attributes, leaf, children }: RenderLeafProps) {
{child}
</span>
);
if (leaf.pendingCommand)
child = (
<span className={css.Command({ active: true })} {...attributes}>
<InlineChromiumBugfix />
{child}
</span>
);
if (child !== children) return child;

View File

@@ -1,6 +1,6 @@
export * from './autocomplete';
export * from './utils';
export * from './Editor';
export { CustomEditor, useEditor, setCommandValidator } from './Editor';
export * from './Elements';
export * from './keyboard';
export * from './output';

View File

@@ -18,6 +18,7 @@ export type FormattedText = Text & {
strikeThrough?: boolean;
code?: boolean;
spoiler?: boolean;
pendingCommand?: boolean;
};
export type LinkElement = {

View File

@@ -1,20 +1,23 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset } from 'folds';
import { DefaultReset, toRem } from 'folds';
export const Image = style([
DefaultReset,
{
objectFit: 'cover',
width: '100%',
height: '100%',
display: 'block',
maxWidth: '100%',
maxHeight: '100%',
imageRendering: 'auto',
backfaceVisibility: 'hidden',
transform: 'translateZ(0)',
},
]);
export const Video = style([
DefaultReset,
{
objectFit: 'contain',
width: '100%',
height: '100%',
display: 'block',
maxWidth: '100%',
maxHeight: '100%',
},
]);

View File

@@ -195,15 +195,10 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
if (typeof mxcUrl !== 'string') {
return <BrokenContent />;
}
const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400);
return (
<Attachment outlined={outlined}>
<AttachmentBox
style={{
height: toRem(height < 48 ? 48 : height),
}}
>
<AttachmentBox>
{renderImageContent({
body: content.body || 'Image',
info: imgInfo,
@@ -245,8 +240,6 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
return <BrokenContent />;
}
const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
const filename = content.filename ?? content.body ?? 'Video';
return (
@@ -265,11 +258,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
}
/>
</AttachmentHeader>
<AttachmentBox
style={{
height: toRem(height < 48 ? 48 : height),
}}
>
<AttachmentBox>
{renderVideoContent({
body: content.body || 'Video',
info: videoInfo,

View File

@@ -8,8 +8,7 @@ export const Attachment = recipe({
color: color.SurfaceVariant.OnContainer,
borderRadius: config.radii.R400,
overflow: 'hidden',
maxWidth: '100%',
width: toRem(400),
maxWidth: toRem(400),
},
variants: {
outlined: {
@@ -29,9 +28,8 @@ export const AttachmentHeader = style({
export const AttachmentBox = style([
DefaultReset,
{
maxWidth: '100%',
maxHeight: toRem(600),
width: toRem(400),
maxWidth: toRem(400),
maxHeight: toRem(400),
overflow: 'hidden',
},
]);

View File

@@ -5,19 +5,20 @@ export const RelativeBase = style([
DefaultReset,
{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
maxWidth: '100%',
maxHeight: '100%',
},
]);
export const AbsoluteContainer = style([
DefaultReset,
{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
maxWidth: '100%',
maxHeight: '100%',
},
]);

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

View File

@@ -14,10 +14,14 @@ interface PluginLoaderProps {
* Should be placed high in the component tree after MatrixClient is available.
*/
export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
console.log('[PluginLoader] Component rendering, matrixClient:', !!matrixClient);
useEffect(() => {
const loadPlugins = async () => {
try {
console.log('[PluginLoader] Loading plugins...');
console.log('[PluginLoader] window.electron:', !!window.electron);
console.log('[PluginLoader] window.electron.plugins:', !!window.electron?.plugins);
if (!window.electron?.plugins) {
console.log('[PluginLoader] Running in web mode, plugins not supported');
@@ -27,6 +31,7 @@ export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
const pluginsToLoad = await pluginMarketplaceManager.listEnabledPlugins();
console.log('[PluginLoader] Plugins to load:', pluginsToLoad.length);
console.log('[PluginLoader] Plugin list:', pluginsToLoad.map(p => p.id));
for (const installedPlugin of pluginsToLoad) {
try {
@@ -65,6 +70,7 @@ export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
);
const compatContext = Object.assign(context as Record<string, unknown>, {
matrixClient,
matrix: {
on: (eventType: string, handler: (...args: unknown[]) => void) =>
matrixClient.on(eventType as any, handler as any),
@@ -74,6 +80,10 @@ export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
React,
});
console.log('[PluginLoader] compatContext.React:', !!compatContext.React);
console.log('[PluginLoader] compatContext.ui:', !!compatContext.ui);
console.log('[PluginLoader] About to register plugin and call onLoad...');
pluginRegistry.registerPlugin(installedPlugin.id, plugin, context);
await plugin.onLoad(compatContext as any);
console.log(`[PluginLoader] Successfully loaded plugin: ${installedPlugin.id}`);