Files
paseo/packages/app/src/plugins/attachments/picker.tsx
T
Mohamed Boudra b6dde61a7f feat(plugins): accept inline SVG and component icons (#7368)
Every contribution `icon` now takes a Lucide name, `{ svg }`, or a
component. Attachment sources take only the first two because composer
drafts persist the source icon.

The daemon's provider-icon check and the app's inline-SVG check are the
same function in the shared SDK root, so a plugin sees the same reasons
in both places. The app parses each icon once at registration and fails
the plugin with that reason. Rendering goes through one module that
turns any form into a component, so surfaces never branch on the form.
2026-10-19 15:36:04 +07:00

180 lines
5.7 KiB
TypeScript

import { useCallback, useMemo, useState, type ReactElement, type RefObject } from "react";
import { View } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type {
PluginAttachmentItem,
PluginAttachmentSourceContribution,
PluginSerializableIcon,
} from "@getpaseo/plugin";
import { searchPluginAttachments } from "@getpaseo/plugin/client/host";
import type { UserComposerAttachment } from "@/attachments/types";
import type { AttachmentMenuItem } from "@/composer/input/input";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { useFetchQuery } from "@/data/query";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { PluginIconView } from "../icons";
import { useInstalledPlugins } from "../registry";
import type { InstalledPlugin } from "../types";
import { createPluginResourceAttachment, togglePluginResourceAttachment } from "./model";
const SEARCH_STALE_TIME_MS = 30_000;
const EMPTY_ATTACHMENT_ITEMS: PluginAttachmentItem[] = [];
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function SourceIcon({ icon, color = "" }: { icon: PluginSerializableIcon; color?: string }) {
return <PluginIconView icon={icon} size={ICON_SIZE.md} color={color} />;
}
const ThemedSourceIcon = withUnistyles(SourceIcon);
interface InstalledAttachmentSource {
plugin: InstalledPlugin;
source: PluginAttachmentSourceContribution;
key: string;
}
interface PluginAttachmentPickerInput {
serverId: string;
client: DaemonClient | null;
connected: boolean;
attachments: UserComposerAttachment[];
onChangeAttachments: (attachments: UserComposerAttachment[]) => void;
anchorRef: RefObject<View | null>;
}
interface PluginAttachmentPickerBinding {
menuItems: AttachmentMenuItem[];
picker: ReactElement | null;
}
function searchEmptyText(error: unknown, isFetching: boolean): string {
if (error instanceof Error) return error.message;
if (error) return String(error);
return isFetching ? "Searching..." : "No results";
}
function installedAttachmentSources(
plugins: InstalledPlugin[],
serverId: string,
): InstalledAttachmentSource[] {
return plugins
.filter((plugin) => plugin.serverId === serverId)
.flatMap((plugin) =>
plugin.attachmentSources.map((source) => ({
plugin,
source,
key: `${plugin.id}/${source.id}`,
})),
);
}
function attachmentOptions(items: PluginAttachmentItem[]): ComboboxOption[] {
return items.map((item) => ({
id: item.id,
label: `${item.identifier} ${item.title}`,
description: item.subtitle,
}));
}
export function usePluginAttachmentPicker(
input: PluginAttachmentPickerInput,
): PluginAttachmentPickerBinding {
const plugins = useInstalledPlugins();
const sources = useMemo(
() => installedAttachmentSources(plugins, input.serverId),
[input.serverId, plugins],
);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [query, setQuery] = useState("");
const active = sources.find((candidate) => candidate.key === activeKey) ?? null;
const trimmedQuery = query.trim();
const search = useFetchQuery(
{
queryKey: [
"plugin-attachment-search",
input.serverId,
active?.plugin.id ?? "",
active?.source.id ?? "",
trimmedQuery,
],
queryFn: async () => {
if (!input.client || !active) throw new Error("Plugin host is offline");
const client = input.client;
return searchPluginAttachments(
active.source,
(method, rpcInput) => client.invokePluginRpc(active.plugin.id, method, rpcInput),
trimmedQuery,
);
},
enabled: input.connected && active !== null,
dataShape: "list",
staleTimeMs: SEARCH_STALE_TIME_MS,
},
active?.plugin.queryClient,
);
const items = search.error
? EMPTY_ATTACHMENT_ITEMS
: (search.data?.items ?? EMPTY_ATTACHMENT_ITEMS);
const options = useMemo(() => attachmentOptions(items), [items]);
const close = useCallback(() => {
setActiveKey(null);
setQuery("");
}, []);
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) close();
},
[close],
);
const handleSelect = useCallback(
(itemId: string) => {
if (!active) return;
const item = items.find((candidate) => candidate.id === itemId);
if (!item) return;
const attachment = createPluginResourceAttachment(
{
pluginId: active.plugin.id,
sourceId: active.source.id,
sourceTitle: active.source.title,
sourceIcon: active.source.icon,
},
item,
);
input.onChangeAttachments(togglePluginResourceAttachment(input.attachments, attachment));
close();
},
[active, close, input, items],
);
const menuItems = useMemo(
() =>
sources.map(({ key, source }) => ({
id: `plugin:${key}`,
label: `Attach ${source.title}`,
icon: <ThemedSourceIcon icon={source.icon} uniProps={iconColorMapping} />,
onSelect: () => setActiveKey(key),
})),
[sources],
);
if (!active) return { menuItems, picker: null };
return {
menuItems,
picker: (
<Combobox
options={options}
value=""
onSelect={handleSelect}
searchable
searchPlaceholder={active.source.searchPlaceholder}
title={active.source.pickerTitle}
open
onOpenChange={handleOpenChange}
onSearchQueryChange={setQuery}
desktopPlacement="top-start"
anchorRef={input.anchorRef}
emptyText={searchEmptyText(search.error, search.isFetching)}
/>
),
};
}