feat(plugins): implement plugin loading and management system

- Added PluginLoader component to dynamically load and initialize plugins.
- Created Plugins component for managing installed and marketplace plugins.
- Introduced PluginAPI for plugin interaction and settings management.
- Defined types for plugin metadata, installed plugins, and plugin index.
- Implemented settings rendering for plugins based on their schema.
- Integrated marketplace plugin fetching and installation logic.
- Added support for enabling/disabling and uninstalling plugins.
This commit is contained in:
2026-04-18 02:55:44 +10:00
parent 9eb5e4fa32
commit d1d3033c15
13 changed files with 2674 additions and 49 deletions

View File

@@ -3,6 +3,7 @@ import { Editor } from 'slate';
import { Box, config, MenuItem, Text } from 'folds';
import { Room } from 'matrix-js-sdk';
import { Command, useCommands } from '../../hooks/useCommands';
import { pluginRegistry } from '../settings/plugins/PluginAPI';
import {
AutocompleteMenu,
AutocompleteQuery,
@@ -38,7 +39,13 @@ export function CommandAutocomplete({
}: CommandAutocompleteProps) {
const mx = useMatrixClient();
const commands = useCommands(mx, room);
const commandNames = useMemo(() => Object.keys(commands) as Command[], [commands]);
// Merge built-in commands with plugin commands
const commandNames = useMemo(() => {
const builtInCommands = Object.keys(commands) as Command[];
const pluginCommands = pluginRegistry.getCommands().map(cmd => cmd.name);
return [...builtInCommands, ...pluginCommands];
}, [commands]);
const [result, search, resetSearch] = useAsyncSearch(
commandNames,
@@ -79,33 +86,40 @@ export function CommandAutocomplete({
}
requestClose={requestClose}
>
{autoCompleteNames.map((commandName) => (
<MenuItem
key={commandName}
as="button"
radii="300"
style={{ height: 'unset' }}
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
onTabPress(evt, () => handleAutocomplete(commandName))
}
onClick={() => handleAutocomplete(commandName)}
>
<Box
style={{ padding: `${config.space.S300} 0` }}
grow="Yes"
direction="Column"
gap="100"
justifyContent="SpaceBetween"
{autoCompleteNames.map((commandName) => {
// Get description from built-in commands or plugin commands
const builtInCmd = commands[commandName as Command];
const pluginCmd = pluginRegistry.getCommands().find(cmd => cmd.name === commandName);
const description = builtInCmd?.description || pluginCmd?.command.description || '';
return (
<MenuItem
key={commandName}
as="button"
radii="300"
style={{ height: 'unset' }}
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
onTabPress(evt, () => handleAutocomplete(commandName))
}
onClick={() => handleAutocomplete(commandName)}
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>
{`/${commandName}`}
</Text>
<Text truncate priority="300" size="T200">
{commands[commandName].description}
</Text>
</Box>
</MenuItem>
))}
<Box
style={{ padding: `${config.space.S300} 0` }}
grow="Yes"
direction="Column"
gap="100"
justifyContent="SpaceBetween"
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>
{`/${commandName}`}
</Text>
<Text truncate priority="300" size="T200">
{description}
</Text>
</Box>
</MenuItem>
);
})}
</AutocompleteMenu>
);
}

View File

@@ -102,6 +102,7 @@ import {
import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
import { pluginRegistry } from '../settings/plugins/PluginAPI';
import { mobileOrTablet } from '../../utils/user-agent';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { ReplyLayout, ThreadIndicator } from '../../components/message';
@@ -308,7 +309,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
contents.forEach((content) => mx.sendMessage(roomId, content as any));
};
const submit = useCallback(() => {
const submit = useCallback(async () => {
uploadBoardHandlers.current?.handleSend();
const commandName = getBeginCommand(editor);
@@ -340,9 +341,30 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
plainText = `${UNFLIP} ${plainText}`;
customHtml = `${UNFLIP} ${customHtml}`;
} else if (commandName) {
// Check built-in commands first
const commandContent = commands[commandName as Command];
if (commandContent) {
commandContent.exe(plainText);
resetEditor(editor);
resetEditorHistory(editor);
sendTypingStatus(false);
return;
}
// Check plugin commands
try {
const result = await pluginRegistry.executeCommand(commandName, plainText);
if (result) {
// If command returns text, send it as a message
const content: IContent = {
msgtype: MsgType.Text,
body: result,
};
mx.sendMessage(roomId, content as any);
}
} catch (err) {
console.error(`[RoomInput] Plugin command /${commandName} failed:`, err);
// Show error to user (could integrate with notification system)
}
resetEditor(editor);
resetEditorHistory(editor);
@@ -398,6 +420,27 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
'm.in_reply_to': { event_id: threadRootId },
};
}
// Process message through plugin interceptors
try {
const messageContext = {
content: content.body,
roomId,
eventType: 'm.room.message',
formatted: content.formatted_body,
metadata: {},
};
const processedContext = await pluginRegistry.processBeforeSend(messageContext);
// Update content with intercepted values
content.body = processedContext.content;
if (processedContext.formatted) {
content.formatted_body = processedContext.formatted;
}
} catch (err) {
console.error('[RoomInput] Plugin interceptor error:', err);
}
mx.sendMessage(roomId, content as any);
resetEditor(editor);
resetEditorHistory(editor);