feat: add plugin button and navigation slots for enhanced plugin integration across various components

This commit is contained in:
2026-04-22 00:18:55 +10:00
parent 9a463facce
commit 64e252da94
15 changed files with 227 additions and 12 deletions

View File

@@ -105,6 +105,7 @@ import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../.
import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
import { pluginRegistry } from '../settings/plugins/PluginAPI';
import { PluginButtonSlot } from '../settings/plugins/PluginButtonSlot';
import { mobileOrTablet } from '../../utils/user-agent';
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
import { ReplyLayout, ThreadIndicator } from '../../components/message';
@@ -746,14 +747,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</>
}
before={
<IconButton
onClick={() => pickFile('*')}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.PlusCircle} />
</IconButton>
<>
<IconButton
onClick={() => pickFile('*')}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.PlusCircle} />
</IconButton>
<PluginButtonSlot location="composer-actions" />
</>
}
after={
<>
@@ -832,6 +836,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</PopOut>
)}
</UseStateProvider>
<PluginButtonSlot location="text-composer-toolbar" />
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Send} />
</IconButton>

View File

@@ -55,6 +55,7 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard';
import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getViaServers } from '../../plugins/via-servers';
import { PluginButtonSlot } from '../settings/plugins/PluginButtonSlot';
import { BackRouteHandler } from '../../components/BackRouteHandler';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
@@ -229,6 +230,10 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
</UseStateProvider>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Row" gap="100" style={{ padding: config.space.S100 }}>
<PluginButtonSlot location="room-menu" />
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
@@ -713,6 +718,7 @@ export function RoomViewHeader() {
</IconButton>
)}
</TooltipProvider>
<PluginButtonSlot location="room-header" />
<TooltipProvider
position="Bottom"
align="End"

View File

