diff --git a/src/app/components/direct-list/DirectList.tsx b/src/app/components/direct-list/DirectList.tsx index c8073da..96c2947 100644 --- a/src/app/components/direct-list/DirectList.tsx +++ b/src/app/components/direct-list/DirectList.tsx @@ -42,6 +42,7 @@ import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategorie import { useRoomsUnread } from '../../state/hooks/unread'; import { useMarkRoomsAsRead } from '../../hooks/useMarkAsRead'; import { stopPropagation } from '../../utils/keyboard'; +import { PluginButtonSlot } from '../../features/settings/plugins/PluginButtonSlot'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { @@ -121,7 +122,8 @@ export function DirectListHeader({ title = 'Direct Messages' }: DirectListHeader {title} - + + diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index afbdd59..3927305 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -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( } before={ - pickFile('*')} - variant="SurfaceVariant" - size="300" - radii="300" - > - - + <> + pickFile('*')} + variant="SurfaceVariant" + size="300" + radii="300" + > + + + + } after={ <> @@ -832,6 +836,7 @@ export const RoomInput = forwardRef( )} + diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 697fa50..8d962ad 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -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(({ room, requestClose + + + + {(promptLeave, setPromptLeave) => ( @@ -713,6 +718,7 @@ export function RoomViewHeader() { )} + ( )} + (
+ ({ 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, })); }, diff --git a/src/app/features/settings/plugins/PluginButtonSlot.tsx b/src/app/features/settings/plugins/PluginButtonSlot.tsx new file mode 100644 index 0000000..0c3757a --- /dev/null +++ b/src/app/features/settings/plugins/PluginButtonSlot.tsx @@ -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(); +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) => ( + button.onClick?.()} + > + + {button.icon ?? '🔌'} + + + ))} + + ); +} diff --git a/src/app/features/settings/plugins/PluginLoader.tsx b/src/app/features/settings/plugins/PluginLoader.tsx index c58be40..cce2dd3 100644 --- a/src/app/features/settings/plugins/PluginLoader.tsx +++ b/src/app/features/settings/plugins/PluginLoader.tsx @@ -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]); diff --git a/src/app/features/settings/plugins/PluginNavSlot.tsx b/src/app/features/settings/plugins/PluginNavSlot.tsx new file mode 100644 index 0000000..b812e57 --- /dev/null +++ b/src/app/features/settings/plugins/PluginNavSlot.tsx @@ -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) => ( + + button.onClick?.()}> + + + + + {button.icon ?? '🔌'} + + + + + {button.label} + + + + + + + ))} + + ); +} diff --git a/src/app/features/settings/plugins/PluginSidebarSlot.tsx b/src/app/features/settings/plugins/PluginSidebarSlot.tsx new file mode 100644 index 0000000..b86b714 --- /dev/null +++ b/src/app/features/settings/plugins/PluginSidebarSlot.tsx @@ -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) => ( + + + {(triggerRef) => ( + button.onClick?.()} + aria-label={button.label} + > + + {button.icon ?? '🔌'} + + + )} + + + ))} + + ); +} diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index 68dc36c..6545ed5 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -20,6 +20,7 @@ import { import { CreateTab } from './sidebar/CreateTab'; import { HiddenSpacesTabs } from './sidebar/SpaceTabs'; import { useHomeHidden } from '../../hooks/useSidebarItems'; +import { PluginSidebarSlot } from '../../features/settings/plugins/PluginSidebarSlot'; export function SidebarNav() { const scrollRef = useRef(null); @@ -37,6 +38,7 @@ export function SidebarNav() { + @@ -47,6 +49,7 @@ export function SidebarNav() { <> + diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 1a0955a..d9349c0 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -44,6 +44,8 @@ import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCatego import { useRoomsUnread } from '../../../state/hooks/unread'; import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead'; import { stopPropagation } from '../../../utils/keyboard'; +import { PluginButtonSlot } from '../../../features/settings/plugins/PluginButtonSlot'; +import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { @@ -228,6 +230,7 @@ export function Direct() { + diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index 9b95b83..c70831f 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -57,6 +57,7 @@ import { useRoomsUnread } from '../../../state/hooks/unread'; import { useMarkRoomsAsRead } from '../../../hooks/useMarkAsRead'; import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; import { stopPropagation } from '../../../utils/keyboard'; +import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { @@ -152,7 +153,7 @@ function HomeHeader() { Home - + @@ -409,6 +410,7 @@ export function Home() { + @@ -420,6 +422,7 @@ export function Home() { Rooms +
- + + + diff --git a/src/app/pages/client/sidebar/SettingsTab.tsx b/src/app/pages/client/sidebar/SettingsTab.tsx index 682a9c6..8cdacbe 100644 --- a/src/app/pages/client/sidebar/SettingsTab.tsx +++ b/src/app/pages/client/sidebar/SettingsTab.tsx @@ -13,6 +13,7 @@ import { useUserProfile } from '../../../hooks/useUserProfile'; import { Modal500 } from '../../../components/Modal500'; import { AccountSwitcher } from '../../../components/account-switcher'; import { stopPropagation } from '../../../utils/keyboard'; +import { PluginButtonSlot } from '../../../features/settings/plugins/PluginButtonSlot'; import { getLoginPath } from '../../pathUtils'; import { logoutClient } from '../../../../client/initMatrix'; import { CustomStatusDialog } from '../../../components/custom-status'; @@ -110,6 +111,10 @@ export function SettingsTab() { + + + + { diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 7c5e7b3..a85038c 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -68,6 +68,7 @@ import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCatego import { useStateEvent } from '../../../hooks/useStateEvent'; import { Membership, StateEvent } from '../../../../types/matrix/room'; import { stopPropagation } from '../../../utils/keyboard'; +import { PluginNavSlot } from '../../../features/settings/plugins/PluginNavSlot'; import { getMatrixToRoom } from '../../../plugins/matrix-to'; import { getViaServers } from '../../../plugins/via-servers'; import { useSetting } from '../../../state/hooks/settings'; @@ -549,6 +550,7 @@ export function Space() { +