From 392d702760e0ef621b24938e122b4f0929c38b08 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 17:45:11 +0000 Subject: [PATCH 1/8] feat: add Blossom server list sync (kind 10063) Include kind 10063 (BUD-03 user server list) in profile sync operations. Users' preferred Blossom media servers are now fetched, displayed, and synced alongside other profile metadata. Closes derekross/syncstr#1 --- src/components/ProfileDataCard.tsx | 7 ++++++- src/hooks/useProfileData.ts | 11 +++++++++-- src/pages/ProfileSync.tsx | 4 ++-- 3 files changed, 17 insertions(+), 5 deletions(-) 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/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/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. From c9637174c60f39cf52e18d9e99246cc6b2324d1a Mon Sep 17 00:00:00 2001 From: Andrii Dvorzhak <20891922+advorzhak@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:43:07 +0300 Subject: [PATCH 2/8] fix: honor target relay in profile sync with NIP-42 auth and fallback - Previously, useSyncProfile attempted to publish via the global nostr pool first, ignoring the user-selected target relay. - Updated to exclusively use a dedicated NPool configured with an eventRouter targeting the selected relay. - Implemented NIP-42 AUTH callback support in NRelay1 to automatically authenticate with paid/protected relays. - Added fallback logic to the default relay if the target relay fails. - Added comprehensive unit tests for successful sync, fallback, failure, NIP-42 auth, and input validation. - Enhanced logging to clearly indicate which relay is receiving each event. --- package-lock.json | 20 --- src/hooks/useSyncProfile.test.tsx | 227 ++++++++++++++++++++++++++++++ src/hooks/useSyncProfile.ts | 118 +++++++++++----- 3 files changed, 306 insertions(+), 59 deletions(-) create mode 100644 src/hooks/useSyncProfile.test.tsx 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/hooks/useSyncProfile.test.tsx b/src/hooks/useSyncProfile.test.tsx new file mode 100644 index 0000000..9721dad --- /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..8f81d00 100644 --- a/src/hooks/useSyncProfile.ts +++ b/src/hooks/useSyncProfile.ts @@ -1,8 +1,9 @@ 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[]; @@ -10,60 +11,99 @@ export interface SyncProfileData { } 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 (!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]; + }, + }); + }; + + for (const event of events) { + let eventSuccess = false; + let eventError: unknown = null; + let usedRelay = ''; - await customPool.event(event, { signal: AbortSignal.timeout(15000) }); - console.log(`โœ… Custom pool succeeded for event ${event.kind}`); - results.push({ event, success: true }); - successCount++; - } catch (customError) { - console.error(`โŒ Both methods failed for event ${event.kind}:`, { globalError, customError }); - results.push({ event, success: false, error: customError }); - errorCount++; + // 1. Try target relay first + try { + console.log(`๐Ÿ“ค Publishing event ${event.kind} (${event.id.slice(0, 8)}...) to TARGET relay: ${targetRelay}`); + const targetPool = createPool(targetRelay); + await targetPool.event(event, { signal: AbortSignal.timeout(15000) }); + console.log(`โœ… Successfully published event ${event.kind} to TARGET relay: ${targetRelay}`); + eventSuccess = true; + usedRelay = targetRelay; + } catch (targetError) { + console.warn(`โš ๏ธ TARGET relay failed for event ${event.kind} (${targetRelay}):`, targetError); + + // 2. Fallback to default relay if different from target + if (config.relayUrl !== targetRelay) { + try { + console.log(`๐Ÿ”„ Falling back to DEFAULT relay for event ${event.kind}: ${config.relayUrl}`); + const fallbackPool = createPool(config.relayUrl); + await fallbackPool.event(event, { signal: AbortSignal.timeout(15000) }); + console.log(`โœ… Successfully published event ${event.kind} to DEFAULT relay: ${config.relayUrl}`); + eventSuccess = true; + usedRelay = config.relayUrl; + } catch (fallbackError) { + console.error(`โŒ Both TARGET (${targetRelay}) and DEFAULT (${config.relayUrl}) relays failed for event ${event.kind}:`, { targetError, fallbackError }); + eventError = fallbackError; + } + } else { + eventError = targetError; } } + + if (eventSuccess) { + successCount++; + } else { + errorCount++; + } + + results.push({ event, success: eventSuccess, usedRelay, error: eventError }); } console.log('๐Ÿ“Š Sync results:', { successCount, errorCount, total: events.length }); @@ -92,7 +132,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", }); } From c9f7c278698d96872131bf9f5c6c4f2167575410 Mon Sep 17 00:00:00 2001 From: Andrii Dvorzhak <20891922+advorzhak@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:29:56 +0300 Subject: [PATCH 3/8] fix: optimize sync concurrency and handle read-only logins safely - Added early user validation to prevent errors for read-only logins. - Moved NPool creation outside the event loop to reuse connections. - Replaced sequential event publishing with Promise.allSettled for concurrent execution. - Fixed unused parameter warning in test mock. --- src/hooks/useSyncProfile.test.tsx | 6 +++--- src/hooks/useSyncProfile.ts | 32 +++++++++++++++++++++++-------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/hooks/useSyncProfile.test.tsx b/src/hooks/useSyncProfile.test.tsx index 9721dad..514e8e9 100644 --- a/src/hooks/useSyncProfile.test.tsx +++ b/src/hooks/useSyncProfile.test.tsx @@ -171,9 +171,9 @@ describe('useSyncProfile', () => { }); const mockEventResult = vi.fn().mockResolvedValue(undefined); - (Nostrify.NPool as any).mockImplementation((opts: any) => { - if (opts.open) { - opts.open('wss://target.relay.test'); + (Nostrify.NPool as any).mockImplementation((_opts: any) => { + if (_opts.open) { + _opts.open('wss://target.relay.test'); } return { event: mockEventResult }; }); diff --git a/src/hooks/useSyncProfile.ts b/src/hooks/useSyncProfile.ts index 8f81d00..188d929 100644 --- a/src/hooks/useSyncProfile.ts +++ b/src/hooks/useSyncProfile.ts @@ -23,6 +23,10 @@ export function useSyncProfile() { throw new Error('No events to sync'); } + 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'); } @@ -63,7 +67,10 @@ export function useSyncProfile() { }); }; - for (const event of events) { + 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 = ''; @@ -71,7 +78,6 @@ export function useSyncProfile() { // 1. Try target relay first try { console.log(`๐Ÿ“ค Publishing event ${event.kind} (${event.id.slice(0, 8)}...) to TARGET relay: ${targetRelay}`); - const targetPool = createPool(targetRelay); await targetPool.event(event, { signal: AbortSignal.timeout(15000) }); console.log(`โœ… Successfully published event ${event.kind} to TARGET relay: ${targetRelay}`); eventSuccess = true; @@ -80,10 +86,9 @@ export function useSyncProfile() { console.warn(`โš ๏ธ TARGET relay failed for event ${event.kind} (${targetRelay}):`, targetError); // 2. Fallback to default relay if different from target - if (config.relayUrl !== targetRelay) { + if (fallbackPool) { try { console.log(`๐Ÿ”„ Falling back to DEFAULT relay for event ${event.kind}: ${config.relayUrl}`); - const fallbackPool = createPool(config.relayUrl); await fallbackPool.event(event, { signal: AbortSignal.timeout(15000) }); console.log(`โœ… Successfully published event ${event.kind} to DEFAULT relay: ${config.relayUrl}`); eventSuccess = true; @@ -97,13 +102,24 @@ export function useSyncProfile() { } } - if (eventSuccess) { - successCount++; + return { event, success: eventSuccess, usedRelay, error: eventError }; + }); + + const settledResults = await Promise.allSettled(publishPromises); + + for (const result of settledResults) { + if (result.status === 'fulfilled') { + const res = result.value; + if (res.success) { + successCount++; + } else { + errorCount++; + } + results.push(res); } else { errorCount++; + console.error('Unexpected promise rejection during event publishing:', result.reason); } - - results.push({ event, success: eventSuccess, usedRelay, error: eventError }); } console.log('๐Ÿ“Š Sync results:', { successCount, errorCount, total: events.length }); From 9d7aad3e50c21c6267ff31878ec130a1ceb40765 Mon Sep 17 00:00:00 2001 From: Andrii Dvorzhak <20891922+advorzhak@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:50:35 +0300 Subject: [PATCH 4/8] fix: add missing DialogTitle components for accessibility --- src/components/auth/LoginDialog.tsx | 6 ++++-- src/components/ui/command.tsx | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) 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} From dfa1f8d2da19d8973730af14f1a479cc7f513d99 Mon Sep 17 00:00:00 2001 From: Andrii Dvorzhak <20891922+advorzhak@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:51:40 +0300 Subject: [PATCH 5/8] fix: add missing DialogTitle components for accessibility --- src/components/auth/LoginDialog.tsx | 6 ++++-- src/components/ui/command.tsx | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) 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} From 56d0a75aba96c0264d2aabfebabd455fa8a29765 Mon Sep 17 00:00:00 2001 From: Andrii Dvorzhak <20891922+advorzhak@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:56:37 +0300 Subject: [PATCH 6/8] feat: add event description to sync logs --- src/hooks/useSyncProfile.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/hooks/useSyncProfile.ts b/src/hooks/useSyncProfile.ts index 188d929..d62d210 100644 --- a/src/hooks/useSyncProfile.ts +++ b/src/hooks/useSyncProfile.ts @@ -75,26 +75,29 @@ export function useSyncProfile() { let eventError: unknown = null; let usedRelay = ''; + const eventDesc = event.tags.find(t => t[0] === 'alt')?.[1] || + (event.content.trim() ? event.content.slice(0, 40) + (event.content.length > 40 ? '...' : '') : 'No description'); + // 1. Try target relay first try { - console.log(`๐Ÿ“ค Publishing event ${event.kind} (${event.id.slice(0, 8)}...) to TARGET relay: ${targetRelay}`); + console.log(`๐Ÿ“ค Publishing event ${event.kind} (${event.id.slice(0, 8)}..., "${eventDesc}") to TARGET relay: ${targetRelay}`); await targetPool.event(event, { signal: AbortSignal.timeout(15000) }); - console.log(`โœ… Successfully published event ${event.kind} to TARGET relay: ${targetRelay}`); + console.log(`โœ… Successfully published event ${event.kind} ("${eventDesc}") to TARGET relay: ${targetRelay}`); eventSuccess = true; usedRelay = targetRelay; } catch (targetError) { - console.warn(`โš ๏ธ TARGET relay failed for event ${event.kind} (${targetRelay}):`, targetError); + console.warn(`โš ๏ธ TARGET relay failed for event ${event.kind} (${event.id.slice(0, 8)}..., "${eventDesc}") (${targetRelay}):`, targetError); // 2. Fallback to default relay if different from target if (fallbackPool) { try { - console.log(`๐Ÿ”„ Falling back to DEFAULT relay for event ${event.kind}: ${config.relayUrl}`); + console.log(`๐Ÿ”„ Falling back to DEFAULT relay for event ${event.kind} ("${eventDesc}"): ${config.relayUrl}`); await fallbackPool.event(event, { signal: AbortSignal.timeout(15000) }); - console.log(`โœ… Successfully published event ${event.kind} to DEFAULT relay: ${config.relayUrl}`); + console.log(`โœ… Successfully published event ${event.kind} ("${eventDesc}") to DEFAULT relay: ${config.relayUrl}`); eventSuccess = true; usedRelay = config.relayUrl; } catch (fallbackError) { - console.error(`โŒ Both TARGET (${targetRelay}) and DEFAULT (${config.relayUrl}) relays failed for event ${event.kind}:`, { targetError, fallbackError }); + console.error(`โŒ Both TARGET (${targetRelay}) and DEFAULT (${config.relayUrl}) relays failed for event ${event.kind} ("${eventDesc}"):`, { targetError, fallbackError }); eventError = fallbackError; } } else { From ad88381137929dc70ec7dd6890b87d1ccaf4c50b Mon Sep 17 00:00:00 2001 From: Andrii Dvorzhak <20891922+advorzhak@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:15:49 +0300 Subject: [PATCH 7/8] feat: add comprehensive kind mapping for readable sync logs --- src/hooks/useSyncProfile.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/hooks/useSyncProfile.ts b/src/hooks/useSyncProfile.ts index d62d210..9d034f7 100644 --- a/src/hooks/useSyncProfile.ts +++ b/src/hooks/useSyncProfile.ts @@ -75,29 +75,32 @@ export function useSyncProfile() { let eventError: unknown = null; let usedRelay = ''; - const eventDesc = event.tags.find(t => t[0] === 'alt')?.[1] || - (event.content.trim() ? event.content.slice(0, 40) + (event.content.length > 40 ? '...' : '') : 'No description'); + // Determine a readable description for the event log + const altTag = event.tags.find(t => t[0] === 'alt')?.[1]; + const contentSnippet = event.content.trim().slice(0, 40).replace(/\n/g, ' ') + (event.content.trim().length > 40 ? '...' : ''); + const description = altTag || (contentSnippet ? `"${contentSnippet}"` : ''); + const eventLabel = description ? `${description} => Kind ${event.kind}` : `Kind ${event.kind}`; // 1. Try target relay first try { - console.log(`๐Ÿ“ค Publishing event ${event.kind} (${event.id.slice(0, 8)}..., "${eventDesc}") to TARGET relay: ${targetRelay}`); + 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 event ${event.kind} ("${eventDesc}") to TARGET relay: ${targetRelay}`); + 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 event ${event.kind} (${event.id.slice(0, 8)}..., "${eventDesc}") (${targetRelay}):`, 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 event ${event.kind} ("${eventDesc}"): ${config.relayUrl}`); + console.log(`๐Ÿ”„ Falling back to DEFAULT relay for ${eventLabel}: ${config.relayUrl}`); await fallbackPool.event(event, { signal: AbortSignal.timeout(15000) }); - console.log(`โœ… Successfully published event ${event.kind} ("${eventDesc}") to DEFAULT relay: ${config.relayUrl}`); + 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 event ${event.kind} ("${eventDesc}"):`, { targetError, fallbackError }); + console.error(`โŒ Both TARGET (${targetRelay}) and DEFAULT (${config.relayUrl}) relays failed for ${eventLabel}:`, { targetError, fallbackError }); eventError = fallbackError; } } else { From 588ef278d2f38d66e66774fef44d8d6386e8b68f Mon Sep 17 00:00:00 2001 From: Andrii Dvorzhak <20891922+advorzhak@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:17:14 +0300 Subject: [PATCH 8/8] fix: parse kind 0 JSON for username in sync logs --- src/hooks/useSyncProfile.ts | 52 ++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/src/hooks/useSyncProfile.ts b/src/hooks/useSyncProfile.ts index 9d034f7..0648c4e 100644 --- a/src/hooks/useSyncProfile.ts +++ b/src/hooks/useSyncProfile.ts @@ -10,6 +10,30 @@ export interface SyncProfileData { 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 { toast } = useToast(); const { config } = useAppContext(); @@ -76,10 +100,30 @@ export function useSyncProfile() { let usedRelay = ''; // Determine a readable description for the event log - const altTag = event.tags.find(t => t[0] === 'alt')?.[1]; - const contentSnippet = event.content.trim().slice(0, 40).replace(/\n/g, ' ') + (event.content.trim().length > 40 ? '...' : ''); - const description = altTag || (contentSnippet ? `"${contentSnippet}"` : ''); - const eventLabel = description ? `${description} => Kind ${event.kind}` : `Kind ${event.kind}`; + 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 {