feat: add plugin button and navigation slots for enhanced plugin integration across various components
This commit is contained in:
@@ -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,
|
||||
}));
|
||||
},
|
||||
|
||||
71
src/app/features/settings/plugins/PluginButtonSlot.tsx
Normal file
71
src/app/features/settings/plugins/PluginButtonSlot.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
48
src/app/features/settings/plugins/PluginNavSlot.tsx
Normal file
48
src/app/features/settings/plugins/PluginNavSlot.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
49
src/app/features/settings/plugins/PluginSidebarSlot.tsx
Normal file
49
src/app/features/settings/plugins/PluginSidebarSlot.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user