diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 49fdf00..5da62e0 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -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 ; } + // 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 ; } diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index bd848dd..4d89bac 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -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( const renderLeaf = useCallback((props: RenderLeafProps) => , []); + 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( renderPlaceholder={renderPlaceholder} renderElement={renderElement} renderLeaf={renderLeaf} + decorate={decorate} onKeyDown={handleKeydown} onKeyUp={onKeyUp} onPaste={onPaste} diff --git a/src/app/components/editor/Elements.tsx b/src/app/components/editor/Elements.tsx index 675c454..25f0305 100644 --- a/src/app/components/editor/Elements.tsx +++ b/src/app/components/editor/Elements.tsx @@ -269,6 +269,13 @@ export function RenderLeaf({ attributes, leaf, children }: RenderLeafProps) { {child} ); + if (leaf.pendingCommand) + child = ( + + + {child} + + ); if (child !== children) return child; diff --git a/src/app/components/editor/index.ts b/src/app/components/editor/index.ts index aae0137..b213579 100644 --- a/src/app/components/editor/index.ts +++ b/src/app/components/editor/index.ts @@ -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'; diff --git a/src/app/components/editor/slate.d.ts b/src/app/components/editor/slate.d.ts index da1460e..04c7ea3 100644 --- a/src/app/components/editor/slate.d.ts +++ b/src/app/components/editor/slate.d.ts @@ -18,6 +18,7 @@ export type FormattedText = Text & { strikeThrough?: boolean; code?: boolean; spoiler?: boolean; + pendingCommand?: boolean; }; export type LinkElement = { diff --git a/src/app/components/media/media.css.ts b/src/app/components/media/media.css.ts index b563ef3..f167966 100644 --- a/src/app/components/media/media.css.ts +++ b/src/app/components/media/media.css.ts @@ -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%', }, ]); diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index a40ecae..8dc3bb3 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -195,15 +195,10 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) { if (typeof mxcUrl !== 'string') { return ; } - const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400); return ( - + {renderImageContent({ body: content.body || 'Image', info: imgInfo, @@ -245,8 +240,6 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: return ; } - 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 }: } /> - + {renderVideoContent({ body: content.body || 'Video', info: videoInfo, diff --git a/src/app/components/message/attachment/Attachment.css.ts b/src/app/components/message/attachment/Attachment.css.ts index 4c41761..41fedc9 100644 --- a/src/app/components/message/attachment/Attachment.css.ts +++ b/src/app/components/message/attachment/Attachment.css.ts @@ -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', }, ]); diff --git a/src/app/components/message/content/style.css.ts b/src/app/components/message/content/style.css.ts index bb5d848..9d79dfc 100644 --- a/src/app/components/message/content/style.css.ts +++ b/src/app/components/message/content/style.css.ts @@ -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%', }, ]); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 397d3f5..afbdd59 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -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( const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [autocompleteQuery, setAutocompleteQuery] = useState>(); + 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( 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( ); 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( 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( } resetEditor(editor); resetEditorHistory(editor); + setCommandHint(undefined); sendTypingStatus(false); return; } @@ -445,6 +477,7 @@ export const RoomInput = forwardRef( 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( 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( ? getAutocompleteQuery(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( requestClose={handleCloseAutocomplete} /> )} - {autocompleteQuery?.prefix === AutocompletePrefix.Command && ( + {autocompleteQuery?.prefix === AutocompletePrefix.Command && !commandHint && ( ( onKeyUp={handleKeyUp} onPaste={handlePaste} top={ - replyDraft && ( -
+ <> + {commandHint && ( - setReplyDraft(undefined)} - variant="SurfaceVariant" - size="300" - radii="300" - > - - - - {replyDraft.relation?.rel_type === RelationType.Thread && } - - - {getMemberDisplayName(room, replyDraft.userId) ?? - getMxIdLocalPart(replyDraft.userId) ?? - replyDraft.userId} - - - } - > - - {trimReplyFromBody(replyDraft.body)} - - - + + /{commandHint.command} + {commandHint.args && commandHint.args.length > 0 && ( + {commandHint.args.map(arg => `[${arg}]`).join(' ')} + )} + + {commandHint.description && ( + + {commandHint.description} + + )} -
- ) + )} + {replyDraft && ( +
+ + setReplyDraft(undefined)} + variant="SurfaceVariant" + size="300" + radii="300" + > + + + + {replyDraft.relation?.rel_type === RelationType.Thread && } + + + {getMemberDisplayName(room, replyDraft.userId) ?? + getMxIdLocalPart(replyDraft.userId) ?? + replyDraft.userId} + + + } + > + + {trimReplyFromBody(replyDraft.body)} + + + + +
+ )} + } before={ { 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, { + 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}`);