diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx new file mode 100644 index 0000000..769cc21 --- /dev/null +++ b/src/components/InboxViewer.tsx @@ -0,0 +1,529 @@ +import { useEffect, useState } from "react"; +import { use$ } from "applesauce-react/hooks"; +import { + Mail, + MailOpen, + Lock, + Unlock, + Loader2, + AlertCircle, + CheckCircle2, + Clock, + Radio, + RefreshCw, + Users, + MessageSquare, +} from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { RelayLink } from "@/components/nostr/RelayLink"; +import { UserName } from "@/components/nostr/UserName"; + +import giftWrapService from "@/services/gift-wrap"; +import accounts from "@/services/accounts"; +import { cn } from "@/lib/utils"; +import { formatTimestamp } from "@/hooks/useLocale"; +import type { DecryptStatus } from "@/services/gift-wrap"; + +/** + * InboxViewer - Manage private messages (NIP-17/59 gift wraps) + */ +function InboxViewer() { + const account = use$(accounts.active$); + const settings = use$(giftWrapService.settings$); + const syncStatus = use$(giftWrapService.syncStatus$); + const giftWraps = use$(giftWrapService.giftWraps$); + const decryptStates = use$(giftWrapService.decryptStates$); + const conversations = use$(giftWrapService.conversations$); + const inboxRelays = use$(giftWrapService.inboxRelays$); + + const [isDecryptingAll, setIsDecryptingAll] = useState(false); + + // Initialize service when account changes + useEffect(() => { + if (account) { + giftWrapService.init(account.pubkey, account.signer ?? null); + } + }, [account]); + + // Update signer when it changes + useEffect(() => { + if (account?.signer) { + giftWrapService.setSigner(account.signer); + } + }, [account?.signer]); + + // Calculate counts + const counts = { + pending: 0, + decrypting: 0, + success: 0, + error: 0, + total: giftWraps?.length ?? 0, + }; + + if (decryptStates) { + for (const state of decryptStates.values()) { + switch (state.status) { + case "pending": + counts.pending++; + break; + case "decrypting": + counts.decrypting++; + break; + case "success": + counts.success++; + break; + case "error": + counts.error++; + break; + } + } + } + + const handleToggleEnabled = (checked: boolean) => { + giftWrapService.updateSettings({ enabled: checked }); + }; + + const handleToggleAutoDecrypt = (checked: boolean) => { + giftWrapService.updateSettings({ autoDecrypt: checked }); + }; + + const handleDecryptAll = async () => { + if (!account?.signer) { + toast.error( + "No signer available. Please log in with a signer that supports encryption.", + ); + return; + } + + setIsDecryptingAll(true); + try { + const result = await giftWrapService.decryptAll(); + if (result.success > 0) { + toast.success(`Decrypted ${result.success} messages`); + } + if (result.error > 0) { + toast.error(`Failed to decrypt ${result.error} messages`); + } + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to decrypt messages", + ); + } finally { + setIsDecryptingAll(false); + } + }; + + const handleRefresh = () => { + giftWrapService.startSync(); + }; + + if (!account) { + return ( +
+
+ +

Log in to access private messages

+
+
+ ); + } + + return ( +
+ {/* Settings Section */} +
+
+
+ + Private Messages +
+
+ {syncStatus === "syncing" && ( + + )} + +
+
+ + {/* Enable/Disable Toggle */} +
+ + +
+ + {/* Auto-decrypt Toggle */} +
+ + +
+
+ + {/* Inbox Relays Section */} + {inboxRelays && inboxRelays.length > 0 && ( +
+
+ + DM Inbox Relays (kind 10050) +
+
+ {inboxRelays.map((relay) => ( + + ))} +
+
+ )} + + {inboxRelays && inboxRelays.length === 0 && settings?.enabled && ( +
+
+ + No DM inbox relays configured (kind 10050) +
+
+ )} + + {/* Decrypt Status Section */} + {settings?.enabled && counts.total > 0 && ( +
+
+ Gift Wraps ({counts.total}) +
+ + + +
+
+ + {(counts.pending > 0 || counts.decrypting > 0) && ( +
+ + {counts.pending + counts.decrypting} messages waiting to be + decrypted + + +
+ )} +
+ )} + + {/* Conversations Section */} +
+ {settings?.enabled && conversations && conversations.length > 0 && ( + <> +
+ + Recent Conversations ({conversations.length}) +
+ {conversations.map((conv) => ( + { + // Open chat window - for now just show a toast + // In future, this would open the conversation in a chat viewer + toast.info("Chat viewer coming soon"); + }} + /> + ))} + + )} + + {settings?.enabled && + (!conversations || conversations.length === 0) && + counts.success === 0 && ( +
+ +

No conversations yet

+ {counts.pending > 0 && ( +

+ Decrypt pending messages to see conversations +

+ )} +
+ )} + + {!settings?.enabled && ( +
+ +

Enable gift wrap sync to receive private messages

+
+ )} +
+ + {/* Pending Gift Wraps List (for manual decrypt) */} + {settings?.enabled && !settings.autoDecrypt && counts.pending > 0 && ( + { + try { + await giftWrapService.decrypt(id); + toast.success("Message decrypted"); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Decryption failed", + ); + } + }} + /> + )} +
+ ); +} + +interface StatusBadgeProps { + status: "success" | "pending" | "error"; + count: number; +} + +function StatusBadge({ status, count }: StatusBadgeProps) { + if (count === 0) return null; + + const config = { + success: { + icon: CheckCircle2, + className: "bg-green-500/10 text-green-500 border-green-500/20", + }, + pending: { + icon: Clock, + className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", + }, + error: { + icon: AlertCircle, + className: "bg-red-500/10 text-red-500 border-red-500/20", + }, + }; + + const { icon: Icon, className } = config[status]; + + return ( + + + {count} + + ); +} + +interface ConversationRowProps { + conversation: { + id: string; + participants: string[]; + lastMessage?: { content: string; created_at: number; pubkey: string }; + }; + currentUserPubkey: string; + onClick: () => void; +} + +function ConversationRow({ + conversation, + currentUserPubkey, + onClick, +}: ConversationRowProps) { + // Filter out current user from participants for display + const otherParticipants = conversation.participants.filter( + (p) => p !== currentUserPubkey, + ); + + return ( +
+
+
+ {otherParticipants.length === 1 ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+
+ {otherParticipants.slice(0, 3).map((pubkey, i) => ( + + {i > 0 && , } + + + ))} + {otherParticipants.length > 3 && ( + + +{otherParticipants.length - 3} more + + )} +
+ {conversation.lastMessage && ( +

+ {conversation.lastMessage.content} +

+ )} +
+ {conversation.lastMessage && ( + + {formatTimestamp(conversation.lastMessage.created_at)} + + )} +
+
+ ); +} + +interface PendingGiftWrapsListProps { + decryptStates: + | Map + | undefined; + giftWraps: { id: string; created_at: number }[]; + onDecrypt: (id: string) => Promise; +} + +function PendingGiftWrapsList({ + decryptStates, + giftWraps, + onDecrypt, +}: PendingGiftWrapsListProps) { + const [decryptingIds, setDecryptingIds] = useState>(new Set()); + + const pendingWraps = giftWraps.filter((gw) => { + const state = decryptStates?.get(gw.id); + return state?.status === "pending" || state?.status === "error"; + }); + + if (pendingWraps.length === 0) return null; + + return ( +
+
+ Pending Decryption +
+ {pendingWraps.slice(0, 10).map((gw) => { + const state = decryptStates?.get(gw.id); + const isDecrypting = decryptingIds.has(gw.id); + + return ( +
+
+ {state?.status === "error" ? ( + + + + + +

{state.error || "Decryption failed"}

+
+
+ ) : ( + + )} + + {gw.id.slice(0, 16)}... + + + {formatTimestamp(gw.created_at)} + +
+ +
+ ); + })} + {pendingWraps.length > 10 && ( +
+ And {pendingWraps.length - 10} more... +
+ )} +
+ ); +} + +export default InboxViewer; diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index e43055e..721cc3b 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -43,6 +43,7 @@ const BlossomViewer = lazy(() => import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })), ); const CountViewer = lazy(() => import("./CountViewer")); +const InboxViewer = lazy(() => import("./InboxViewer")); // Loading fallback component function ViewerLoading() { @@ -220,6 +221,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { /> ); break; + case "inbox": + content = ; + break; default: content = (
diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index 111a2ea..ccea41a 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -1,4 +1,4 @@ -import { User, HardDrive, Palette } from "lucide-react"; +import { User, HardDrive, Palette, Mail } from "lucide-react"; import accounts from "@/services/accounts"; import { useProfile } from "@/hooks/useProfile"; import { use$ } from "applesauce-react/hooks"; @@ -158,6 +158,15 @@ export default function UserMenu() { )} + { + addWindow("inbox", {}, "Inbox"); + }} + > + + Private Messages + diff --git a/src/services/db.ts b/src/services/db.ts index 6e9ee79..5ef886c 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -61,6 +61,12 @@ export interface CachedBlossomServerList { updatedAt: number; } +export interface EncryptedContentEntry { + id: string; // Event ID (gift wrap or seal) + plaintext: string; // Decrypted content + savedAt: number; +} + export interface LocalSpell { id: string; // UUID for local-only spells, or event ID for published spells alias?: string; // Optional local-only quick name (e.g., "btc") @@ -98,6 +104,7 @@ class GrimoireDb extends Dexie { blossomServers!: Table; spells!: Table; spellbooks!: Table; + encryptedContent!: Table; constructor(name: string) { super(name); @@ -333,6 +340,21 @@ class GrimoireDb extends Dexie { spells: "&id, alias, createdAt, isPublished, deletedAt", spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", }); + + // Version 16: Add encrypted content cache for gift wraps (NIP-17/59) + this.version(16).stores({ + profiles: "&pubkey", + nip05: "&nip05", + nips: "&id", + relayInfo: "&url", + relayAuthPreferences: "&url", + relayLists: "&pubkey, updatedAt", + relayLiveness: "&url", + blossomServers: "&pubkey, updatedAt", + spells: "&id, alias, createdAt, isPublished, deletedAt", + spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt", + encryptedContent: "&id, savedAt", + }); } } @@ -360,4 +382,23 @@ export const relayLivenessStorage = { }, }; +/** + * Dexie storage adapter for encrypted content persistence (NIP-17/59 gift wraps) + * Implements the EncryptedContentCache interface expected by applesauce-common + */ +export const encryptedContentStorage = { + async getItem(id: string): Promise { + const entry = await db.encryptedContent.get(id); + return entry?.plaintext ?? null; + }, + + async setItem(id: string, plaintext: string): Promise { + await db.encryptedContent.put({ + id, + plaintext, + savedAt: Date.now(), + }); + }, +}; + export default db; diff --git a/src/services/gift-wrap.ts b/src/services/gift-wrap.ts new file mode 100644 index 0000000..12b3805 --- /dev/null +++ b/src/services/gift-wrap.ts @@ -0,0 +1,470 @@ +import { BehaviorSubject, Subject, Subscription, filter, map } from "rxjs"; +import { kinds } from "applesauce-core/helpers/event"; +import { + isGiftWrapUnlocked, + getGiftWrapRumor, + unlockGiftWrap, +} from "applesauce-common/helpers/gift-wrap"; +import { + getConversationIdentifierFromMessage, + getConversationParticipants, +} from "applesauce-common/helpers/messages"; +import { persistEncryptedContent } from "applesauce-common/helpers/encrypted-content-cache"; +import type { NostrEvent } from "@/types/nostr"; +import type { ISigner } from "applesauce-signers"; +import eventStore from "./event-store"; +import pool from "./relay-pool"; +import { encryptedContentStorage } from "./db"; + +/** Kind 10050: DM relay list (NIP-17) */ +const DM_RELAY_LIST_KIND = 10050; + +/** Kind 14: Private direct message (NIP-17) */ +const PRIVATE_DM_KIND = 14; + +/** Rumor is an unsigned event - used for gift wrap contents */ +interface Rumor { + id: string; + pubkey: string; + created_at: number; + kind: number; + tags: string[][]; + content: string; +} + +/** Status of a gift wrap decryption */ +export type DecryptStatus = "pending" | "decrypting" | "success" | "error"; + +export interface DecryptState { + status: DecryptStatus; + error?: string; + decryptedAt?: number; +} + +export interface Conversation { + id: string; + participants: string[]; + lastMessage?: Rumor; + lastGiftWrap?: NostrEvent; + unreadCount?: number; +} + +/** Settings for the inbox service */ +export interface InboxSettings { + enabled: boolean; + autoDecrypt: boolean; +} + +const SETTINGS_KEY = "grimoire-inbox-settings"; + +function loadSettings(): InboxSettings { + try { + const saved = localStorage.getItem(SETTINGS_KEY); + if (saved) return JSON.parse(saved); + } catch { + // ignore + } + return { enabled: false, autoDecrypt: false }; +} + +function saveSettings(settings: InboxSettings) { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); +} + +class GiftWrapService { + /** Current user's pubkey */ + private userPubkey: string | null = null; + /** Current signer for decryption */ + private signer: ISigner | null = null; + + /** Map of gift wrap ID -> decrypt state */ + private decryptStates = new Map(); + /** Observable for decrypt state changes */ + readonly decryptStates$ = new BehaviorSubject>( + new Map(), + ); + + /** All gift wraps for the current user */ + private giftWraps: NostrEvent[] = []; + readonly giftWraps$ = new BehaviorSubject([]); + + /** Conversations grouped by participants */ + readonly conversations$ = new BehaviorSubject([]); + + /** Inbox relays (kind 10050) */ + readonly inboxRelays$ = new BehaviorSubject([]); + + /** Settings */ + readonly settings$ = new BehaviorSubject(loadSettings()); + + /** Sync status */ + readonly syncStatus$ = new BehaviorSubject< + "idle" | "syncing" | "error" | "disabled" + >("idle"); + + /** Event emitter for decrypt events */ + readonly decryptEvent$ = new Subject<{ + giftWrapId: string; + status: DecryptStatus; + rumor?: Rumor; + error?: string; + }>(); + + private subscriptions: Subscription[] = []; + private relaySubscription: Subscription | null = null; + private persistenceCleanup: (() => void) | null = null; + + constructor() { + // Start encrypted content persistence + this.persistenceCleanup = persistEncryptedContent( + eventStore, + encryptedContentStorage, + ); + } + + /** Initialize the service with user pubkey and signer */ + init(pubkey: string, signer: ISigner | null) { + this.cleanup(); + this.userPubkey = pubkey; + this.signer = signer; + this.decryptStates.clear(); + this.decryptStates$.next(new Map()); + + // Load inbox relays (kind 10050) + this.loadInboxRelays(); + + // If enabled, start syncing + if (this.settings$.value.enabled) { + this.startSync(); + } + } + + /** Update settings */ + updateSettings(settings: Partial) { + const newSettings = { ...this.settings$.value, ...settings }; + this.settings$.next(newSettings); + saveSettings(newSettings); + + // Handle enabled state change + if (settings.enabled !== undefined) { + if (settings.enabled) { + this.startSync(); + } else { + this.stopSync(); + } + } + + // Handle auto-decrypt change + if (settings.autoDecrypt && this.signer) { + this.autoDecryptPending(); + } + } + + /** Load inbox relays from kind 10050 event */ + private async loadInboxRelays() { + if (!this.userPubkey) return; + + const sub = eventStore + .replaceable(DM_RELAY_LIST_KIND, this.userPubkey) + .pipe( + filter((e) => e !== undefined), + map((event) => { + if (!event) return []; + // Extract relay URLs from tags + return event.tags + .filter((tag) => tag[0] === "relay") + .map((tag) => tag[1]) + .filter(Boolean); + }), + ) + .subscribe((relays) => { + this.inboxRelays$.next(relays); + }); + + this.subscriptions.push(sub); + } + + /** Start syncing gift wraps from inbox relays */ + startSync() { + if (!this.userPubkey) { + this.syncStatus$.next("disabled"); + return; + } + + const relays = this.inboxRelays$.value; + if (relays.length === 0) { + // Use default relays if no inbox relays set + this.syncStatus$.next("syncing"); + this.subscribeToGiftWraps([]); + } else { + this.syncStatus$.next("syncing"); + this.subscribeToGiftWraps(relays); + } + } + + /** Stop syncing */ + stopSync() { + this.syncStatus$.next("disabled"); + if (this.relaySubscription) { + this.relaySubscription.unsubscribe(); + this.relaySubscription = null; + } + } + + /** Subscribe to gift wraps for current user */ + private subscribeToGiftWraps(relays: string[]) { + if (!this.userPubkey) return; + + // Subscribe to gift wraps addressed to this user + const reqFilter = { + kinds: [kinds.GiftWrap], + "#p": [this.userPubkey], + }; + + // Use timeline observable for reactive updates + const sub = eventStore + .timeline(reqFilter) + .pipe(map((events) => events.sort((a, b) => b.created_at - a.created_at))) + .subscribe((giftWraps) => { + this.giftWraps = giftWraps; + this.giftWraps$.next(giftWraps); + + // Update decrypt states for new gift wraps + for (const gw of giftWraps) { + if (!this.decryptStates.has(gw.id)) { + const isUnlocked = isGiftWrapUnlocked(gw); + this.decryptStates.set(gw.id, { + status: isUnlocked ? "success" : "pending", + decryptedAt: isUnlocked ? Date.now() : undefined, + }); + } + } + this.decryptStates$.next(new Map(this.decryptStates)); + + // Update conversations + this.updateConversations(); + + // Auto-decrypt if enabled + if (this.settings$.value.autoDecrypt && this.signer) { + this.autoDecryptPending(); + } + + this.syncStatus$.next("idle"); + }); + + this.relaySubscription = sub; + + // Also request from relays + if (relays.length > 0) { + pool.request(relays, [reqFilter], { eventStore }).subscribe({ + next: () => { + // Events are automatically added to eventStore via the options + }, + error: (err) => { + console.warn(`[GiftWrap] Error fetching from relays:`, err); + }, + }); + } + } + + /** Update conversations from decrypted gift wraps */ + private updateConversations() { + const conversationMap = new Map(); + + for (const gw of this.giftWraps) { + if (!isGiftWrapUnlocked(gw)) continue; + + const rumor = getGiftWrapRumor(gw); + if (!rumor || rumor.kind !== PRIVATE_DM_KIND) continue; + + const convId = getConversationIdentifierFromMessage(rumor); + const existing = conversationMap.get(convId); + + if ( + !existing || + rumor.created_at > (existing.lastMessage?.created_at ?? 0) + ) { + conversationMap.set(convId, { + id: convId, + participants: getConversationParticipants(rumor), + lastMessage: rumor, + lastGiftWrap: gw, + }); + } + } + + const conversations = Array.from(conversationMap.values()).sort( + (a, b) => + (b.lastMessage?.created_at ?? 0) - (a.lastMessage?.created_at ?? 0), + ); + + this.conversations$.next(conversations); + } + + /** Decrypt a single gift wrap */ + async decrypt(giftWrapId: string): Promise { + if (!this.signer) { + throw new Error("No signer available"); + } + + const gw = this.giftWraps.find((g) => g.id === giftWrapId); + if (!gw) { + throw new Error("Gift wrap not found"); + } + + // Check if already decrypted + if (isGiftWrapUnlocked(gw)) { + return getGiftWrapRumor(gw) ?? null; + } + + // Update state to decrypting + this.decryptStates.set(giftWrapId, { status: "decrypting" }); + this.decryptStates$.next(new Map(this.decryptStates)); + + try { + const rumor = await unlockGiftWrap(gw, this.signer); + + // Update state to success + this.decryptStates.set(giftWrapId, { + status: "success", + decryptedAt: Date.now(), + }); + this.decryptStates$.next(new Map(this.decryptStates)); + + // Emit decrypt event + this.decryptEvent$.next({ + giftWrapId, + status: "success", + rumor, + }); + + // Update conversations + this.updateConversations(); + + return rumor; + } catch (err) { + const error = err instanceof Error ? err.message : "Unknown error"; + + // Update state to error + this.decryptStates.set(giftWrapId, { status: "error", error }); + this.decryptStates$.next(new Map(this.decryptStates)); + + // Emit decrypt event + this.decryptEvent$.next({ + giftWrapId, + status: "error", + error, + }); + + return null; + } + } + + /** Decrypt all pending gift wraps */ + async decryptAll(): Promise<{ success: number; error: number }> { + if (!this.signer) { + throw new Error("No signer available"); + } + + let success = 0; + let error = 0; + + const pending = this.giftWraps.filter( + (gw) => + !isGiftWrapUnlocked(gw) && + this.decryptStates.get(gw.id)?.status !== "decrypting", + ); + + for (const gw of pending) { + try { + await this.decrypt(gw.id); + success++; + } catch { + error++; + } + } + + return { success, error }; + } + + /** Auto-decrypt pending gift wraps (called when auto-decrypt is enabled) */ + private async autoDecryptPending() { + if (!this.signer || !this.settings$.value.autoDecrypt) return; + + const pending = this.giftWraps.filter((gw) => { + const state = this.decryptStates.get(gw.id); + return state?.status === "pending"; + }); + + for (const gw of pending) { + try { + await this.decrypt(gw.id); + } catch { + // Errors are already tracked in decryptStates + } + } + } + + /** Get counts by status */ + getCounts(): { + pending: number; + success: number; + error: number; + total: number; + } { + let pending = 0; + let success = 0; + let error = 0; + + for (const state of this.decryptStates.values()) { + switch (state.status) { + case "pending": + case "decrypting": + pending++; + break; + case "success": + success++; + break; + case "error": + error++; + break; + } + } + + return { pending, success, error, total: this.giftWraps.length }; + } + + /** Update signer (when user logs in/out or changes) */ + setSigner(signer: ISigner | null) { + this.signer = signer; + + // Auto-decrypt if enabled and signer is available + if (signer && this.settings$.value.autoDecrypt) { + this.autoDecryptPending(); + } + } + + /** Cleanup subscriptions */ + cleanup() { + this.subscriptions.forEach((s) => s.unsubscribe()); + this.subscriptions = []; + if (this.relaySubscription) { + this.relaySubscription.unsubscribe(); + this.relaySubscription = null; + } + } + + /** Full destroy (call when app unmounts) */ + destroy() { + this.cleanup(); + if (this.persistenceCleanup) { + this.persistenceCleanup(); + this.persistenceCleanup = null; + } + } +} + +// Singleton instance +const giftWrapService = new GiftWrapService(); + +export default giftWrapService; diff --git a/src/types/app.ts b/src/types/app.ts index 09a1148..e581461 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -21,6 +21,7 @@ export type AppId = | "spells" | "spellbooks" | "blossom" + | "inbox" | "win"; export interface WindowInstance { diff --git a/src/types/man.ts b/src/types/man.ts index c566eed..4cf7f7d 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -641,6 +641,18 @@ export const manPages: Record = { category: "Nostr", defaultProps: {}, }, + inbox: { + name: "inbox", + section: "1", + synopsis: "inbox", + description: + "Manage private messages using NIP-17/NIP-59 gift wraps. View and configure your DM inbox relays (kind 10050), enable/disable gift wrap sync, track decryption status, and browse recent conversations. Supports auto-decrypt mode for hands-free message decryption.", + examples: ["inbox Open the private message inbox manager"], + seeAlso: ["chat", "profile", "conn"], + appId: "inbox", + category: "Nostr", + defaultProps: {}, + }, blossom: { name: "blossom", section: "1",