@@ -75,6 +75,7 @@ import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
import { getViaServers } from '../../../plugins/via-servers';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { PluginButtonSlot } from '../../../features/settings/plugins/PluginButtonSlot';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
import { PowerIcon } from '../../../components/power';
@@ -1070,6 +1071,7 @@ export const Message = as<'div', MessageProps>(
<Icon src={Icons.Delete} size="100" />
</IconButton>
)}
<PluginButtonSlot location="message-actions" />
<PopOut
anchor={menuAnchor}
position="Bottom"
@@ -1333,6 +1335,7 @@ export const Event = as<'div', EventProps>(
<div className={css.MessageOptionsBase}>
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
<Box gap="100">
<PluginButtonSlot location="message-actions" />
<PopOut
anchor={menuAnchor}
position="Bottom"

View File

@@ -41,6 +41,8 @@ export type {
PluginContext,
PluginSettingsSection,
UILocation,
UIButtonDefinition,
UIButtonPosition,
Plugin,
PluginLogEntry,
} from '@paarrot/plugin-manager';
@@ -54,7 +56,7 @@ import {
} from '@paarrot/plugin-manager';
const PLUGIN_INDEX_URL =
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json';
`https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json?cache_burst=${ (Math.random()*256).toString()}`;
const PLUGIN_BASE_URL =
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/';
@@ -107,6 +109,13 @@ export const pluginMarketplaceManager = new PluginMarketplaceManager({
return result.data.map((plugin) => ({
id: plugin.id,
name: plugin.name,
version: plugin.version,
description: plugin.description,
author: plugin.author,
repository: plugin.repository,
thumbnail: plugin.thumbnail,
homepage: plugin.homepage,
tags: plugin.tags,
installedDate: plugin.installedDate,
}));
},

View File

@@ -0,0 +1,71 @@
import React, { useSyncExternalStore } from 'react';
import { IconButton } from 'folds';
import { UILocation } from '@paarrot/plugin-manager';
import { pluginRegistry } from './PluginAPI';
// ---------------------------------------------------------------------------
// Module-level subscription store — works correctly with React 18 concurrent
// rendering, lazy-mounted components (nav panels, route changes), and popup
// menus that mount fresh after plugins have already loaded.
// ---------------------------------------------------------------------------
type Listener = () => void;
const listeners = new Set<Listener>();
let storeVersion = 0;
export function subscribeToButtons(callback: Listener): () => void {
listeners.add(callback);
return () => listeners.delete(callback);
}
export function getStoreVersion(): number {
return storeVersion;
}
/**
* Notify all subscribed slots that the button registry has changed.
* Call this after plugins load or unload.
*/
export function dispatchPluginButtonsChanged(): void {
storeVersion += 1;
for (const listener of listeners) {
listener();
}
}
interface PluginButtonSlotProps {
/** The UI location to render buttons for. */
location: UILocation;
}
/**
* Renders all plugin-registered buttons for the given UI location.
* Correctly handles components that mount before OR after plugins load,
* including popup menus, navigation panels, and concurrent React renders.
*/
export function PluginButtonSlot({ location }: PluginButtonSlotProps): React.ReactElement | null {
useSyncExternalStore(subscribeToButtons, getStoreVersion);
const buttons = pluginRegistry.getButtonsForLocation(location);
if (buttons.length === 0) return null;
return (
<>
{buttons.map((button) => (
<IconButton
key={button.id}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label={button.label}
title={button.label}
onClick={() => button.onClick?.()}
>
<span aria-hidden style={{ fontSize: '1.1em', lineHeight: 1 }}>
{button.icon ?? '🔌'}
</span>
</IconButton>
))}
</>
);
}

View File

@@ -3,6 +3,7 @@ import { MatrixClient } from 'matrix-js-sdk';
import { Plugin, createPluginContext } from '@paarrot/plugin-manager';
import { pluginMarketplaceManager, pluginRegistry } from './PluginAPI';
import { sendNotification } from '../../../utils/tauri';
import { dispatchPluginButtonsChanged } from './PluginButtonSlot';
interface PluginLoaderProps {
matrixClient: MatrixClient;
@@ -87,6 +88,7 @@ export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
pluginRegistry.registerPlugin(installedPlugin.id, plugin, context);
await plugin.onLoad(compatContext as any);
console.log(`[PluginLoader] Successfully loaded plugin: ${installedPlugin.id}`);
dispatchPluginButtonsChanged();
} catch (error) {
console.error(`[PluginLoader] Failed to load plugin ${installedPlugin.id}:`, error);
pluginRegistry.addLog(installedPlugin.id, 'error', ['Failed to load:', error]);
@@ -102,6 +104,7 @@ export function PluginLoader({ matrixClient, children }: PluginLoaderProps) {
return () => {
console.log('[PluginLoader] Cleaning up plugins');
pluginRegistry.clear();
dispatchPluginButtonsChanged();
};
}, [matrixClient]);

View File

@@ -0,0 +1,48 @@
import React, { useSyncExternalStore } from 'react';
import { Avatar, Box, Text } from 'folds';
import { UILocation } from '@paarrot/plugin-manager';
import { NavItem, NavButton, NavItemContent } from '../../../components/nav';
import { pluginRegistry } from './PluginAPI';
import { subscribeToButtons, getStoreVersion } from './PluginButtonSlot';
interface PluginNavSlotProps {
/** The UI location to render nav list entries for. */
location: UILocation;
}
/**
* Renders plugin buttons as `NavItem` list entries, matching the style of
* built-in sidebar items like "Create Room" and "Message Search".
* Intended for locations that live inside the channel / room list, not toolbars.
*/
export function PluginNavSlot({ location }: PluginNavSlotProps): React.ReactElement | null {
useSyncExternalStore(subscribeToButtons, getStoreVersion);
const buttons = pluginRegistry.getButtonsForLocation(location);
if (buttons.length === 0) return null;
return (
<>
{buttons.map((button) => (
<NavItem key={button.id} variant="Background" radii="400">
<NavButton onClick={() => button.onClick?.()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<span aria-hidden style={{ fontSize: '1em', lineHeight: 1 }}>
{button.icon ?? '🔌'}
</span>
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
{button.label}
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
))}
</>
);
}

View File

@@ -0,0 +1,49 @@
import React, { useSyncExternalStore } from 'react';
import { UILocation } from '@paarrot/plugin-manager';
import {
SidebarItem,
SidebarItemTooltip,
SidebarAvatar,
} from '../../../components/sidebar';
import { pluginRegistry } from './PluginAPI';
import { subscribeToButtons, getStoreVersion } from './PluginButtonSlot';
interface PluginSidebarSlotProps {
/** The UI location to render sidebar icon entries for. */
location: UILocation;
}
/**
* Renders plugin buttons as `SidebarItem` entries, matching the style of
* built-in sidebar tabs like SearchTab, InboxTab, and SettingsTab.
* Intended for the `sidebar-actions` location in the main left sidebar.
*/
export function PluginSidebarSlot({ location }: PluginSidebarSlotProps): React.ReactElement | null {
useSyncExternalStore(subscribeToButtons, getStoreVersion);
const buttons = pluginRegistry.getButtonsForLocation(location);
if (buttons.length === 0) return null;
return (
<>
{buttons.map((button) => (
<SidebarItem key={button.id}>
<SidebarItemTooltip tooltip={button.label}>
{(triggerRef) => (
<SidebarAvatar
as="button"
ref={triggerRef}
onClick={() => button.onClick?.()}
aria-label={button.label}
>
<span aria-hidden style={{ fontSize: '1.2em', lineHeight: 1 }}>
{button.icon ?? '🔌'}
</span>
</SidebarAvatar>
)}
</SidebarItemTooltip>
</SidebarItem>
))}
</>
);
}