import type { CapabilityInstall, Connection, ConnectionCatalogItem } from "@rakazo/contracts"; import { abortableDelay, buildFeaturedConnectorTiles, CONNECTION_CATALOG_PAGE_SIZE, EMPTY_PLUGIN_CATALOG_MESSAGE, filterConnectionCatalogItems, GITHUB_MCP_SOURCE, GITHUB_TOKEN_URL, humanizeToolName, isGithubInstall, } from "@rakazo/core"; import { useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Alert, Image, Linking, Pressable, ScrollView, StyleSheet, Text, TextInput, useWindowDimensions, View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { rpc } from "../lib/api"; import { mobileTokens } from "../lib/appearance"; import { useI18n } from "../lib/i18n"; import { loadLastBotId } from "../lib/last-bot"; import { native, useThemedStyles } from "../lib/native"; type SourceKind = "treg" | "executor" | "mcp" | "api" | "graphql"; type ConnectionTool = { name: string; description: string }; const LOGO_SIZE = 32; function itemKey(item: Pick) { return `${item.connectorId}:${item.slug}`; } function ConnectorLogo({ logo, label, styles, }: { logo?: string | null; label: string; styles: ReturnType; }) { const [failed, setFailed] = useState(false); useEffect(() => { setFailed(false); }, [logo]); const initial = (label.trim()[0] || "?").toUpperCase(); if (!logo || failed) { return ( {initial} ); } return ( setFailed(true)} resizeMode="contain" source={{ uri: logo }} style={styles.logo} /> ); } export default function Integrations() { const styles = useThemedStyles(createIntegrationsStyles); const { t } = useI18n(); const { width } = useWindowDimensions(); const catalogColumns = width >= 480 ? 2 : 1; const [catalog, setCatalog] = useState([]); const [connections, setConnections] = useState([]); const [query, setQuery] = useState(""); const [visibleCount, setVisibleCount] = useState(CONNECTION_CATALOG_PAGE_SIZE); const [sources, setSources] = useState([]); const github = sources.find(isGithubInstall); const customSources = sources.filter((source) => !isGithubInstall(source)); const [githubOpen, setGithubOpen] = useState(false); const [githubToken, setGithubToken] = useState(""); const [githubError, setGithubError] = useState(null); const [sourceKind, setSourceKind] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(false); const [name, setName] = useState(""); const [url, setUrl] = useState(""); const [credential, setCredential] = useState(""); const [requiresAuth, setRequiresAuth] = useState(true); const [pending, setPending] = useState(null); const [catalogError, setCatalogError] = useState(null); const [sourceError, setSourceError] = useState(null); const [lastBotId, setLastBotId] = useState(""); const [catalogReady, setCatalogReady] = useState(false); const [labelDrafts, setLabelDrafts] = useState>({}); const [detailKey, setDetailKey] = useState<{ connectorId: string; slug: string } | null>(null); const [tools, setTools] = useState([]); const [toolsLoading, setToolsLoading] = useState(false); const [toolsOpen, setToolsOpen] = useState(true); const [toolsTick, setToolsTick] = useState(0); const connectionAttempt = useRef(null); const featuredTiles = useMemo(() => buildFeaturedConnectorTiles(catalog), [catalog]); const showFeatured = !query.trim(); const catalogApps = useMemo(() => filterConnectionCatalogItems(catalog, query), [catalog, query]); const renderedApps = catalogApps.slice(0, visibleCount); const detailItem = useMemo(() => { if (!detailKey) return null; return ( catalog.find( (entry) => entry.connectorId === detailKey.connectorId && entry.slug === detailKey.slug, ) ?? null ); }, [catalog, detailKey]); async function refresh() { const catalogResult = await rpc("connections/catalog"); setCatalog(catalogResult); setCatalogReady(true); try { const rows = await rpc("connections/list"); setConnections(rows); setLabelDrafts((current) => { const next: Record = {}; for (const row of rows) { if (row.status === "connected" || row.status === "pending") { next[row.id] = current[row.id] ?? row.displayName; } } return next; }); } catch { setConnections([]); setLabelDrafts({}); } try { const installs = await rpc("capabilities/list"); setSources( installs.filter( (item) => item.kind === "mcp" || item.kind === "api" || item.kind === "graphql", ), ); } catch { // Tool sources are optional; keep featured/catalog usable if this fails. } } useEffect(() => { void refresh().catch((reason) => { setCatalogReady(false); setCatalogError(reason instanceof Error ? reason.message : t("Could not load integrations")); }); void loadLastBotId().then(setLastBotId); return () => connectionAttempt.current?.abort(); }, []); useEffect(() => { if (!detailKey) { setTools([]); setToolsLoading(false); return; } let cancelled = false; setToolsLoading(true); void rpc("connections/tools", { connectorId: detailKey.connectorId, provider: detailKey.slug, }) .then((list) => { if (!cancelled) setTools(list); }) .catch(() => { if (!cancelled) setTools([]); }) .finally(() => { if (!cancelled) setToolsLoading(false); }); return () => { cancelled = true; }; }, [detailKey, toolsTick]); function closeAdvanced() { setAdvancedOpen(false); setSourceKind(null); setSourceError(null); setName(""); setUrl(""); setCredential(""); setRequiresAuth(true); } function openDetail(item: ConnectionCatalogItem) { setCatalogError(null); setToolsOpen(true); setDetailKey({ connectorId: item.connectorId, slug: item.slug }); } function closeDetail() { setDetailKey(null); setTools([]); } function accountsFor(item: Pick) { return connections.filter( (row) => row.connectorId === item.connectorId && row.provider === item.slug && (row.status === "connected" || row.status === "pending"), ); } function itemConnected(item: ConnectionCatalogItem) { return item.connected || accountsFor(item).some((row) => row.status === "connected"); } async function notifyAppConnected(item: ConnectionCatalogItem) { const botId = lastBotId || (await loadLastBotId()); if (!botId) return; if (botId !== lastBotId) setLastBotId(botId); void rpc("onboarding/appConnected", { botId, provider: item.slug, connectorId: item.connectorId, }).catch(() => undefined); } async function connect(item: ConnectionCatalogItem) { connectionAttempt.current?.abort(); const controller = new AbortController(); connectionAttempt.current = controller; const key = itemKey(item); setPending(key); setCatalogError(null); try { const started = await rpc<{ connectionId: string; authorizationUrl: string | null }>( "connections/begin", { connectorId: item.connectorId, provider: item.slug, displayName: (() => { const count = accountsFor(item).filter((row) => row.status === "connected").length; return count <= 0 ? item.name : `${item.name} ${count + 1}`; })(), }, ); if (started.authorizationUrl) await Linking.openURL(started.authorizationUrl); for (let attempt = 0; attempt < 45; attempt += 1) { if (controller.signal.aborted) return; const row = await rpc("connections/complete", { connectionId: started.connectionId, }).catch(() => undefined); if (row?.status === "connected") { if (controller.signal.aborted) return; void notifyAppConnected(item); await refresh(); setToolsTick((tick) => tick + 1); return; } await abortableDelay(2_000, controller.signal); } if (controller.signal.aborted) return; Alert.alert( t("Connection pending"), t("Finish connecting in the browser, then refresh this page."), ); } catch (reason) { if (controller.signal.aborted) return; setCatalogError(reason instanceof Error ? reason.message : t("Could not connect")); } finally { if (connectionAttempt.current === controller) { connectionAttempt.current = null; setPending(null); } } } async function revokeAccount(row: Connection) { setPending(row.id); setCatalogError(null); try { await rpc("connections/revoke", { connectionId: row.id }); await refresh(); setToolsTick((tick) => tick + 1); } catch (reason) { setCatalogError(reason instanceof Error ? reason.message : t("Could not revoke connection")); } finally { setPending(null); } } async function renameAccount(row: Connection) { const displayName = (labelDrafts[row.id] ?? row.displayName).trim(); if (!displayName || displayName === row.displayName) return; setPending(`rename:${row.id}`); setCatalogError(null); try { const updated = await rpc("connections/rename", { connectionId: row.id, displayName, }); setConnections((current) => current.map((entry) => entry.id === row.id ? { ...entry, displayName: updated.displayName } : entry, ), ); setLabelDrafts((current) => ({ ...current, [row.id]: updated.displayName })); } catch (reason) { setCatalogError(reason instanceof Error ? reason.message : t("Could not rename connection")); } finally { setPending(null); } } async function uninstall(item: ConnectionCatalogItem) { const matches = accountsFor(item); const key = itemKey(item); if (matches.length === 0) { closeDetail(); return; } setPending(`uninstall:${key}`); setCatalogError(null); try { for (const row of matches) { await rpc("connections/revoke", { connectionId: row.id }); } await refresh(); closeDetail(); } catch (reason) { setCatalogError(reason instanceof Error ? reason.message : t("Could not revoke connection")); await refresh().catch(() => undefined); } finally { setPending(null); } } function beginSource(kind: SourceKind) { setSourceKind(kind); setSourceError(null); setName(kind === "treg" ? "Treg" : kind === "executor" ? "Executor" : ""); setUrl(kind === "treg" ? "https://treg.to/mcp/" : ""); setCredential(""); setRequiresAuth(kind === "treg" || kind === "executor"); } async function addSource() { if (!sourceKind) return; setPending("source"); setSourceError(null); try { await rpc("capabilities/install", { kind: sourceKind === "treg" || sourceKind === "executor" ? "mcp" : sourceKind, name: name.trim() || (sourceKind === "treg" ? "Treg" : sourceKind === "executor" ? "Executor" : sourceKind === "graphql" ? "GraphQL" : t("Custom connector")), source: url.trim(), credential: credential.trim() || undefined, config: sourceKind === "treg" ? { preset: "treg", auth: { type: "bearer" } } : sourceKind === "api" ? { openApi: true, auth: { type: requiresAuth ? "bearer" : "none" } } : sourceKind === "graphql" ? { auth: { type: requiresAuth ? "bearer" : "none" } } : { preset: "custom", auth: { type: sourceKind === "executor" || requiresAuth ? "bearer" : "none" }, }, }); setCredential(""); setSourceKind(null); await refresh(); } catch (reason) { setSourceError(reason instanceof Error ? reason.message : t("Could not add source")); } finally { setPending(null); } } async function connectGithub() { setPending("github"); setGithubError(null); try { await rpc("capabilities/install", { kind: "mcp", name: "GitHub", source: GITHUB_MCP_SOURCE, credential: githubToken.trim(), config: { preset: "github" }, }); setGithubToken(""); setGithubOpen(false); await refresh(); } catch (reason) { setGithubError(reason instanceof Error ? reason.message : t("Could not connect GitHub")); } finally { setPending(null); } } async function removeSource(source: CapabilityInstall) { setPending(source.id); setSourceError(null); try { await rpc("capabilities/remove", { id: source.id }); setSources((current) => current.filter((item) => item.id !== source.id)); } catch (reason) { setSourceError(reason instanceof Error ? reason.message : t("Could not remove source")); } finally { setPending(null); } } function renderCatalogActions(item: ConnectionCatalogItem, label: string) { const key = itemKey(item); const connected = itemConnected(item); const connecting = pending === key; if (connected) { return ( openDetail(item)} > {connecting ? t("Working…") : t("Added")} ); } return ( void connect(item)} > {connecting ? t("Working…") : t("Add")} ); } function renderCatalogTile(item: ConnectionCatalogItem, label: string) { const connected = itemConnected(item); const body = ( <> {label} {renderCatalogActions(item, label)} ); const tileStyle = [styles.row, catalogColumns === 2 ? styles.catalogCell : null]; if (connected) { return ( openDetail(item)} style={tileStyle} > {body} ); } return ( {body} ); } function renderDetail(item: ConnectionCatalogItem) { const accounts = accountsFor(item); const key = itemKey(item); const connecting = pending === key; const uninstalling = pending === `uninstall:${key}`; const toolCount = tools.length; return ( {t("Back")} {item.name} void uninstall(item)} > {uninstalling ? t("Working…") : t("Uninstall")} {t("Accounts")} {accounts.map((row) => ( setLabelDrafts((current) => ({ ...current, [row.id]: value })) } onEndEditing={() => void renameAccount(row)} accessibilityLabel={t("Account label")} style={styles.accountLabel} /> void revokeAccount(row)} > {pending === row.id ? t("Working…") : t("Remove")} ))} void connect(item)} style={styles.cardButton} > {connecting ? t("Working…") : t("Add another")} setToolsOpen((open) => !open)} style={styles.toolsToggle} > {toolsLoading ? t("Tools") : toolCount === 1 ? t("1 tool") : t("{count} tools", { count: toolCount })} {toolsOpen ? "˅" : "›"} {toolsOpen ? ( {toolsLoading ? ( {t("Loading tools…")} ) : tools.length === 0 ? ( {t("No tools available.")} ) : ( tools.map((tool) => ( {humanizeToolName(tool.name)} )) )} ) : null} ); } return ( {!detailItem ? ( { setQuery(value); setVisibleCount(CONNECTION_CATALOG_PAGE_SIZE); }} accessibilityLabel={t("Search apps")} placeholder={t("Search apps")} placeholderTextColor={native.tertiaryLabel} autoCapitalize="none" autoCorrect={false} returnKeyType="search" style={styles.input} /> ) : null} {catalogError ? {catalogError} : null} {detailItem ? ( renderDetail(detailItem) ) : ( <> GitHub {github ? ( void removeSource(github)} > {t("Disconnect")} ) : githubOpen ? null : ( setGithubOpen(true)} style={styles.smallButton} > {t("Connect")} )} {!github && githubOpen ? ( <> void Linking.openURL(GITHUB_TOKEN_URL)} style={styles.cardButton} > {t("Create token on GitHub")} void connectGithub()} style={styles.cardButton} > {pending === "github" ? t("Connecting…") : t("Connect")} ) : null} {githubError ? {githubError} : null} {!catalogReady ? : null} {catalogReady && catalog.length === 0 ? ( {t(EMPTY_PLUGIN_CATALOG_MESSAGE)} ) : null} {catalogReady && catalog.length > 0 ? ( {showFeatured ? featuredTiles.map((tile) => { const item = tile.item; const key = item ? itemKey(item) : tile.id; const disabled = tile.missing || !item; if (item && !tile.missing) { return renderCatalogTile(item, tile.label); } return ( {tile.label} {disabled ? ( {t("Not in the plugin catalog")} ) : null} ); }) : null} {renderedApps.map((item) => renderCatalogTile(item, item.name))} ) : null} {catalogReady && catalog.length > 0 && catalogApps.length === 0 && !showFeatured ? ( {t("No apps match your search.")} ) : null} {renderedApps.length < catalogApps.length ? ( setVisibleCount((count) => count + CONNECTION_CATALOG_PAGE_SIZE)} style={styles.smallButton} > {t("Show more")} ) : null} { if (advancedOpen) closeAdvanced(); else setAdvancedOpen(true); }} style={styles.advancedToggle} > {t("Advanced")} › {advancedOpen ? ( {(["mcp", "api", "graphql", "executor", "treg"] as const).map((kind) => ( beginSource(kind)} style={styles.smallButton} > {kind === "treg" ? t("Add Treg") : kind === "executor" ? t("Add Executor") : kind === "mcp" ? t("Add MCP server") : kind === "graphql" ? t("Add GraphQL") : t("Add OpenAPI")} ))} {sourceError ? {sourceError} : null} {sourceKind ? ( {sourceKind === "treg" ? t("Connect Treg") : sourceKind === "executor" ? t("Connect Executor") : sourceKind === "mcp" ? t("Remote MCP server") : sourceKind === "graphql" ? t("GraphQL endpoint") : t("OpenAPI JSON")} {sourceKind !== "treg" ? ( ) : null} {sourceKind !== "treg" && sourceKind !== "executor" ? ( setRequiresAuth((value) => !value)} style={styles.authToggle} > {requiresAuth ? t("Bearer authentication") : t("No authentication")} ) : null} {sourceKind === "treg" || sourceKind === "executor" || requiresAuth ? ( ) : null} void addSource()} style={styles.smallButton} > {pending === "source" ? ( ) : ( {t("Verify and add")} )} setSourceKind(null)} style={styles.smallButton} > {t("Cancel")} ) : null} {t("Tool sources")} {customSources.length === 0 ? ( {t("No custom sources installed.")} ) : null} {customSources.map((source) => ( {source.name} {source.kind.toUpperCase()} · {source.source} void removeSource(source)}> {pending === source.id ? t("Removing…") : t("Remove")} ))} ) : null} )} ); } function createIntegrationsStyles() { const destructive = mobileTokens().destructive; return StyleSheet.create({ screen: { flex: 1, backgroundColor: native.page }, content: { padding: 20, gap: 14 }, explanation: { color: native.secondaryLabel, fontSize: 14, lineHeight: 20 }, section: { color: native.secondaryLabel, fontSize: 14, fontWeight: "600", marginTop: 2 }, smallButton: { minHeight: 42, paddingHorizontal: 14, borderRadius: 12, backgroundColor: native.fill, alignItems: "center", justifyContent: "center", }, cardButton: { alignSelf: "flex-start", minHeight: 42, paddingHorizontal: 14, borderRadius: 12, backgroundColor: native.fillPressed, alignItems: "center", justifyContent: "center", }, buttonLabel: { color: native.label, fontSize: 14, fontWeight: "600" }, card: { padding: 16, borderRadius: 16, backgroundColor: native.fill, gap: 12 }, input: { minHeight: 48, borderRadius: 12, backgroundColor: native.fillPressed, color: native.label, paddingHorizontal: 14, fontSize: 15, }, authToggle: { minHeight: 42, justifyContent: "center" }, catalogGrid: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, catalogStack: { gap: 8 }, catalogCell: { flexGrow: 1, flexBasis: "47%", maxWidth: "49%" }, row: { minHeight: 56, paddingHorizontal: 12, paddingVertical: 12, borderRadius: 14, backgroundColor: native.fill, flexDirection: "row", alignItems: "center", gap: 10, }, logo: { width: LOGO_SIZE, height: LOGO_SIZE, borderRadius: 10, backgroundColor: native.fillPressed, }, logoFallback: { width: LOGO_SIZE, height: LOGO_SIZE, borderRadius: 10, backgroundColor: native.fillPressed, alignItems: "center", justifyContent: "center", }, logoInitial: { color: native.label, fontSize: 14, fontWeight: "600", }, grow: { flex: 1, gap: 3, minWidth: 0 }, title: { color: native.label, fontSize: 15, fontWeight: "600" }, secondary: { color: native.secondaryLabel, fontSize: 13 }, accountActions: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8, }, accountRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8, }, accountLabel: { flex: 1, minHeight: 36, borderRadius: 10, backgroundColor: native.fillPressed, color: native.label, paddingHorizontal: 10, fontSize: 13, }, link: { color: native.label, fontSize: 14, fontWeight: "600" }, remove: { color: destructive, fontSize: 14, fontWeight: "600" }, error: { color: destructive, fontSize: 14 }, detail: { gap: 14 }, detailHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 12, }, detailTitleRow: { flex: 1, flexDirection: "row", alignItems: "center", gap: 10, minWidth: 0 }, backButton: { paddingVertical: 4 }, detailTitle: { flex: 1, color: native.label, fontSize: 17, fontWeight: "600" }, toolsToggle: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8, }, toolsBody: { gap: 8, paddingTop: 4 }, toolName: { color: native.label, fontSize: 14 }, advancedToggle: { marginTop: 8, minHeight: 44, flexDirection: "row", alignItems: "center", justifyContent: "space-between", }, advancedLabel: { color: native.secondaryLabel, fontSize: 14 }, advancedBody: { gap: 14 }, chevron: { color: native.secondaryLabel, fontSize: 18 }, }); }