diff --git a/src/app/features/room/CommandAutocomplete.tsx b/src/app/features/room/CommandAutocomplete.tsx
index 6b7ba56..dbf04db 100644
--- a/src/app/features/room/CommandAutocomplete.tsx
+++ b/src/app/features/room/CommandAutocomplete.tsx
@@ -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) => (
-
- ))}
+
+
+ {`/${commandName}`}
+
+
+ {description}
+
+
+
+ );
+ })}
);
}
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index e270a54..397d3f5 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -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(
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(
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(
'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);
diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx
index 72068de..2f1f58c 100644
--- a/src/app/features/settings/Settings.tsx
+++ b/src/app/features/settings/Settings.tsx
@@ -31,6 +31,7 @@ import { EmojisStickers } from './emojis-stickers';
import { DeveloperTools } from './developer-tools';
import { About } from './about';
import { Audio } from './audio';
+import { Plugins } from './plugins';
import { UseStateProvider } from '../../components/UseStateProvider';
import { stopPropagation } from '../../utils/keyboard';
import { LogoutDialog } from '../../components/LogoutDialog';
@@ -42,6 +43,7 @@ export enum SettingsPages {
AudioPage,
DevicesPage,
EmojisStickersPage,
+ PluginsPage,
DeveloperToolsPage,
AboutPage,
}
@@ -85,6 +87,11 @@ const useSettingsMenuItems = (): SettingsMenuItem[] =>
name: 'Emojis & Stickers',
icon: Icons.Smile,
},
+ {
+ page: SettingsPages.PluginsPage,
+ name: 'Plugins',
+ icon: Icons.Download,
+ },
{
page: SettingsPages.DeveloperToolsPage,
name: 'Developer Tools',
@@ -235,6 +242,9 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
{activePage === SettingsPages.EmojisStickersPage && (
)}
+ {activePage === SettingsPages.PluginsPage && (
+
+ )}
{activePage === SettingsPages.DeveloperToolsPage && (
)}
diff --git a/src/app/features/settings/plugins/PLUGIN_DEVELOPMENT.md b/src/app/features/settings/plugins/PLUGIN_DEVELOPMENT.md
new file mode 100644
index 0000000..18ff2bf
--- /dev/null
+++ b/src/app/features/settings/plugins/PLUGIN_DEVELOPMENT.md
@@ -0,0 +1,612 @@
+# Plugin Development Guide
+
+## Plugin Structure
+
+Each plugin must be a directory containing at least:
+- `index.js` - Main entry point
+- `plugin-metadata.json` - Plugin metadata
+
+### Example Directory Structure
+```
+my-plugin/
+├── index.js
+├── plugin-metadata.json
+└── assets/
+ └── icon.png
+```
+
+## Plugin Metadata
+
+The `plugin-metadata.json` file contains information about your plugin:
+
+```json
+{
+ "name": "My Cool Plugin",
+ "version": "1.0.0",
+ "description": "Does cool things",
+ "author": "Your Name",
+ "homepage": "https://github.com/yourusername/my-plugin",
+ "thumbnail": "https://example.com/thumbnail.png",
+ "tags": ["utility", "ui"]
+}
+```
+
+## Plugin Entry Point (index.js)
+
+Your `index.js` must export a default plugin object with an `onLoad` function:
+
+```javascript
+// index.js
+module.exports = {
+ name: "My Plugin",
+ version: "1.0.0",
+ dependencies: {
+ "paarrot.utils": "^1.0.0" // Optional: depend on other plugins
+ },
+
+ /**
+ * Called when the plugin is loaded
+ * @param {PluginContext} ctx - The plugin context
+ */
+ onLoad: async (ctx) => {
+ ctx.log('Plugin loaded!');
+
+ // Your plugin code here
+ },
+
+ /**
+ * Called when the plugin is unloaded (optional)
+ */
+ onUnload: async () => {
+ ctx.log('Plugin unloaded!');
+ // Cleanup resources here
+ },
+
+ /**
+ * Exports for other plugins to use (optional)
+ */
+ exports: {
+ myFunction: () => { /* ... */ }
+ }
+};
+```
+
+## Plugin Context API
+
+The `ctx` object provided to your plugin contains:
+
+### 🎯 Commands API
+
+Register slash commands with argument parsing:
+
+```javascript
+// Simple command
+ctx.commands.register({
+ name: "shrug",
+ run: () => "¯\\_(ツ)_/¯"
+});
+
+// Command with args
+ctx.commands.register({
+ name: "echo",
+ args: ["text"],
+ run: ({ text }) => text
+});
+
+// Advanced command
+ctx.commands.register({
+ name: "greet",
+ description: "Greet someone",
+ args: ["name", "greeting"],
+ run: ({ name, greeting }) => {
+ return `${greeting || 'Hello'}, ${name || 'stranger'}!`;
+ }
+});
+
+// Execute commands programmatically
+ctx.commands.execute("shrug", {});
+
+// Unregister a command
+ctx.commands.unregister("mycommand");
+```
+
+### 💬 Messages API
+
+Intercept and modify messages:
+
+```javascript
+// Before send interceptor
+ctx.messages.onBeforeSend((msg) => {
+ if (msg.content === "brb") {
+ msg.content = "be right back!";
+ }
+
+ // Add prefix
+ msg.content = `[Bot] ${msg.content}`;
+
+ // Access metadata
+ msg.metadata = { processed: true };
+});
+
+// Receive interceptor
+ctx.messages.onReceive((msg) => {
+ if (msg.content.includes("@bot")) {
+ ctx.log('Bot was mentioned!');
+ }
+});
+```
+
+MessageContext interface:
+```typescript
+interface MessageContext {
+ content: string;
+ roomId: string;
+ eventType: string;
+ formatted?: string;
+ metadata?: Record;
+}
+```
+
+### 🎨 UI API
+
+Register custom renderers:
+
+```javascript
+ctx.ui.registerRenderer("message", (msg, defaultRenderer) => {
+ if (msg.type === "image") {
+ return `
`;
+ }
+ // Fall back to default
+ return defaultRenderer?.();
+});
+
+// Unregister renderer
+ctx.ui.unregisterRenderer("message");
+```
+
+### ⚙️ Settings API
+
+Define plugin settings with UI:
+
+```javascript
+ctx.settings.define({
+ theme: {
+ type: "select",
+ label: "Theme",
+ description: "Choose your theme",
+ options: [
+ { value: "dark", label: "Dark" },
+ { value: "light", label: "Light" }
+ ],
+ default: "dark"
+ },
+ spamFilter: {
+ type: "boolean",
+ label: "Spam Filter",
+ description: "Enable spam filtering",
+ default: true
+ },
+ prefix: {
+ type: "string",
+ label: "Command Prefix",
+ default: "!"
+ },
+ maxMessages: {
+ type: "number",
+ label: "Max Messages",
+ default: 100
+ },
+ accentColor: {
+ type: "color",
+ label: "Accent Color",
+ default: "#6366f1"
+ }
+});
+
+// Get/set settings
+const theme = ctx.settings.get('theme');
+ctx.settings.set('theme', 'light');
+```
+
+### 🪝 Matrix Events API
+
+Listen to raw Matrix events:
+
+```javascript
+ctx.matrix.on("m.room.message", (event) => {
+ const content = event.getContent();
+ ctx.log('Message received:', content.body);
+});
+
+ctx.matrix.on("Room.timeline", (event) => {
+ if (event.getType() === 'm.room.member') {
+ ctx.log('Member event:', event.getSender());
+ }
+});
+
+// Remove listener
+ctx.matrix.off("m.room.message", handler);
+```
+
+Common events:
+- `"m.room.message"` - Room messages
+- `"m.room.member"` - Membership changes
+- `"Room.timeline"` - Timeline events
+- `"sync"` - Sync state changes
+- `"RoomState.events"` - State events
+
+### ⏱️ Timers API
+
+Run background tasks:
+
+```javascript
+// Set interval
+const intervalId = ctx.timers.setInterval(() => {
+ ctx.log('Running every 10 seconds');
+ // Auto-cleanup bots, sync data, etc.
+}, 10000);
+
+// Set timeout
+const timeoutId = ctx.timers.setTimeout(() => {
+ ctx.log('Running once after 5 seconds');
+}, 5000);
+
+// Clear timers
+ctx.timers.clearInterval(intervalId);
+ctx.timers.clearTimeout(timeoutId);
+
+// Timers are auto-cleaned on plugin unload
+```
+
+### 📣 Notifications API
+
+Show notifications to users:
+
+```javascript
+// Simple notification
+ctx.notify("Plugin loaded!");
+
+// Advanced notification
+ctx.notify({
+ title: "New Message",
+ body: "You have a new message from Steve",
+ type: "info", // info, success, error, warning
+ duration: 5000,
+ actions: [
+ {
+ label: "View",
+ action: () => {
+ // Handle action
+ }
+ }
+ ]
+});
+```
+
+### 📝 Logging API
+
+Per-plugin logging:
+
+```javascript
+ctx.log('Info message', { data: 123 });
+ctx.warn('Warning message');
+ctx.error('Error message', error);
+
+// Logs are visible in plugin logs panel
+// Format: [Plugin pluginId] message
+```
+
+### 🔗 Require API
+
+Use other plugins:
+
+```javascript
+// Require another plugin's exports
+const utils = ctx.require("paarrot.utils");
+utils.formatDate(new Date());
+
+// Check if plugin exists
+try {
+ const plugin = ctx.require("optional.plugin");
+} catch (err) {
+ ctx.warn('Optional plugin not available');
+}
+```
+
+### 🔌 Matrix Client
+
+Direct access to Matrix.js SDK:
+
+```javascript
+const mx = ctx.matrixClient;
+
+// Get user info
+const userId = mx.getUserId();
+const user = mx.getUser(userId);
+
+// Get rooms
+const rooms = mx.getRooms();
+const room = mx.getRoom(roomId);
+
+// Send messages
+await mx.sendTextMessage(roomId, "Hello!");
+
+// Full Matrix.js SDK available
+```
+
+### ⚛️ React
+
+Access to React:
+
+```javascript
+const { useState, useEffect } = ctx.React;
+
+// Create custom UI components
+const MyComponent = () => {
+ const [count, setCount] = useState(0);
+ return ctx.React.createElement('button', {
+ onClick: () => setCount(count + 1)
+ }, `Count: ${count}`);
+};
+```
+
+## Complete Example
+
+See [`example-plugin/index.js`](../../../../example-plugin/index.js) for a full-featured example.
+
+```javascript
+module.exports = {
+ name: "Awesome Plugin",
+ version: "1.0.0",
+
+ onLoad: async (ctx) => {
+ // Register commands
+ ctx.commands.register({
+ name: "shrug",
+ run: () => "¯\\_(ツ)_/¯"
+ });
+
+ // Message interceptors
+ ctx.messages.onBeforeSend((msg) => {
+ if (msg.content === "brb") {
+ msg.content = "be right back!";
+ }
+ });
+
+ // Settings
+ ctx.settings.define({
+ enabled: { type: "boolean", default: true }
+ });
+
+ // Matrix events
+ ctx.matrix.on("m.room.message", (event) => {
+ ctx.log('Message:', event.getContent().body);
+ });
+
+ // Background task
+ ctx.timers.setInterval(() => {
+ ctx.log('Checking for updates...');
+ }, 60000);
+
+ ctx.notify("Plugin loaded!");
+ },
+
+ onUnload: async () => {
+ // Cleanup happens automatically
+ },
+
+ exports: {
+ version: "1.0.0"
+ }
+};
+```
+
+## Best Practices
+
+### 1. Error Handling
+Always wrap your code in try-catch blocks:
+
+```javascript
+onLoad: async (ctx) => {
+ try {
+ // Your code
+ } catch (error) {
+ ctx.error('Failed to load:', error);
+ }
+}
+```
+
+### 2. Cleanup Resources
+Timers and Matrix handlers are auto-cleaned, but clean up other resources:
+
+```javascript
+onUnload: async () => {
+ // Close connections, save state, etc.
+}
+```
+
+### 3. Performance
+Don't block the main thread:
+
+```javascript
+// Bad
+for (let i = 0; i < 1000000; i++) { /* ... */ }
+
+// Good
+ctx.timers.setTimeout(() => {
+ // Heavy work in background
+}, 0);
+```
+
+### 4. Settings Best Practices
+Provide defaults and descriptions:
+
+```javascript
+ctx.settings.define({
+ maxRetries: {
+ type: "number",
+ label: "Max Retries",
+ description: "Number of retry attempts",
+ default: 3
+ }
+});
+```
+
+### 5. Logging
+Use ctx.log() instead of console.log():
+
+```javascript
+ctx.log('Info'); // Shows in plugin logs panel
+ctx.warn('Warning'); // Highlighted as warning
+ctx.error('Error'); // Highlighted as error
+```
+
+## Advanced Patterns
+
+### Plugin Dependencies
+
+Require and use other plugins:
+
+```javascript
+onLoad: async (ctx) => {
+ try {
+ const utils = ctx.require("paarrot.utils");
+ const formatted = utils.formatDate(new Date());
+ ctx.log('Using utils:', formatted);
+ } catch (err) {
+ ctx.warn('Utils plugin not available, using fallback');
+ }
+}
+```
+
+### Conditional Features
+
+Enable features based on settings:
+
+```javascript
+const autoReply = ctx.settings.get('autoReply');
+if (autoReply) {
+ ctx.matrix.on("m.room.message", (event) => {
+ // Auto-reply logic
+ });
+}
+```
+
+### State Management
+
+Store plugin state in settings:
+
+```javascript
+// Save state
+ctx.settings.set('lastRun', Date.now());
+ctx.settings.set('messageCount', count);
+
+// Load state
+const lastRun = ctx.settings.get('lastRun');
+```
+
+### Command Aliases
+
+Create multiple commands for the same function:
+
+```javascript
+const shrugFn = () => "¯\\_(ツ)_/¯";
+
+ctx.commands.register({ name: "shrug", run: shrugFn });
+ctx.commands.register({ name: "dunno", run: shrugFn });
+```
+
+### Hot Reload Support
+
+Make your plugin hot-reload friendly:
+
+```javascript
+let messageHandler = null;
+
+onLoad: async (ctx) => {
+ messageHandler = (event) => { /* ... */ };
+ ctx.matrix.on("m.room.message", messageHandler);
+},
+
+onUnload: async () => {
+ if (messageHandler) {
+ // Cleanup happens automatically but explicit is good
+ }
+}
+```
+
+## Security Considerations
+
+1. **Never store passwords or tokens in plaintext**
+2. **Validate all user input**
+3. **Don't execute arbitrary code from messages**
+4. **Use HTTPS for external requests**
+5. **Respect user privacy - don't log sensitive data**
+
+## Packaging
+
+To package your plugin:
+
+1. Create a ZIP file containing your plugin directory
+2. The ZIP should contain the plugin folder at the root:
+ ```
+ my-plugin.zip
+ └── my-plugin/
+ ├── index.js
+ ├── plugin-metadata.json
+ └── assets/
+ ```
+3. Upload to a URL accessible by users
+4. Add your plugin to the Plugin Directory repository
+
+## Plugin Directory
+
+To list your plugin in the marketplace:
+
+1. Fork the [Paarrot Plugin Directory](https://github.com/Paarrot/Plugin-Directory)
+2. Add your plugin metadata to `plugins/index.json`
+3. Create a plugin JSON file in `plugins/your-plugin.json`
+4. Submit a pull request
+
+## Debugging
+
+### Browser Console
+Check the browser console (F12) for plugin logs:
+- `[PluginLoader]` - Plugin loading messages
+- `[Plugin yourplugin]` - Your plugin's logs
+
+### Plugin Logs Panel
+View per-plugin logs in Settings → Plugins → Logs (coming soon)
+
+### Common Issues
+
+**Plugin won't load:**
+- Check `index.js` exists
+- Verify `module.exports` is correct
+- Look for JavaScript syntax errors
+
+**Command not working:**
+- Ensure command is registered in onLoad
+- Check command name doesn't conflict
+- Verify run function returns a value
+
+**Settings not saving:**
+- Use ctx.settings.set() not localStorage
+- Check setting key matches defined schema
+
+## Testing
+
+Test your plugin locally:
+
+1. Copy plugin folder to `%APPDATA%/paarrot/plugins/` (Windows) or `~/.config/Paarrot/plugins/` (Linux)
+2. Restart Paarrot
+3. Enable plugin in Settings → Plugins
+4. Check console for errors
+5. Test all features
+
+## Resources
+
+- [Matrix.js SDK Docs](https://matrix-org.github.io/matrix-js-sdk/)
+- [Paarrot Plugin Examples](../../../../example-plugin/)
+- [Plugin Directory](https://github.com/Paarrot/Plugin-Directory)
+- [Report Issues](https://github.com/Paarrot/cinny-desktop/issues)
diff --git a/src/app/features/settings/plugins/PluginAPI.ts b/src/app/features/settings/plugins/PluginAPI.ts
new file mode 100644
index 0000000..1d8e2bc
--- /dev/null
+++ b/src/app/features/settings/plugins/PluginAPI.ts
@@ -0,0 +1,698 @@
+import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
+import { ReactNode } from 'react';
+
+/**
+ * Color group with five container variants and an OnContainer text color.
+ */
+export interface ThemeColorGroup {
+ container: string;
+ containerHover: string;
+ containerActive: string;
+ containerLine: string;
+ onContainer: string;
+}
+
+/**
+ * Color group for interactive palette entries (Main + Container sub-groups).
+ */
+export interface ThemePaletteGroup {
+ main: string;
+ mainHover: string;
+ mainActive: string;
+ mainLine: string;
+ onMain: string;
+ container: string;
+ containerHover: string;
+ containerActive: string;
+ containerLine: string;
+ onContainer: string;
+}
+
+/**
+ * Full color token set for a plugin theme.
+ * Property names map directly to the folds design-token contract.
+ */
+export interface PluginThemeColors {
+ background: ThemeColorGroup;
+ surface: ThemeColorGroup;
+ surfaceVariant: ThemeColorGroup;
+ primary: ThemePaletteGroup;
+ secondary: ThemePaletteGroup;
+ success: ThemePaletteGroup;
+ warning: ThemePaletteGroup;
+ critical: ThemePaletteGroup;
+ other: {
+ focusRing: string;
+ shadow: string;
+ overlay: string;
+ };
+}
+
+/**
+ * Theme definition for plugins. Provide human-readable colors and the system
+ * maps them to the correct compiled CSS variables automatically.
+ */
+export interface PluginTheme {
+ id: string;
+ name: string;
+ kind: 'light' | 'dark';
+ colors: PluginThemeColors;
+ /** Optional extra CSS injected alongside the theme (body backgrounds, scrollbars, etc.) */
+ extraCss?: string;
+}
+
+/**
+ * Command argument definition
+ */
+export interface CommandArg {
+ name: string;
+ type?: 'string' | 'number' | 'boolean';
+ required?: boolean;
+ description?: string;
+}
+
+/**
+ * Enhanced command with argument parsing
+ */
+export interface PluginCommand {
+ name: string;
+ description?: string;
+ args?: string[] | CommandArg[];
+ run: (args: Record, ctx: PluginContext) => string | void | Promise;
+}
+
+/**
+ * Message interceptor callback
+ */
+export type MessageInterceptor = (msg: MessageContext) => void | Promise;
+
+/**
+ * Message context for interceptors
+ */
+export interface MessageContext {
+ content: string;
+ roomId: string;
+ eventType: string;
+ formatted?: string;
+ metadata?: Record;
+}
+
+/**
+ * Custom renderer function
+ */
+export type CustomRenderer = (data: any, defaultRenderer?: () => ReactNode) => ReactNode;
+
+/**
+ * Setting definition
+ */
+export interface SettingDefinition {
+ type: 'string' | 'number' | 'boolean' | 'select' | 'color';
+ label?: string;
+ description?: string;
+ default?: any;
+ options?: Array<{ value: any; label: string }>;
+}
+
+/**
+ * Plugin settings schema
+ */
+export type SettingsSchema = Record;
+
+/**
+ * Notification options
+ */
+export interface NotificationOptions {
+ title: string;
+ body: string;
+ type?: 'info' | 'success' | 'error' | 'warning';
+ duration?: number;
+ actions?: Array<{ label: string; action: () => void }>;
+}
+
+/**
+ * The context provided to plugins when they are initialized.
+ * This gives plugins access to core app functionality.
+ */
+export interface PluginContext {
+ /** Plugin ID */
+ pluginId: string;
+
+ /** The Matrix client instance */
+ matrixClient: MatrixClient;
+
+ /** React for creating UI components */
+ React: typeof import('react');
+
+ /** Commands API */
+ commands: {
+ register: (command: PluginCommand) => void;
+ unregister: (name: string) => void;
+ execute: (name: string, args: Record) => Promise;
+ };
+
+ /** Messages API */
+ messages: {
+ onBeforeSend: (interceptor: MessageInterceptor) => void;
+ onReceive: (interceptor: MessageInterceptor) => void;
+ };
+
+ /** UI API */
+ ui: {
+ registerRenderer: (type: string, renderer: CustomRenderer) => void;
+ unregisterRenderer: (type: string) => void;
+ };
+
+ /** Settings API */
+ settings: {
+ define: (schema: SettingsSchema) => void;
+ get: (key: string) => T | undefined;
+ set: (key: string, value: any) => void;
+ };
+
+ /** Themes API */
+ themes: {
+ register: (theme: PluginTheme) => void;
+ unregister: (themeId: string) => void;
+ };
+
+ /** Matrix raw events API */
+ matrix: {
+ on: (eventType: string, handler: (event: MatrixEvent) => void) => void;
+ off: (eventType: string, handler: (event: MatrixEvent) => void) => void;
+ };
+
+ /** Background tasks API */
+ timers: {
+ setInterval: (callback: () => void, ms: number) => number;
+ setTimeout: (callback: () => void, ms: number) => number;
+ clearInterval: (id: number) => void;
+ clearTimeout: (id: number) => void;
+ };
+
+ /** Notifications API */
+ notify: (options: NotificationOptions | string) => void;
+
+ /** Logging API */
+ log: (...args: any[]) => void;
+ warn: (...args: any[]) => void;
+ error: (...args: any[]) => void;
+
+ /** Require other plugins */
+ require: (pluginId: string) => any;
+}
+
+/**
+ * A custom settings section registered by a plugin
+ */
+export interface PluginSettingsSection {
+ id: string;
+ name: string;
+ icon?: string;
+ component: ReactNode;
+}
+
+/**
+ * UI locations where plugins can inject content
+ */
+export type UILocation =
+ | 'room-header'
+ | 'message-actions'
+ | 'user-menu'
+ | 'room-menu'
+ | 'composer-actions';
+
+/**
+ * The main plugin interface that all plugins must implement
+ */
+export interface Plugin {
+ /** Plugin metadata */
+ name?: string;
+ version?: string;
+ dependencies?: Record;
+
+ /** Called when the plugin is loaded */
+ onLoad: (context: PluginContext) => void | Promise;
+
+ /** Called when the plugin is unloaded/disabled */
+ onUnload?: () => void | Promise;
+
+ /** Exposed API for other plugins to require */
+ exports?: any;
+}
+
+/**
+ * Plugin log entry
+ */
+export interface PluginLogEntry {
+ pluginId: string;
+ level: 'log' | 'warn' | 'error';
+ timestamp: number;
+ args: any[];
+}
+
+/**
+ * Maps a PluginThemeColors object to a CSS block overriding the folds
+ * vanilla-extract compiled custom properties. The variable names (--oq6d07*)
+ * are stable for the installed version of folds and match the order of the
+ * color contract: Background → Surface → SurfaceVariant → Primary →
+ * Secondary → Success → Warning → Critical → Other.
+ */
+function generateThemeCSS(className: string, theme: PluginTheme): string {
+ const { background: bg, surface: sf, surfaceVariant: sv, primary: pr, secondary: sc, success: su, warning: wa, critical: cr, other: ot } = theme.colors;
+ const vars = `
+ .${className} {
+ --oq6d070: ${bg.container};
+ --oq6d071: ${bg.containerHover};
+ --oq6d072: ${bg.containerActive};
+ --oq6d073: ${bg.containerLine};
+ --oq6d074: ${bg.onContainer};
+ --oq6d075: ${sf.container};
+ --oq6d076: ${sf.containerHover};
+ --oq6d077: ${sf.containerActive};
+ --oq6d078: ${sf.containerLine};
+ --oq6d079: ${sf.onContainer};
+ --oq6d07a: ${sv.container};
+ --oq6d07b: ${sv.containerHover};
+ --oq6d07c: ${sv.containerActive};
+ --oq6d07d: ${sv.containerLine};
+ --oq6d07e: ${sv.onContainer};
+ --oq6d07f: ${pr.main};
+ --oq6d07g: ${pr.mainHover};
+ --oq6d07h: ${pr.mainActive};
+ --oq6d07i: ${pr.mainLine};
+ --oq6d07j: ${pr.onMain};
+ --oq6d07k: ${pr.container};
+ --oq6d07l: ${pr.containerHover};
+ --oq6d07m: ${pr.containerActive};
+ --oq6d07n: ${pr.containerLine};
+ --oq6d07o: ${pr.onContainer};
+ --oq6d07p: ${sc.main};
+ --oq6d07q: ${sc.mainHover};
+ --oq6d07r: ${sc.mainActive};
+ --oq6d07s: ${sc.mainLine};
+ --oq6d07t: ${sc.onMain};
+ --oq6d07u: ${sc.container};
+ --oq6d07v: ${sc.containerHover};
+ --oq6d07w: ${sc.containerActive};
+ --oq6d07x: ${sc.containerLine};
+ --oq6d07y: ${sc.onContainer};
+ --oq6d07z: ${su.main};
+ --oq6d0710: ${su.mainHover};
+ --oq6d0711: ${su.mainActive};
+ --oq6d0712: ${su.mainLine};
+ --oq6d0713: ${su.onMain};
+ --oq6d0714: ${su.container};
+ --oq6d0715: ${su.containerHover};
+ --oq6d0716: ${su.containerActive};
+ --oq6d0717: ${su.containerLine};
+ --oq6d0718: ${su.onContainer};
+ --oq6d0719: ${wa.main};
+ --oq6d071a: ${wa.mainHover};
+ --oq6d071b: ${wa.mainActive};
+ --oq6d071c: ${wa.mainLine};
+ --oq6d071d: ${wa.onMain};
+ --oq6d071e: ${wa.container};
+ --oq6d071f: ${wa.containerHover};
+ --oq6d071g: ${wa.containerActive};
+ --oq6d071h: ${wa.containerLine};
+ --oq6d071i: ${wa.onContainer};
+ --oq6d071j: ${cr.main};
+ --oq6d071k: ${cr.mainHover};
+ --oq6d071l: ${cr.mainActive};
+ --oq6d071m: ${cr.mainLine};
+ --oq6d071n: ${cr.onMain};
+ --oq6d071o: ${cr.container};
+ --oq6d071p: ${cr.containerHover};
+ --oq6d071q: ${cr.containerActive};
+ --oq6d071r: ${cr.containerLine};
+ --oq6d071s: ${cr.onContainer};
+ --oq6d071t: ${ot.focusRing};
+ --oq6d071u: ${ot.shadow};
+ --oq6d071v: ${ot.overlay};
+ }`;
+ return theme.extraCss ? `${vars}\n${theme.extraCss}` : vars;
+}
+
+/**
+ * Plugin registry to track loaded plugins and their features
+ */
+export class PluginRegistry {
+ private plugins = new Map();
+ private commands = new Map();
+ private beforeSendInterceptors: Array<{ pluginId: string; interceptor: MessageInterceptor }> = [];
+ private receiveInterceptors: Array<{ pluginId: string; interceptor: MessageInterceptor }> = [];
+ private renderers = new Map();
+ private settingsSchemas = new Map();
+ private pluginSettings = new Map>();
+ private matrixHandlers = new Map>();
+ private timers = new Map();
+ private themes = new Map();
+ private logs: PluginLogEntry[] = [];
+ private maxLogs = 1000;
+
+ registerPlugin(id: string, plugin: Plugin, context: PluginContext): void {
+ if (this.plugins.has(id)) {
+ console.warn(`[PluginRegistry] Plugin ${id} is already registered`);
+ return;
+ }
+ this.plugins.set(id, { plugin, context });
+ this.timers.set(id, []);
+ }
+
+ async unregisterPlugin(id: string): Promise {
+ const entry = this.plugins.get(id);
+ if (!entry) return;
+
+ // Call onUnload
+ if (entry.plugin.onUnload) {
+ try {
+ await entry.plugin.onUnload();
+ } catch (err) {
+ console.error(`[PluginRegistry] Error unloading plugin ${id}:`, err);
+ }
+ }
+
+ // Clean up commands
+ for (const [cmdName, cmd] of this.commands.entries()) {
+ if (cmd.pluginId === id) {
+ this.commands.delete(cmdName);
+ }
+ }
+
+ // Clean up interceptors
+ this.beforeSendInterceptors = this.beforeSendInterceptors.filter(i => i.pluginId !== id);
+ this.receiveInterceptors = this.receiveInterceptors.filter(i => i.pluginId !== id);
+
+ // Clean up renderers
+ for (const [type, renderer] of this.renderers.entries()) {
+ if (renderer.pluginId === id) {
+ this.renderers.delete(type);
+ }
+ }
+
+ // Clean up themes
+ for (const [themeId, theme] of this.themes.entries()) {
+ if (theme.pluginId === id) {
+ this.themes.delete(themeId);
+ }
+ }
+
+ // Clean up timers
+ const timerIds = this.timers.get(id) || [];
+ timerIds.forEach(timerId => clearInterval(timerId));
+ this.timers.delete(id);
+
+ // Clean up Matrix handlers
+ for (const [eventType, handlers] of this.matrixHandlers.entries()) {
+ const filtered = handlers.filter(h => h.pluginId !== id);
+ if (filtered.length === 0) {
+ this.matrixHandlers.delete(eventType);
+ } else {
+ this.matrixHandlers.set(eventType, filtered);
+ }
+ }
+
+ this.plugins.delete(id);
+ }
+
+ // Command management
+ registerCommand(pluginId: string, command: PluginCommand): void {
+ if (this.commands.has(command.name)) {
+ console.warn(`[PluginRegistry] Command /${command.name} already exists`);
+ return;
+ }
+ this.commands.set(command.name, { pluginId, command });
+ }
+
+ unregisterCommand(name: string): void {
+ this.commands.delete(name);
+ }
+
+ async executeCommand(name: string, rawArgs: string): Promise {
+ const cmd = this.commands.get(name);
+ if (!cmd) {
+ throw new Error(`Command /${name} not found`);
+ }
+
+ const args = this.parseCommandArgs(cmd.command, rawArgs);
+ const entry = this.plugins.get(cmd.pluginId);
+ if (!entry) {
+ throw new Error(`Plugin ${cmd.pluginId} not loaded`);
+ }
+
+ return await cmd.command.run(args, entry.context);
+ }
+
+ private parseCommandArgs(command: PluginCommand, rawArgs: string): Record {
+ if (!command.args || command.args.length === 0) {
+ return {};
+ }
+
+ const parts = rawArgs.trim().split(/\s+/);
+ const result: Record = {};
+
+ command.args.forEach((arg, index) => {
+ const argName = typeof arg === 'string' ? arg : arg.name;
+ const value = parts[index] || undefined;
+ result[argName] = value;
+ });
+
+ return result;
+ }
+
+ getCommands(): Array<{ name: string; pluginId: string; command: PluginCommand }> {
+ return Array.from(this.commands.entries()).map(([name, data]) => ({
+ name,
+ ...data,
+ }));
+ }
+
+ // Message interceptors
+ addBeforeSendInterceptor(pluginId: string, interceptor: MessageInterceptor): void {
+ this.beforeSendInterceptors.push({ pluginId, interceptor });
+ }
+
+ addReceiveInterceptor(pluginId: string, interceptor: MessageInterceptor): void {
+ this.receiveInterceptors.push({ pluginId, interceptor });
+ }
+
+ async processBeforeSend(msg: MessageContext): Promise {
+ let processedMsg = { ...msg };
+ for (const { interceptor } of this.beforeSendInterceptors) {
+ try {
+ await interceptor(processedMsg);
+ } catch (err) {
+ console.error('[PluginRegistry] Error in beforeSend interceptor:', err);
+ }
+ }
+ return processedMsg;
+ }
+
+ async processReceive(msg: MessageContext): Promise {
+ let processedMsg = { ...msg };
+ for (const { interceptor } of this.receiveInterceptors) {
+ try {
+ await interceptor(processedMsg);
+ } catch (err) {
+ console.error('[PluginRegistry] Error in receive interceptor:', err);
+ }
+ }
+ return processedMsg;
+ }
+
+ // Renderers
+ registerRenderer(pluginId: string, type: string, renderer: CustomRenderer): void {
+ this.renderers.set(type, { pluginId, renderer });
+ }
+
+ unregisterRenderer(type: string): void {
+ this.renderers.delete(type);
+ }
+
+ getRenderer(type: string): CustomRenderer | undefined {
+ return this.renderers.get(type)?.renderer;
+ }
+
+ // Settings
+ defineSettings(pluginId: string, schema: SettingsSchema): void {
+ this.settingsSchemas.set(pluginId, schema);
+
+ // Load from localStorage
+ const stored = localStorage.getItem(`plugin:${pluginId}:settings`);
+ if (stored) {
+ try {
+ this.pluginSettings.set(pluginId, JSON.parse(stored));
+ } catch (err) {
+ console.error(`[PluginRegistry] Failed to load settings for ${pluginId}:`, err);
+ }
+ } else {
+ // Initialize with defaults
+ const defaults: Record = {};
+ Object.keys(schema).forEach(key => {
+ const def = schema[key];
+ if (def.default !== undefined) {
+ defaults[key] = def.default;
+ }
+ });
+ this.pluginSettings.set(pluginId, defaults);
+ }
+ }
+
+ getPluginSetting(pluginId: string, key: string): T | undefined {
+ return this.pluginSettings.get(pluginId)?.[key];
+ }
+
+ setPluginSetting(pluginId: string, key: string, value: any): void {
+ const settings = this.pluginSettings.get(pluginId) || {};
+ settings[key] = value;
+ this.pluginSettings.set(pluginId, settings);
+
+ // Save to localStorage
+ localStorage.setItem(`plugin:${pluginId}:settings`, JSON.stringify(settings));
+ }
+
+ getPluginSettingsSchema(pluginId: string): SettingsSchema | undefined {
+ return this.settingsSchemas.get(pluginId);
+ }
+
+ // Theme management
+ registerTheme(pluginId: string, theme: PluginTheme): void {
+ const fullThemeId = `plugin-${pluginId}-${theme.id}`;
+ if (this.themes.has(fullThemeId)) {
+ console.warn(`[PluginRegistry] Theme ${fullThemeId} already exists`);
+ return;
+ }
+
+ const styleId = `plugin-theme-${fullThemeId}`;
+ let styleEl = document.getElementById(styleId) as HTMLStyleElement;
+ if (!styleEl) {
+ styleEl = document.createElement('style');
+ styleEl.id = styleId;
+ document.head.appendChild(styleEl);
+ }
+ styleEl.textContent = generateThemeCSS(fullThemeId, theme);
+
+ this.themes.set(fullThemeId, { pluginId, theme: { ...theme, id: fullThemeId }, styleId });
+ console.log(`[PluginRegistry] Theme registered: ${fullThemeId}`);
+ }
+
+ unregisterTheme(pluginId: string, themeId: string): void {
+ const fullThemeId = `plugin-${pluginId}-${themeId}`;
+ const themeData = this.themes.get(fullThemeId);
+ if (themeData) {
+ // Remove injected CSS
+ const styleEl = document.getElementById(themeData.styleId);
+ if (styleEl) {
+ styleEl.remove();
+ }
+ this.themes.delete(fullThemeId);
+ }
+ }
+
+ getPluginThemes(): Array<{ id: string; name: string; kind: 'light' | 'dark'; className: string }> {
+ return Array.from(this.themes.values()).map(({ theme }) => ({
+ id: theme.id,
+ name: theme.name,
+ kind: theme.kind,
+ className: theme.id // Use the full theme ID as the class name
+ }));
+ }
+
+ // Matrix event handlers
+ addMatrixHandler(pluginId: string, eventType: string, handler: Function): void {
+ if (!this.matrixHandlers.has(eventType)) {
+ this.matrixHandlers.set(eventType, []);
+ }
+ this.matrixHandlers.get(eventType)!.push({ pluginId, handler });
+ }
+
+ removeMatrixHandler(pluginId: string, eventType: string, handler: Function): void {
+ const handlers = this.matrixHandlers.get(eventType);
+ if (handlers) {
+ const filtered = handlers.filter(h => h.pluginId !== pluginId || h.handler !== handler);
+ this.matrixHandlers.set(eventType, filtered);
+ }
+ }
+
+ getMatrixHandlers(eventType: string): Array<{ pluginId: string; handler: Function }> {
+ return this.matrixHandlers.get(eventType) || [];
+ }
+
+ // Timers
+ addTimer(pluginId: string, timerId: number): void {
+ const timers = this.timers.get(pluginId) || [];
+ timers.push(timerId);
+ this.timers.set(pluginId, timers);
+ }
+
+ removeTimer(pluginId: string, timerId: number): void {
+ const timers = this.timers.get(pluginId) || [];
+ const filtered = timers.filter(id => id !== timerId);
+ this.timers.set(pluginId, filtered);
+ }
+
+ // Logging
+ addLog(pluginId: string, level: 'log' | 'warn' | 'error', args: any[]): void {
+ this.logs.push({
+ pluginId,
+ level,
+ timestamp: Date.now(),
+ args,
+ });
+
+ // Keep only last N logs
+ if (this.logs.length > this.maxLogs) {
+ this.logs = this.logs.slice(-this.maxLogs);
+ }
+ }
+
+ getLogs(pluginId?: string): PluginLogEntry[] {
+ if (pluginId) {
+ return this.logs.filter(log => log.pluginId === pluginId);
+ }
+ return this.logs;
+ }
+
+ clearLogs(pluginId?: string): void {
+ if (pluginId) {
+ this.logs = this.logs.filter(log => log.pluginId !== pluginId);
+ } else {
+ this.logs = [];
+ }
+ }
+
+ // Plugin exports (for require())
+ getPluginExports(pluginId: string): any {
+ const entry = this.plugins.get(pluginId);
+ return entry?.plugin.exports;
+ }
+
+ // Hot reload
+ async reloadPlugin(pluginId: string, newPlugin: Plugin, newContext: PluginContext): Promise {
+ await this.unregisterPlugin(pluginId);
+ this.registerPlugin(pluginId, newPlugin, newContext);
+ await newPlugin.onLoad(newContext);
+ }
+
+ clear(): void {
+ // Unload all plugins
+ for (const pluginId of this.plugins.keys()) {
+ this.unregisterPlugin(pluginId).catch(err => {
+ console.error(`[PluginRegistry] Error clearing plugin ${pluginId}:`, err);
+ });
+ }
+
+ this.plugins.clear();
+ this.commands.clear();
+ this.beforeSendInterceptors = [];
+ this.receiveInterceptors = [];
+ this.renderers.clear();
+ this.settingsSchemas.clear();
+ this.matrixHandlers.clear();
+ this.timers.clear();
+ this.logs = [];
+ }
+}
+
+// Global plugin registry instance
+export const pluginRegistry = new PluginRegistry();
diff --git a/src/app/features/settings/plugins/PluginLoader.tsx b/src/app/features/settings/plugins/PluginLoader.tsx
new file mode 100644
index 0000000..b0ebb61
--- /dev/null
+++ b/src/app/features/settings/plugins/PluginLoader.tsx
@@ -0,0 +1,331 @@
+import React, { useEffect, useState } from 'react';
+import { MatrixClient } from 'matrix-js-sdk';
+import { Plugin, PluginContext, pluginRegistry } from './PluginAPI';
+import { InstalledPlugin } from './types';
+import { sendNotification } from '../../../utils/tauri';
+
+interface PluginLoaderProps {
+ matrixClient: MatrixClient;
+ children: React.ReactNode;
+}
+
+/**
+ * Component that loads and initializes enabled plugins.
+ * Should be placed high in the component tree after MatrixClient is available.
+ */
+export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
+ const [loading, setLoading] = useState(true);
+ const [loadedPlugins, setLoadedPlugins] = useState([]);
+
+ useEffect(() => {
+ let mounted = true;
+
+ const loadPlugins = async () => {
+ try {
+ console.log('[PluginLoader] Loading plugins...');
+
+ // Get list of installed plugins from Electron
+ if (!window.electron?.plugins) {
+ console.log('[PluginLoader] Running in web mode, plugins not supported');
+ setLoading(false);
+ return;
+ }
+
+ const response = await window.electron.plugins.list();
+ if (!response.success || !response.data) {
+ console.error('[PluginLoader] Failed to list plugins:', response.error);
+ setLoading(false);
+ return;
+ }
+
+ const installedPlugins = response.data;
+ console.log('[PluginLoader] Found installed plugins:', installedPlugins.length);
+
+ // Check which plugins are enabled in localStorage
+ const enabledPluginsStr = localStorage.getItem('enabledPlugins');
+ const enabledPlugins = enabledPluginsStr ? JSON.parse(enabledPluginsStr) : {};
+
+ const pluginsToLoad = installedPlugins.filter(
+ (plugin: any) => enabledPlugins[plugin.id] !== false
+ );
+
+ console.log('[PluginLoader] Plugins to load:', pluginsToLoad.length);
+
+ const loaded: string[] = [];
+ const React = await import('react');
+
+ // Load each enabled plugin
+ for (const installedPlugin of pluginsToLoad) {
+ try {
+ console.log(`[PluginLoader] Loading plugin: ${installedPlugin.id}`);
+
+ // Dynamically import the plugin
+ const pluginModule = await loadPluginModule(installedPlugin.id);
+
+ // Handle both CommonJS (module.exports = {...}) and ES6 (export default {...})
+ const plugin: Plugin = pluginModule?.default || pluginModule;
+
+ if (!plugin || typeof plugin !== 'object') {
+ console.error(
+ `[PluginLoader] Plugin ${installedPlugin.id} does not export a valid plugin object`
+ );
+ continue;
+ }
+
+ // Validate plugin structure
+ if (typeof plugin.onLoad !== 'function') {
+ console.error(
+ `[PluginLoader] Plugin ${installedPlugin.id} does not have an onLoad function`
+ );
+ continue;
+ }
+
+ // Create enhanced plugin context
+ const context = createPluginContext(installedPlugin.id, matrixClient, React);
+
+ // Register plugin
+ pluginRegistry.registerPlugin(installedPlugin.id, plugin, context);
+
+ // Call plugin's onLoad
+ await plugin.onLoad(context);
+
+ loaded.push(installedPlugin.id);
+ console.log(`[PluginLoader] Successfully loaded plugin: ${installedPlugin.id}`);
+ } catch (error) {
+ console.error(`[PluginLoader] Failed to load plugin ${installedPlugin.id}:`, error);
+ pluginRegistry.addLog(installedPlugin.id, 'error', ['Failed to load:', error]);
+ }
+ }
+
+ if (mounted) {
+ setLoadedPlugins(loaded);
+ setLoading(false);
+ }
+ } catch (error) {
+ console.error('[PluginLoader] Error loading plugins:', error);
+ if (mounted) {
+ setLoading(false);
+ }
+ }
+ };
+
+ loadPlugins();
+
+ return () => {
+ mounted = false;
+ // Cleanup: unload all plugins
+ console.log('[PluginLoader] Cleaning up plugins');
+ pluginRegistry.clear();
+ };
+ }, [matrixClient]);
+
+ // Show children immediately, plugins load in background
+ return <>{children}>;
+}
+
+/**
+ * Create enhanced plugin context with all APIs
+ */
+function createPluginContext(
+ pluginId: string,
+ matrixClient: MatrixClient,
+ React: typeof import('react')
+): PluginContext {
+ return {
+ pluginId,
+ matrixClient,
+ React,
+
+ // Commands API
+ commands: {
+ register: (command) => {
+ pluginRegistry.registerCommand(pluginId, command);
+ pluginRegistry.addLog(pluginId, 'log', [`Registered command: /${command.name}`]);
+ },
+ unregister: (name) => {
+ pluginRegistry.unregisterCommand(name);
+ },
+ execute: async (name, args) => {
+ return pluginRegistry.executeCommand(name, JSON.stringify(args));
+ },
+ },
+
+ // Messages API
+ messages: {
+ onBeforeSend: (interceptor) => {
+ pluginRegistry.addBeforeSendInterceptor(pluginId, interceptor);
+ pluginRegistry.addLog(pluginId, 'log', ['Registered beforeSend interceptor']);
+ },
+ onReceive: (interceptor) => {
+ pluginRegistry.addReceiveInterceptor(pluginId, interceptor);
+ pluginRegistry.addLog(pluginId, 'log', ['Registered receive interceptor']);
+ },
+ },
+
+ // UI API
+ ui: {
+ registerRenderer: (type, renderer) => {
+ pluginRegistry.registerRenderer(pluginId, type, renderer);
+ pluginRegistry.addLog(pluginId, 'log', [`Registered renderer for: ${type}`]);
+ },
+ unregisterRenderer: (type) => {
+ pluginRegistry.unregisterRenderer(type);
+ },
+ },
+
+ // Settings API
+ settings: {
+ define: (schema) => {
+ pluginRegistry.defineSettings(pluginId, schema);
+ pluginRegistry.addLog(pluginId, 'log', ['Defined settings schema']);
+ },
+ get: (key: string): T | undefined => {
+ return pluginRegistry.getPluginSetting(pluginId, key);
+ },
+ set: (key, value) => {
+ pluginRegistry.setPluginSetting(pluginId, key, value);
+ },
+ },
+
+ // Themes API
+ themes: {
+ register: (theme) => {
+ pluginRegistry.registerTheme(pluginId, theme);
+ pluginRegistry.addLog(pluginId, 'log', [`Registered theme: ${theme.name}`]);
+ },
+ unregister: (themeId) => {
+ pluginRegistry.unregisterTheme(pluginId, themeId);
+ },
+ },
+
+ // Matrix events API
+ matrix: {
+ on: (eventType, handler) => {
+ pluginRegistry.addMatrixHandler(pluginId, eventType, handler);
+ matrixClient.on(eventType as any, handler as any);
+ pluginRegistry.addLog(pluginId, 'log', [`Listening to Matrix event: ${eventType}`]);
+ },
+ off: (eventType, handler) => {
+ pluginRegistry.removeMatrixHandler(pluginId, eventType, handler);
+ matrixClient.off(eventType as any, handler as any);
+ },
+ },
+
+ // Timers API
+ timers: {
+ setInterval: (callback, ms) => {
+ const id = window.setInterval(() => {
+ try {
+ callback();
+ } catch (err) {
+ pluginRegistry.addLog(pluginId, 'error', ['Interval error:', err]);
+ }
+ }, ms);
+ pluginRegistry.addTimer(pluginId, id);
+ return id;
+ },
+ setTimeout: (callback, ms) => {
+ const id = window.setTimeout(() => {
+ try {
+ callback();
+ } catch (err) {
+ pluginRegistry.addLog(pluginId, 'error', ['Timeout error:', err]);
+ }
+ pluginRegistry.removeTimer(pluginId, id);
+ }, ms);
+ pluginRegistry.addTimer(pluginId, id);
+ return id;
+ },
+ clearInterval: (id) => {
+ window.clearInterval(id);
+ pluginRegistry.removeTimer(pluginId, id);
+ },
+ clearTimeout: (id) => {
+ window.clearTimeout(id);
+ pluginRegistry.removeTimer(pluginId, id);
+ },
+ },
+
+ // Notifications API
+ notify: async (options) => {
+ const opts = typeof options === 'string' ? { title: 'Plugin', body: options } : options;
+ console.log(`[Plugin ${pluginId}] Notification:`, opts.title, opts.body);
+ pluginRegistry.addLog(pluginId, 'log', ['Notification:', opts]);
+
+ // Send actual notification
+ try {
+ await sendNotification({
+ title: opts.title || 'Plugin',
+ body: opts.body || '',
+ path: undefined, // Could be enhanced to support navigation
+ });
+ } catch (err) {
+ console.error(`[Plugin ${pluginId}] Notification failed:`, err);
+ }
+ },
+
+ // Logging API
+ log: (...args) => {
+ console.log(`[Plugin ${pluginId}]`, ...args);
+ pluginRegistry.addLog(pluginId, 'log', args);
+ },
+ warn: (...args) => {
+ console.warn(`[Plugin ${pluginId}]`, ...args);
+ pluginRegistry.addLog(pluginId, 'warn', args);
+ },
+ error: (...args) => {
+ console.error(`[Plugin ${pluginId}]`, ...args);
+ pluginRegistry.addLog(pluginId, 'error', args);
+ },
+
+ // Require API
+ require: (requiredPluginId) => {
+ const exports = pluginRegistry.getPluginExports(requiredPluginId);
+ if (!exports) {
+ throw new Error(`Plugin ${requiredPluginId} not found or has no exports`);
+ }
+ pluginRegistry.addLog(pluginId, 'log', [`Required plugin: ${requiredPluginId}`]);
+ return exports;
+ },
+ };
+}
+
+/**
+ * Load a plugin module from the filesystem.
+ * Handles both Electron and web environments.
+ */
+async function loadPluginModule(pluginId: string): Promise {
+ // Check if we're in Electron
+ if (window.electron?.plugins) {
+ // In Electron, we need to read the plugin file and evaluate it
+ // This is necessary because dynamic imports don't work with file:// URLs in all cases
+ try {
+ // Request Electron to load the plugin code
+ const response = await window.electron.plugins.readPluginCode(pluginId);
+
+ if (!response.success || !response.data) {
+ throw new Error(response.error || 'Failed to read plugin code');
+ }
+
+ const pluginCode = response.data;
+
+ // Create a sandboxed environment for the plugin
+ const pluginExports: any = {};
+ const module = { exports: pluginExports };
+
+ // Evaluate the plugin code
+ // eslint-disable-next-line no-new-func
+ const pluginFunction = new Function('module', 'exports', pluginCode);
+
+ pluginFunction(module, pluginExports);
+
+ return module.exports;
+ } catch (error) {
+ console.error(`[PluginLoader] Error evaluating plugin ${pluginId}:`, error);
+ throw error;
+ }
+ } else {
+ // In web mode, plugins are not supported
+ throw new Error('Plugins are only supported in the desktop app');
+ }
+}
diff --git a/src/app/features/settings/plugins/Plugins.tsx b/src/app/features/settings/plugins/Plugins.tsx
new file mode 100644
index 0000000..76f2524
--- /dev/null
+++ b/src/app/features/settings/plugins/Plugins.tsx
@@ -0,0 +1,795 @@
+import React, { useState, useEffect, useCallback, useMemo } from 'react';
+import {
+ Avatar,
+ Badge,
+ Box,
+ Button,
+ color,
+ config,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Input,
+ Menu,
+ MenuItem,
+ PopOut,
+ RectCords,
+ Scroll,
+ Spinner,
+ Switch,
+ Text,
+ toRem,
+} from 'folds';
+import { HexColorPicker } from 'react-colorful';
+import FocusTrap from 'focus-trap-react';
+import { Page, PageContent, PageHeader } from '../../../components/page';
+import { PluginTab, PluginMetadata, PluginIndex, InstalledPlugin } from './types';
+import { SequenceCard } from '../../../components/sequence-card';
+import { SettingTile } from '../../../components/setting-tile';
+import { SequenceCardStyle } from '../styles.css';
+import { pluginRegistry, SettingsSchema, SettingDefinition } from './PluginAPI';
+import { stopPropagation } from '../../../utils/keyboard';
+
+const PLUGIN_INDEX_URL =
+ 'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json';
+const PLUGIN_BASE_URL =
+ 'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/';
+
+// Check if running in Electron
+const isElectron = typeof window !== 'undefined' && (window as any).electron?.plugins;
+
+/**
+ * Renders settings UI for a plugin based on its settings schema
+ */
+function PluginSettingsRenderer({ pluginId }: { pluginId: string }) {
+ const schema = pluginRegistry.getPluginSettingsSchema(pluginId);
+ const [, forceUpdate] = useState({});
+
+ if (!schema || Object.keys(schema).length === 0) {
+ return (
+
+ No settings available for this plugin.
+
+ );
+ }
+
+ const handleChange = (key: string, value: any) => {
+ pluginRegistry.setPluginSetting(pluginId, key, value);
+ forceUpdate({});
+ };
+
+ return (
+
+ {Object.entries(schema).map(([key, def]) => (
+ handleChange(key, value)}
+ />
+ ))}
+
+ );
+}
+
+/**
+ * Renders a single setting based on its type
+ */
+function SettingRenderer({
+ settingKey,
+ definition,
+ value,
+ onChange,
+}: {
+ settingKey: string;
+ definition: SettingDefinition;
+ value: any;
+ onChange: (value: any) => void;
+}) {
+ const [colorMenuCords, setColorMenuCords] = useState();
+ const [selectMenuCords, setSelectMenuCords] = useState();
+
+ const label = definition.label || settingKey;
+ const description = definition.description;
+
+ // Boolean - Switch
+ if (definition.type === 'boolean') {
+ return (
+ }
+ />
+ );
+ }
+
+ // String - Input
+ if (definition.type === 'string') {
+ return (
+
+ onChange(e.target.value)}
+ placeholder={definition.default}
+ />
+
+ );
+ }
+
+ // Number - Input
+ if (definition.type === 'number') {
+ return (
+
+ onChange(parseFloat(e.target.value) || 0)}
+ />
+
+ );
+ }
+
+ // Select - Dropdown
+ if (definition.type === 'select' && definition.options) {
+ const currentValue = value ?? definition.default ?? definition.options[0]?.value;
+ const selectedOption = definition.options.find(opt => opt.value === currentValue);
+
+ return (
+
+ }
+ onClick={(evt) => setSelectMenuCords(evt.currentTarget.getBoundingClientRect())}
+ >
+ {selectedOption?.label || currentValue}
+
+ setSelectMenuCords(undefined),
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+
+ }
+ />
+
+ );
+ }
+
+ // Color - Color Picker
+ if (definition.type === 'color') {
+ const currentColor = value ?? definition.default ?? '#000000';
+
+ return (
+
+
+ }
+ onClick={(evt) => setColorMenuCords(evt.currentTarget.getBoundingClientRect())}
+ >
+ {currentColor}
+
+ setColorMenuCords(undefined),
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+
+
+
+ }
+ />
+
+ );
+ }
+
+ return null;
+}
+
+type PluginsProps = {
+ requestClose: () => void;
+};
+
+export function Plugins({ requestClose }: PluginsProps) {
+ const [activeTab, setActiveTab] = useState(PluginTab.Marketplace);
+ const [marketplacePlugins, setMarketplacePlugins] = useState([]);
+ const [installedPlugins, setInstalledPlugins] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [expandedSettings, setExpandedSettings] = useState(null);
+
+ const fetchMarketplacePlugins = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const indexResponse = await fetch(PLUGIN_INDEX_URL);
+ if (!indexResponse.ok) {
+ throw new Error('Failed to fetch plugin index');
+ }
+ const index: PluginIndex = await indexResponse.json();
+
+ const pluginPromises = index.plugins.map(async (pluginId) => {
+ const pluginResponse = await fetch(`${PLUGIN_BASE_URL}${pluginId}.json`);
+ if (!pluginResponse.ok) {
+ console.error(`Failed to fetch plugin ${pluginId}`);
+ return null;
+ }
+ return pluginResponse.json();
+ });
+
+ const plugins = await Promise.all(pluginPromises);
+ setMarketplacePlugins(plugins.filter((p): p is PluginMetadata => p !== null));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to fetch plugins');
+ console.error('Error fetching marketplace plugins:', err);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (activeTab === PluginTab.Marketplace) {
+ fetchMarketplacePlugins();
+ }
+ }, [activeTab, fetchMarketplacePlugins]);
+
+ useEffect(() => {
+ const loadInstalledPlugins = async () => {
+ if (isElectron) {
+ // Load from filesystem via Electron
+ try {
+ const result = await (window as any).electron.plugins.list();
+ if (result.success && result.data) {
+ // Merge filesystem data with metadata from marketplace
+ const fsPlugins = result.data.map((fsPlugin: any) => ({
+ ...fsPlugin,
+ enabled: true, // Default to enabled
+ }));
+ setInstalledPlugins(fsPlugins);
+ }
+ } catch (err) {
+ console.error('Failed to list plugins from filesystem:', err);
+ }
+ } else {
+ // Fallback to localStorage for web
+ const stored = localStorage.getItem('installedPlugins');
+ if (stored) {
+ try {
+ setInstalledPlugins(JSON.parse(stored));
+ } catch (err) {
+ console.error('Failed to parse installed plugins:', err);
+ }
+ }
+ }
+ };
+
+ loadInstalledPlugins();
+ }, []);
+
+ const handleInstall = useCallback(
+ async (plugin: PluginMetadata) => {
+ if (isElectron) {
+ // Download and install to filesystem via Electron
+ try {
+ setLoading(true);
+ const result = await (window as any).electron.plugins.download(
+ plugin.id,
+ plugin.downloadUrl,
+ plugin.name
+ );
+
+ if (result.success) {
+ const newPlugin: InstalledPlugin = {
+ ...plugin,
+ installedDate: new Date().toISOString(),
+ enabled: true,
+ };
+ setInstalledPlugins([...installedPlugins, newPlugin]);
+ } else {
+ setError(result.error || 'Failed to install plugin');
+ }
+ } catch (err) {
+ console.error('Failed to install plugin:', err);
+ setError(err instanceof Error ? err.message : 'Failed to install plugin');
+ } finally {
+ setLoading(false);
+ }
+ } else {
+ // Fallback to localStorage for web
+ const newPlugin: InstalledPlugin = {
+ ...plugin,
+ installedDate: new Date().toISOString(),
+ enabled: true,
+ };
+ const updated = [...installedPlugins, newPlugin];
+ setInstalledPlugins(updated);
+ localStorage.setItem('installedPlugins', JSON.stringify(updated));
+ }
+ },
+ [installedPlugins]
+ );
+
+ const handleUninstall = useCallback(
+ async (pluginId: string) => {
+ // First, unregister the plugin to trigger onUnload
+ try {
+ await pluginRegistry.unregisterPlugin(pluginId);
+ } catch (err) {
+ console.error(`Failed to unregister plugin ${pluginId}:`, err);
+ }
+
+ if (isElectron) {
+ // Uninstall from filesystem via Electron
+ try {
+ const result = await (window as any).electron.plugins.uninstall(pluginId);
+ if (result.success) {
+ const updated = installedPlugins.filter((p) => p.id !== pluginId);
+ setInstalledPlugins(updated);
+ } else {
+ setError(result.error || 'Failed to uninstall plugin');
+ }
+ } catch (err) {
+ console.error('Failed to uninstall plugin:', err);
+ setError(err instanceof Error ? err.message : 'Failed to uninstall plugin');
+ }
+ } else {
+ // Fallback to localStorage for web
+ const updated = installedPlugins.filter((p) => p.id !== pluginId);
+ setInstalledPlugins(updated);
+ localStorage.setItem('installedPlugins', JSON.stringify(updated));
+ }
+ },
+ [installedPlugins]
+ );
+
+ const handleToggleEnabled = useCallback(
+ async (pluginId: string) => {
+ const plugin = installedPlugins.find((p) => p.id === pluginId);
+ if (!plugin) return;
+
+ const newEnabledState = !plugin.enabled;
+
+ if (plugin.enabled) {
+ // Disabling: unregister the plugin to trigger onUnload
+ try {
+ await pluginRegistry.unregisterPlugin(pluginId);
+ console.log(`[Plugins] Plugin ${pluginId} disabled and unloaded`);
+ } catch (err) {
+ console.error(`Failed to unregister plugin ${pluginId}:`, err);
+ }
+ }
+
+ // Update installedPlugins list
+ const updated = installedPlugins.map((p) =>
+ p.id === pluginId ? { ...p, enabled: newEnabledState } : p
+ );
+ setInstalledPlugins(updated);
+ localStorage.setItem('installedPlugins', JSON.stringify(updated));
+
+ // Also update enabledPlugins for PluginLoader
+ const enabledPluginsStr = localStorage.getItem('enabledPlugins');
+ const enabledPlugins = enabledPluginsStr ? JSON.parse(enabledPluginsStr) : {};
+ enabledPlugins[pluginId] = newEnabledState;
+ localStorage.setItem('enabledPlugins', JSON.stringify(enabledPlugins));
+
+ // Note: Enabling a plugin requires app restart to load it
+ // PluginLoader only loads plugins on mount
+ if (newEnabledState) {
+ console.log(`[Plugins] Plugin ${pluginId} enabled. Restart app to load it.`);
+ }
+ },
+ [installedPlugins]
+ );
+
+ const isPluginInstalled = useCallback(
+ (pluginId: string) => installedPlugins.some((p) => p.id === pluginId),
+ [installedPlugins]
+ );
+
+ const fuzzySearch = (query: string, text: string): boolean => {
+ if (!query) return true;
+ const searchLower = query.toLowerCase();
+ const textLower = text.toLowerCase();
+ return textLower.includes(searchLower);
+ };
+
+ const PluginAvatar = ({ thumbnail, name }: { thumbnail: string; name: string }) => {
+ const [imageError, setImageError] = useState(false);
+
+ return (
+
+ {!imageError ? (
+
setImageError(true)}
+ />
+ ) : (
+
+
+ {name.charAt(0).toUpperCase()}
+
+
+ )}
+
+ );
+ };
+
+ const filteredMarketplacePlugins = useMemo(() => {
+ if (!searchQuery) return marketplacePlugins;
+ return marketplacePlugins.filter((plugin) =>
+ fuzzySearch(searchQuery, plugin.name) ||
+ fuzzySearch(searchQuery, plugin.description) ||
+ fuzzySearch(searchQuery, plugin.author) ||
+ plugin.tags.some((tag) => fuzzySearch(searchQuery, tag))
+ );
+ }, [marketplacePlugins, searchQuery]);
+
+ const filteredInstalledPlugins = useMemo(() => {
+ if (!searchQuery) return installedPlugins;
+ return installedPlugins.filter((plugin) =>
+ fuzzySearch(searchQuery, plugin.name) ||
+ fuzzySearch(searchQuery, plugin.description) ||
+ fuzzySearch(searchQuery, plugin.author)
+ );
+ }, [installedPlugins, searchQuery]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ setActiveTab(PluginTab.Marketplace)}
+ >
+
+ Marketplace
+
+
+ setActiveTab(PluginTab.Installed)}
+ >
+
+ Installed
+
+
+
+ setSearchQuery(e.target.value)}
+ before={}
+ after={
+ searchQuery && (
+ setSearchQuery('')}
+ >
+
+
+ )
+ }
+ />
+
+
+
+
+
+ {activeTab === PluginTab.Marketplace && (
+
+ {loading && (
+
+
+
+ )}
+ {error && (
+
+
+ Error: {error}
+
+
+
+ )}
+ {!loading && !error && filteredMarketplacePlugins.length === 0 && (
+
+
+ {searchQuery ? 'No plugins found matching your search' : 'No plugins available'}
+
+
+ )}
+ {!loading &&
+ !error &&
+ filteredMarketplacePlugins.map((plugin) => (
+ {
+ e.currentTarget.style.transform = 'translateY(-2px)';
+ e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.transform = 'translateY(0)';
+ e.currentTarget.style.boxShadow = 'none';
+ }}
+ >
+
+
+
+
+
+
+ {plugin.name}
+
+
+ by {plugin.author}
+
+ •
+
+ v{plugin.version}
+
+
+
+
+ {plugin.description}
+
+ {plugin.tags.length > 0 && (
+
+ {plugin.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ ))}
+
+ )}
+ {activeTab === PluginTab.Installed && (
+
+ {filteredInstalledPlugins.length === 0 && (
+
+
+ {searchQuery ? 'No installed plugins found matching your search' : 'No plugins installed'}
+
+
+ )}
+ {filteredInstalledPlugins.map((plugin) => {
+ const hasSettings = pluginRegistry.getPluginSettingsSchema(plugin.id) &&
+ Object.keys(pluginRegistry.getPluginSettingsSchema(plugin.id)!).length > 0;
+ const settingsExpanded = expandedSettings === plugin.id;
+
+ return (
+
+ {
+ e.currentTarget.parentElement!.style.transform = 'translateY(-2px)';
+ e.currentTarget.parentElement!.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.parentElement!.style.transform = 'translateY(0)';
+ e.currentTarget.parentElement!.style.boxShadow = 'none';
+ }}
+ >
+
+
+
+
+
+
+ {plugin.name}
+
+
+ by {plugin.author}
+
+ •
+
+ v{plugin.version}
+
+
+
+
+ {plugin.description}
+
+
+
+ {plugin.enabled ? 'Enabled' : 'Disabled'}
+
+
+
+
+ {hasSettings && (
+ }
+ after={}
+ onClick={() => setExpandedSettings(settingsExpanded ? null : plugin.id)}
+ >
+ Settings
+
+ )}
+
+
+
+
+
+ {hasSettings && settingsExpanded && (
+
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/settings/plugins/index.ts b/src/app/features/settings/plugins/index.ts
new file mode 100644
index 0000000..d871849
--- /dev/null
+++ b/src/app/features/settings/plugins/index.ts
@@ -0,0 +1,4 @@
+export * from './Plugins';
+export * from './types';
+export * from './PluginLoader';
+export * from './PluginAPI';
diff --git a/src/app/features/settings/plugins/types.ts b/src/app/features/settings/plugins/types.ts
new file mode 100644
index 0000000..37279a0
--- /dev/null
+++ b/src/app/features/settings/plugins/types.ts
@@ -0,0 +1,38 @@
+/**
+ * Plugin metadata from the marketplace
+ */
+export interface PluginMetadata {
+ id: string;
+ name: string;
+ version: string;
+ description: string;
+ author: string;
+ repository: string;
+ thumbnail: string;
+ downloadUrl: string;
+ homepage: string;
+ tags: string[];
+ addedDate: string;
+}
+
+/**
+ * Plugin directory index response
+ */
+export interface PluginIndex {
+ version: string;
+ updatedAt: string;
+ plugins: string[];
+}
+
+/**
+ * Installed plugin information
+ */
+export interface InstalledPlugin extends PluginMetadata {
+ installedDate: string;
+ enabled: boolean;
+}
+
+export enum PluginTab {
+ Marketplace = 'marketplace',
+ Installed = 'installed',
+}
diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts
index 82265f3..36c1cea 100644
--- a/src/app/hooks/useTheme.ts
+++ b/src/app/hooks/useTheme.ts
@@ -4,6 +4,7 @@ import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
import { butterTheme, catppuccinMochaTheme, darkTheme, discordTheme, discordDarkerTheme, mochaTheme, silverTheme, twilightTheme } from '../../colors.css';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
+import { pluginRegistry } from '../features/settings/plugins/PluginAPI';
export enum ThemeKind {
Light = 'light',
@@ -64,14 +65,51 @@ export const CatppuccinMochaTheme: Theme = {
};
export const useThemes = (): Theme[] => {
- const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme, DiscordTheme, DiscordDarkerTheme, TwilightTheme, MochaTheme, CatppuccinMochaTheme], []);
+ const [pluginThemesUpdate, setPluginThemesUpdate] = useState(0);
+
+ // Force update when plugins change (this is a simple approach)
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setPluginThemesUpdate(prev => prev + 1);
+ }, 1000);
+ return () => clearInterval(interval);
+ }, []);
+
+ const builtInThemes: Theme[] = useMemo(() => [
+ LightTheme, SilverTheme, DarkTheme, ButterTheme,
+ DiscordTheme, DiscordDarkerTheme, TwilightTheme,
+ MochaTheme, CatppuccinMochaTheme
+ ], []);
+
+ // Convert plugin themes to Theme format
+ const pluginThemes: Theme[] = useMemo(() => {
+ const themes = pluginRegistry.getPluginThemes();
+ return themes.map(pluginTheme => ({
+ id: pluginTheme.id,
+ kind: pluginTheme.kind === 'dark' ? ThemeKind.Dark : ThemeKind.Light,
+ classNames: [
+ pluginTheme.className,
+ pluginTheme.kind === 'dark' ? onDarkFontWeight : onLightFontWeight,
+ pluginTheme.kind === 'dark' ? 'prism-dark' : 'prism-light'
+ ]
+ }));
+ }, [pluginThemesUpdate]);
- return themes;
+ return [...builtInThemes, ...pluginThemes];
};
-export const useThemeNames = (): Record =>
- useMemo(
- () => ({
+export const useThemeNames = (): Record => {
+ const [pluginThemesUpdate, setPluginThemesUpdate] = useState(0);
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setPluginThemesUpdate(prev => prev + 1);
+ }, 1000);
+ return () => clearInterval(interval);
+ }, []);
+
+ return useMemo(() => {
+ const builtInNames = {
[LightTheme.id]: 'Light',
[SilverTheme.id]: 'Silver',
[DarkTheme.id]: 'Dark',
@@ -81,9 +119,17 @@ export const useThemeNames = (): Record =>
[TwilightTheme.id]: 'Twilight',
[MochaTheme.id]: 'Mocha',
[CatppuccinMochaTheme.id]: 'Catppuccin Mocha',
- }),
- []
- );
+ };
+
+ const pluginThemes = pluginRegistry.getPluginThemes();
+ const pluginNames = pluginThemes.reduce((acc, theme) => {
+ acc[theme.id] = theme.name;
+ return acc;
+ }, {} as Record);
+
+ return { ...builtInNames, ...pluginNames };
+ }, [pluginThemesUpdate]);
+};
export const useSystemThemeKind = (): ThemeKind => {
const darkModeQueryList = useMemo(() => window.matchMedia('(prefers-color-scheme: dark)'), []);
diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx
index 3a3ff23..4534809 100644
--- a/src/app/pages/client/ClientRoot.tsx
+++ b/src/app/pages/client/ClientRoot.tsx
@@ -28,6 +28,7 @@ import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
import { MediaConfigProvider } from '../../hooks/useMediaConfig';
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
+import { PluginLoader } from '../../features/settings/plugins';
import { SpecVersions } from './SpecVersions';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useSyncState } from '../../hooks/useSyncState';
@@ -284,19 +285,21 @@ export function ClientRoot({ children }: ClientRootProps) {
) : (
-
-
- {(serverConfigs) => (
-
-
-
- {children}
-
-
-
- )}
-
-
+
+
+
+ {(serverConfigs) => (
+
+
+
+ {children}
+
+
+
+ )}
+
+
+
)}
diff --git a/src/colors.css.ts b/src/colors.css.ts
index baca59d..a8abd1d 100644
--- a/src/colors.css.ts
+++ b/src/colors.css.ts
@@ -721,3 +721,4 @@ export const catppuccinMochaTheme = createTheme(color, {
Overlay: 'rgba(0, 0, 0, 0.8)',
},
});
+
diff --git a/src/ext.d.ts b/src/ext.d.ts
index 72acc58..e89820f 100644
--- a/src/ext.d.ts
+++ b/src/ext.d.ts
@@ -33,3 +33,33 @@ declare module '*.svg' {
const content: string;
export default content;
}
+
+interface PluginAPI {
+ getPath: () => Promise<{ success: boolean; data?: string; error?: string }>;
+ download: (
+ pluginId: string,
+ downloadUrl: string,
+ name: string
+ ) => Promise<{ success: boolean; data?: { path: string }; error?: string }>;
+ list: () => Promise<{
+ success: boolean;
+ data?: Array<{ id: string; name: string; installedDate: string; path: string }>;
+ error?: string;
+ }>;
+ uninstall: (pluginId: string) => Promise<{ success: boolean; error?: string }>;
+ readPluginCode: (pluginId: string) => Promise<{ success: boolean; data?: string; error?: string }>;
+}
+
+interface ElectronAPI {
+ platform?: string;
+ arch?: string;
+ plugins?: PluginAPI;
+}
+
+declare global {
+ interface Window {
+ electron?: ElectronAPI;
+ }
+}
+
+export {};