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 { testMatrixTo } from '../plugins/matrix-to';
import { IImageContent } from '../../types/matrix/common'; import { IImageContent } from '../../types/matrix/common';
import { filterDisabledUrls } from '../hooks/useRoomEmbedFilters'; import { filterDisabledUrls } from '../hooks/useRoomEmbedFilters';
import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
type RenderMessageContentProps = { type RenderMessageContentProps = {
displayName: string; displayName: string;
@@ -309,5 +310,36 @@ export function RenderMessageContent({
return <MBadEncrypted />; 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 />; return <UnsupportedContent />;
} }

View File

@@ -8,7 +8,7 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
import { Box, Scroll, Text } from 'folds'; 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 { import {
Slate, Slate,
Editable, Editable,
@@ -23,6 +23,7 @@ import { RenderElement, RenderLeaf } from './Elements';
import { CustomElement } from './slate'; import { CustomElement } from './slate';
import * as css from './Editor.css'; import * as css from './Editor.css';
import { toggleKeyboardShortcut } from './keyboard'; import { toggleKeyboardShortcut } from './keyboard';
import { createCommandElement } from './utils';
const initialValue: CustomElement[] = [ const initialValue: CustomElement[] = [
{ {
@@ -52,8 +53,25 @@ const withVoid = (editor: Editor): Editor => {
return 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 => { export const useEditor = (): Editor => {
const [editor] = useState(() => withInline(withVoid(withReact(withHistory(createEditor()))))); const [editor] = useState(() =>
withCommandAutoConvert(withInline(withVoid(withReact(withHistory(createEditor())))))
);
return editor; return editor;
}; };
@@ -97,6 +115,49 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []); 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( const handleKeydown: KeyboardEventHandler = useCallback(
(evt) => { (evt) => {
onKeyDown?.(evt); onKeyDown?.(evt);
@@ -143,6 +204,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
renderPlaceholder={renderPlaceholder} renderPlaceholder={renderPlaceholder}
renderElement={renderElement} renderElement={renderElement}
renderLeaf={renderLeaf} renderLeaf={renderLeaf}
decorate={decorate}
onKeyDown={handleKeydown} onKeyDown={handleKeydown}
onKeyUp={onKeyUp} onKeyUp={onKeyUp}
onPaste={onPaste} onPaste={onPaste}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,6 +25,7 @@ import {
PopOut, PopOut,
Scroll, Scroll,
Text, Text,
color,
config, config,
toRem, toRem,
} from 'folds'; } from 'folds';
@@ -53,6 +54,7 @@ import {
getBeginCommand, getBeginCommand,
trimCommand, trimCommand,
getMentions, getMentions,
setCommandValidator,
} from '../../components/editor'; } from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board'; import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
@@ -186,9 +188,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [autocompleteQuery, setAutocompleteQuery] = const [autocompleteQuery, setAutocompleteQuery] =
useState<AutocompleteQuery<AutocompletePrefix>>(); useState<AutocompleteQuery<AutocompletePrefix>>();
const [commandHint, setCommandHint] = useState<{
command: string;
description: string;
args?: string[];
}>();
const sendTypingStatus = useTypingStatusUpdater(mx, roomId); 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( const handleFiles = useCallback(
async (files: File[]) => { async (files: File[]) => {
setUploadBoard(true); setUploadBoard(true);
@@ -312,7 +327,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const submit = useCallback(async () => { const submit = useCallback(async () => {
uploadBoardHandlers.current?.handleSend(); uploadBoardHandlers.current?.handleSend();
const commandName = getBeginCommand(editor); let commandName = getBeginCommand(editor);
let plainText = toPlainText(editor.children, isMarkdown).trim(); let plainText = toPlainText(editor.children, isMarkdown).trim();
let customHtml = trimCustomHtml( let customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, { toMatrixCustomHTML(editor.children, {
@@ -323,6 +338,21 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
); );
let msgType = MsgType.Text; 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) { if (commandName) {
plainText = trimCommand(commandName, plainText); plainText = trimCommand(commandName, plainText);
customHtml = trimCommand(commandName, customHtml); customHtml = trimCommand(commandName, customHtml);
@@ -347,13 +377,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
commandContent.exe(plainText); commandContent.exe(plainText);
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
setCommandHint(undefined);
sendTypingStatus(false); sendTypingStatus(false);
return; return;
} }
// Check plugin commands // Check plugin commands
try { try {
const result = await pluginRegistry.executeCommand(commandName, plainText); const result = await pluginRegistry.executeCommand(commandName, plainText, roomId);
if (result) { if (result) {
// If command returns text, send it as a message // If command returns text, send it as a message
const content: IContent = { const content: IContent = {
@@ -368,6 +399,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
} }
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
setCommandHint(undefined);
sendTypingStatus(false); sendTypingStatus(false);
return; return;
} }
@@ -445,6 +477,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
setReplyDraft(undefined); setReplyDraft(undefined);
setCommandHint(undefined);
sendTypingStatus(false); sendTypingStatus(false);
}, [mx, roomId, threadRootId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons]); }, [mx, roomId, threadRootId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, autoConvertEmoticons]);
@@ -463,10 +496,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setAutocompleteQuery(undefined); setAutocompleteQuery(undefined);
return; return;
} }
if (commandHint) {
setCommandHint(undefined);
return;
}
setReplyDraft(undefined); setReplyDraft(undefined);
} }
}, },
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing] [submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing, commandHint]
); );
const handleKeyUp: KeyboardEventHandler = useCallback( const handleKeyUp: KeyboardEventHandler = useCallback(
@@ -485,8 +522,35 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES) ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
: undefined; : undefined;
setAutocompleteQuery(query); 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(() => { const handleCloseAutocomplete = useCallback(() => {
@@ -597,7 +661,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
requestClose={handleCloseAutocomplete} requestClose={handleCloseAutocomplete}
/> />
)} )}
{autocompleteQuery?.prefix === AutocompletePrefix.Command && ( {autocompleteQuery?.prefix === AutocompletePrefix.Command && !commandHint && (
<CommandAutocomplete <CommandAutocomplete
room={room} room={room}
editor={editor} editor={editor}
@@ -614,7 +678,35 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
onKeyUp={handleKeyUp} onKeyUp={handleKeyUp}
onPaste={handlePaste} onPaste={handlePaste}
top={ top={
replyDraft && ( <>
{commandHint && (
<Box
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"
>
<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>
)}
{replyDraft && (
<div> <div>
<Box <Box
alignItems="Center" alignItems="Center"
@@ -650,7 +742,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</Box> </Box>
</Box> </Box>
</div> </div>
) )}
</>
} }
before={ before={
<IconButton <IconButton

View File

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