diff --git a/package-lock.json b/package-lock.json index 0f99c46..41dffc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2767,7 +2767,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2781,7 +2780,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2795,7 +2793,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2809,7 +2806,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2823,7 +2819,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2837,7 +2832,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2851,7 +2845,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2865,7 +2858,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2879,7 +2871,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2893,7 +2884,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2907,7 +2897,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2921,7 +2910,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2935,7 +2923,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2949,7 +2936,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2963,7 +2949,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2977,7 +2962,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2991,7 +2975,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3005,7 +2988,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3019,7 +3001,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3033,7 +3014,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/src/components/ProfileDataCard.tsx b/src/components/ProfileDataCard.tsx index 4478b13..0e7a43a 100644 --- a/src/components/ProfileDataCard.tsx +++ b/src/components/ProfileDataCard.tsx @@ -2,7 +2,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; -import { CalendarIcon, UserIcon, TagIcon, BookmarkIcon, RadioIcon, PinIcon, Users2Icon, HashIcon, SmileIcon, SearchIcon, MessageCircleIcon, BanIcon, DatabaseIcon, Share2Icon, ShieldCheckIcon } from 'lucide-react'; +import { CalendarIcon, UserIcon, TagIcon, BookmarkIcon, RadioIcon, PinIcon, Users2Icon, HashIcon, SmileIcon, SearchIcon, MessageCircleIcon, BanIcon, DatabaseIcon, Share2Icon, ShieldCheckIcon, ServerIcon } from 'lucide-react'; import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; import type { ProfileData } from '@/hooks/useProfileData'; @@ -63,6 +63,8 @@ export function ProfileDataCard({ profileData, relayUrl, onSelectEvents, selecte return { name: 'Emoji List', icon: SmileIcon, color: 'bg-pink-500/20 border-pink-500/30 text-pink-400' }; case 10050: return { name: 'DM Relays', icon: MessageCircleIcon, color: 'bg-violet-500/20 border-violet-500/30 text-violet-400' }; + case 10063: + return { name: 'Blossom Servers', icon: ServerIcon, color: 'bg-emerald-500/20 border-emerald-500/30 text-emerald-400' }; case 10086: return { name: 'Indexer Relays', icon: DatabaseIcon, color: 'bg-blue-500/20 border-blue-500/30 text-blue-400' }; case 10087: @@ -117,6 +119,9 @@ export function ProfileDataCard({ profileData, relayUrl, onSelectEvents, selecte } else if (event.kind === 10050) { const dmRelayTags = event.tags.filter(tag => tag[0] === 'relay'); details = `${dmRelayTags.length} DM relays`; + } else if (event.kind === 10063) { + const serverTags = event.tags.filter(tag => tag[0] === 'server'); + details = `${serverTags.length} Blossom server${serverTags.length !== 1 ? 's' : ''}`; } else if (event.kind === 10006) { const blockedRelayTags = event.tags.filter(tag => tag[0] === 'relay'); details = `${blockedRelayTags.length} blocked relays`; diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index 7eb2bd3..9a31ef1 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -6,7 +6,7 @@ import { QRCodeSVG } from 'qrcode.react'; import { Shield, Upload, AlertTriangle, UserPlus, KeyRound, Sparkles, Link, Loader2, Copy, Check, ChevronDown, ChevronUp, ExternalLink } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { useLoginActions, generateNostrConnectParams, generateNostrConnectURI, NostrConnectParams } from '@/hooks/useLoginActions'; @@ -266,7 +266,9 @@ const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onS className={cn("max-w-[95vw] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl overflow-y-scroll")} > - + + Sign In or Sign Up + Sign up or log in to continue diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 69d63a4..6cd0c11 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -4,7 +4,7 @@ import { Command as CommandPrimitive } from "cmdk" import { Search } from "lucide-react" import { cn } from "@/lib/utils" -import { Dialog, DialogContent } from "@/components/ui/dialog" +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog" const Command = React.forwardRef< React.ElementRef, @@ -27,6 +27,7 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => { return ( + Command Menu {children} diff --git a/src/hooks/useProfileData.ts b/src/hooks/useProfileData.ts index 0a3f2cc..e2243d1 100644 --- a/src/hooks/useProfileData.ts +++ b/src/hooks/useProfileData.ts @@ -16,6 +16,7 @@ export interface ProfileData { searchRelays?: NostrEvent; dmRelays?: NostrEvent; blockedRelays?: NostrEvent; + blossomServers?: NostrEvent; indexerRelays?: NostrEvent; proxyRelays?: NostrEvent; broadcastRelays?: NostrEvent; @@ -45,7 +46,7 @@ export function useProfileData(pubkey: string | undefined, relayUrl: string) { // If the global instance works, use it for all queries const allEvents = await nostr.query( [{ - kinds: [0, 3, 10000, 10001, 10002, 10003, 10004, 10006, 10007, 10015, 10030, 10050, 10086, 10087, 10088, 10089], + kinds: [0, 3, 10000, 10001, 10002, 10003, 10004, 10006, 10007, 10015, 10030, 10050, 10063, 10086, 10087, 10088, 10089], authors: [pubkey], limit: 50 }], @@ -92,6 +93,9 @@ export function useProfileData(pubkey: string | undefined, relayUrl: string) { case 10050: profileData.dmRelays = event; break; + case 10063: + profileData.blossomServers = event; + break; case 10086: profileData.indexerRelays = event; break; @@ -125,7 +129,7 @@ export function useProfileData(pubkey: string | undefined, relayUrl: string) { }); const events = await customPool.query( - [{ kinds: [0, 3, 10000, 10001, 10002, 10003, 10004, 10006, 10007, 10015, 10030, 10050, 10086, 10087, 10088, 10089], authors: [pubkey], limit: 50 }], + [{ kinds: [0, 3, 10000, 10001, 10002, 10003, 10004, 10006, 10007, 10015, 10030, 10050, 10063, 10086, 10087, 10088, 10089], authors: [pubkey], limit: 50 }], { signal } ); @@ -170,6 +174,9 @@ export function useProfileData(pubkey: string | undefined, relayUrl: string) { case 10050: profileData.dmRelays = event; break; + case 10063: + profileData.blossomServers = event; + break; case 10086: profileData.indexerRelays = event; break; diff --git a/src/hooks/useSyncProfile.test.tsx b/src/hooks/useSyncProfile.test.tsx new file mode 100644 index 0000000..514e8e9 --- /dev/null +++ b/src/hooks/useSyncProfile.test.tsx @@ -0,0 +1,227 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useSyncProfile } from './useSyncProfile'; +import type { NostrEvent } from '@nostrify/nostrify'; +import * as Nostrify from '@nostrify/nostrify'; + +vi.mock('@/hooks/useAppContext'); +vi.mock('@/hooks/useCurrentUser'); +vi.mock('@/hooks/useToast'); +vi.mock('@nostrify/nostrify', () => ({ + NPool: vi.fn(), + NRelay1: vi.fn(), +})); + +import { useAppContext } from '@/hooks/useAppContext'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useToast } from '@/hooks/useToast'; + +const mockToast = vi.fn(); +const mockAppContext = { + config: { + theme: 'light', + relayUrl: 'wss://default.relay.test', + }, + updateConfig: vi.fn(), + presetRelays: [], +}; + +const mockUser = { + pubkey: 'testpubkey123', + signer: { + signEvent: vi.fn().mockImplementation((event: any) => + Promise.resolve({ ...event, pubkey: 'testpubkey123', id: 'mockid', sig: 'mocksig' }) + ), + }, +}; + +const mockEvent1: NostrEvent = { + id: 'event1id123456789', + pubkey: 'testpubkey123', + created_at: 1234567890, + kind: 0, + tags: [], + content: 'test content', + sig: 'mocksig', +}; + +describe('useSyncProfile', () => { + let queryClient: QueryClient; + + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { retry: false }, + }, + }); + + (useAppContext as any).mockReturnValue(mockAppContext); + (useCurrentUser as any).mockReturnValue({ user: mockUser }); + (useToast as any).mockReturnValue({ toast: mockToast }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + it('should successfully publish events to target relay', async () => { + const mockEventResult = vi.fn().mockResolvedValue(undefined); + (Nostrify.NPool as any).mockImplementation((opts: any) => ({ + event: mockEventResult, + })); + (Nostrify.NRelay1 as any).mockImplementation(() => ({})); + + const { result } = renderHook(() => useSyncProfile(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync({ + events: [mockEvent1], + targetRelay: 'wss://target.relay.test', + }); + }); + + expect(Nostrify.NPool).toHaveBeenCalledWith( + expect.objectContaining({ + eventRouter: expect.any(Function), + reqRouter: expect.any(Function), + }) + ); + + expect(mockEventResult).toHaveBeenCalledWith( + mockEvent1, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Sync Complete' }) + ); + }); + + it('should fall back to default relay when target relay fails', async () => { + let callCount = 0; + const mockEventResult = vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(new Error('Target relay timeout')); + } + return Promise.resolve(undefined); + }); + + (Nostrify.NPool as any).mockImplementation(() => ({ + event: mockEventResult, + })); + (Nostrify.NRelay1 as any).mockImplementation(() => ({})); + + const { result } = renderHook(() => useSyncProfile(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync({ + events: [mockEvent1], + targetRelay: 'wss://target.relay.test', + }); + }); + + expect(Nostrify.NPool).toHaveBeenCalledTimes(2); + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Sync Complete' }) + ); + }); + + it('should fail if both target and default relay fail', async () => { + const mockEventResult = vi.fn().mockRejectedValue(new Error('Relay connection failed')); + (Nostrify.NPool as any).mockImplementation(() => ({ + event: mockEventResult, + })); + (Nostrify.NRelay1 as any).mockImplementation(() => ({})); + + const { result } = renderHook(() => useSyncProfile(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync({ + events: [mockEvent1], + targetRelay: 'wss://target.relay.test', + }); + }); + + expect(Nostrify.NPool).toHaveBeenCalledTimes(2); + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Sync Failed' }) + ); + }); + + it('should configure NRelay1 with NIP-42 AUTH callback', async () => { + let capturedRelayUrl = ''; + let capturedAuthCallback: ((challenge: string) => Promise) | undefined; + + (Nostrify.NRelay1 as any).mockImplementation((url: string, opts: any) => { + capturedRelayUrl = url; + if (opts?.auth) { + capturedAuthCallback = opts.auth; + } + return { url, opts }; + }); + + const mockEventResult = vi.fn().mockResolvedValue(undefined); + (Nostrify.NPool as any).mockImplementation((_opts: any) => { + if (_opts.open) { + _opts.open('wss://target.relay.test'); + } + return { event: mockEventResult }; + }); + + const { result } = renderHook(() => useSyncProfile(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync({ + events: [mockEvent1], + targetRelay: 'wss://target.relay.test', + }); + }); + + expect(capturedRelayUrl).toBe('wss://target.relay.test'); + expect(capturedAuthCallback).toBeDefined(); + + if (capturedAuthCallback) { + const authEvent = await capturedAuthCallback('test-challenge-123'); + expect(authEvent.kind).toBe(22242); + expect(authEvent.tags).toContainEqual(['relay', 'wss://target.relay.test']); + expect(authEvent.tags).toContainEqual(['challenge', 'test-challenge-123']); + expect(mockUser.signer.signEvent).toHaveBeenCalled(); + } + }); + + it('should reject invalid target relay URLs', async () => { + const { result } = renderHook(() => useSyncProfile(), { wrapper }); + + await expect( + act(async () => { + await result.current.mutateAsync({ + events: [mockEvent1], + targetRelay: 'invalid-url', + }); + }) + ).rejects.toThrow('Invalid target relay URL'); + }); + + it('should throw error if no events are provided', async () => { + const { result } = renderHook(() => useSyncProfile(), { wrapper }); + + await expect( + act(async () => { + await result.current.mutateAsync({ + events: [], + targetRelay: 'wss://target.relay.test', + }); + }) + ).rejects.toThrow('No events to sync'); + }); +}); diff --git a/src/hooks/useSyncProfile.ts b/src/hooks/useSyncProfile.ts index e0be05d..0648c4e 100644 --- a/src/hooks/useSyncProfile.ts +++ b/src/hooks/useSyncProfile.ts @@ -1,68 +1,174 @@ import { useMutation } from '@tanstack/react-query'; -import { useNostr } from '@nostrify/react'; import { NPool, NRelay1 } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify'; import { useToast } from '@/hooks/useToast'; +import { useAppContext } from '@/hooks/useAppContext'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; export interface SyncProfileData { events: NostrEvent[]; targetRelay: string; } +// Common Nostr event kinds mapping for readable logs +const KIND_NAMES: Record = { + 0: 'User Metadata', + 1: 'Short Text Note', + 2: 'Recommendation', + 3: 'Contact List', + 4: 'Encrypted Direct Message', + 6: 'Repost', + 7: 'Reaction', + 9: 'Event Deletion Request', + 16: 'Generic Repost', + 1111: 'Comment', + 10000: 'Mute List', + 10001: 'Pin List', + 10002: 'Relay List Metadata', + 10005: 'Bookmark List', + 10007: 'Relay Sets List', + 10015: 'Interest List', + 10050: 'DM Relays', + 10063: 'Blossom Server List', + 10086: 'User Statuses', + 22242: 'Relay Auth', +}; + export function useSyncProfile() { - const { nostr } = useNostr(); const { toast } = useToast(); + const { config } = useAppContext(); + const { user } = useCurrentUser(); return useMutation({ mutationFn: async ({ events, targetRelay }: SyncProfileData) => { - console.log('๐Ÿš€ Starting sync:', { eventCount: events.length, targetRelay }); + console.log('๐Ÿš€ Starting sync:', { eventCount: events.length, targetRelay, defaultRelay: config.relayUrl }); if (events.length === 0) { throw new Error('No events to sync'); } - // Try using global nostr instance first (like we did for queries) - console.log('๐Ÿงช Testing global nostr for publishing...'); + if (!user) { + throw new Error('User must be logged in to sync profile'); + } + + if (!targetRelay || (!targetRelay.startsWith('wss://') && !targetRelay.startsWith('ws://'))) { + throw new Error('Invalid target relay URL'); + } + let successCount = 0; let errorCount = 0; - const results: Array<{ event: NostrEvent; success: boolean; error?: unknown }> = []; + const results: Array<{ event: NostrEvent; success: boolean; usedRelay: string; error?: unknown }> = []; - // Try global nostr first - for (const event of events) { - try { - console.log(`๐Ÿ“ค Publishing event ${event.kind} (${event.id.slice(0, 8)}...) via global nostr`); - await nostr.event(event, { signal: AbortSignal.timeout(15000) }); - console.log(`โœ… Successfully published event ${event.kind}`); - results.push({ event, success: true }); - successCount++; - } catch (globalError) { - console.warn(`โš ๏ธ Global nostr failed for event ${event.kind}:`, globalError); - - // Fallback to custom pool for this specific event - try { - console.log(`๐Ÿ”„ Falling back to custom pool for event ${event.kind}`); - const customPool = new NPool({ - open(url: string) { - console.log('๐Ÿ”Œ Opening custom connection for sync to:', url); - return new NRelay1(url); - }, - reqRouter() { - return new Map([[targetRelay, []]]); - }, - eventRouter() { - return [targetRelay]; + const createPool = (relayUrl: string) => { + return new NPool({ + open(url: string) { + console.log(`๐Ÿ”Œ Opening connection to relay: ${url}`); + return new NRelay1(url, { + auth: async (challenge: string) => { + if (!user?.signer) { + throw new Error('No signer available for relay authentication (NIP-42)'); + } + console.log(`๐Ÿ” Responding to NIP-42 AUTH challenge from ${url}`); + const event: Omit = { + kind: 22242, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['relay', url], + ['challenge', challenge], + ], + content: '', + }; + return await user.signer.signEvent(event); }, }); + }, + reqRouter() { + return new Map([[relayUrl, []]]); + }, + eventRouter() { + return [relayUrl]; + }, + }); + }; + + const targetPool = createPool(targetRelay); + const fallbackPool = config.relayUrl !== targetRelay ? createPool(config.relayUrl) : null; + + const publishPromises = events.map(async (event) => { + let eventSuccess = false; + let eventError: unknown = null; + let usedRelay = ''; + + // Determine a readable description for the event log + let description = ''; + + if (event.kind === 0) { + try { + const metadata = JSON.parse(event.content); + if (metadata.display_name || metadata.name) { + description = metadata.display_name || metadata.name; + } + } catch { + // ignore JSON parsing errors + } + } + + if (!description) { + description = event.tags.find(t => t[0] === 'alt')?.[1] || ''; + } + + if (!description && event.content.trim()) { + const snippet = event.content.trim().slice(0, 40).replace(/\n/g, ' '); + description = snippet + (event.content.trim().length > 40 ? '...' : ''); + } + + const kindName = KIND_NAMES[event.kind] || `Kind ${event.kind}`; + const eventLabel = description ? `"${description}" => ${kindName}` : kindName; + + // 1. Try target relay first + try { + console.log(`๐Ÿ“ค Publishing ${eventLabel} (${event.id.slice(0, 8)}...) to TARGET relay: ${targetRelay}`); + await targetPool.event(event, { signal: AbortSignal.timeout(15000) }); + console.log(`โœ… Successfully published ${eventLabel} (${event.id.slice(0, 8)}...) to TARGET relay: ${targetRelay}`); + eventSuccess = true; + usedRelay = targetRelay; + } catch (targetError) { + console.warn(`โš ๏ธ TARGET relay failed for ${eventLabel} (${event.id.slice(0, 8)}...) (${targetRelay}):`, targetError); + + // 2. Fallback to default relay if different from target + if (fallbackPool) { + try { + console.log(`๐Ÿ”„ Falling back to DEFAULT relay for ${eventLabel}: ${config.relayUrl}`); + await fallbackPool.event(event, { signal: AbortSignal.timeout(15000) }); + console.log(`โœ… Successfully published ${eventLabel} to DEFAULT relay: ${config.relayUrl}`); + eventSuccess = true; + usedRelay = config.relayUrl; + } catch (fallbackError) { + console.error(`โŒ Both TARGET (${targetRelay}) and DEFAULT (${config.relayUrl}) relays failed for ${eventLabel}:`, { targetError, fallbackError }); + eventError = fallbackError; + } + } else { + eventError = targetError; + } + } + + return { event, success: eventSuccess, usedRelay, error: eventError }; + }); + + const settledResults = await Promise.allSettled(publishPromises); - await customPool.event(event, { signal: AbortSignal.timeout(15000) }); - console.log(`โœ… Custom pool succeeded for event ${event.kind}`); - results.push({ event, success: true }); + for (const result of settledResults) { + if (result.status === 'fulfilled') { + const res = result.value; + if (res.success) { successCount++; - } catch (customError) { - console.error(`โŒ Both methods failed for event ${event.kind}:`, { globalError, customError }); - results.push({ event, success: false, error: customError }); + } else { errorCount++; } + results.push(res); + } else { + errorCount++; + console.error('Unexpected promise rejection during event publishing:', result.reason); } } @@ -92,7 +198,7 @@ export function useSyncProfile() { } else { toast({ title: "Sync Failed", - description: `Failed to sync any events to the target relay.`, + description: `Failed to sync any events.`, variant: "destructive", }); } diff --git a/src/pages/ProfileSync.tsx b/src/pages/ProfileSync.tsx index 9a9b921..be927d0 100644 --- a/src/pages/ProfileSync.tsx +++ b/src/pages/ProfileSync.tsx @@ -221,7 +221,7 @@ export function ProfileSync() {

What can you sync?

- {['Profile', 'Contacts', 'Bookmarks', 'Relay Lists', 'Mute Lists', 'Communities', 'Interests', 'Emojis', 'DM Relays', 'Search Relays'].map((item) => ( + {['Profile', 'Contacts', 'Bookmarks', 'Relay Lists', 'Mute Lists', 'Communities', 'Interests', 'Emojis', 'DM Relays', 'Search Relays', 'Blossom Servers'].map((item) => ( {item} @@ -298,7 +298,7 @@ export function ProfileSync() { This tool syncs profile metadata (kind 0), contact lists (kind 3), mute lists (kind 10000), pinned notes (kind 10001), relay lists (kind 10002), bookmarks (kind 10003), communities (kind 10004), search relays (kind 10007), interests (kind 10015), emoji lists (kind 10030), - and DM relays (kind 10050) between relays. + DM relays (kind 10050), and Blossom server lists (kind 10063) between relays.