Fix profile not loading after Amber login - rewrite NostrSync
Root causes found:
1. syncedUsers blocked re-sync permanently even on failure — first attempt
fires before WebSocket connections are ready on mobile, fails silently,
gets marked done, profile stays anonymous forever that session
2. useAuthor had 5min staleTime — empty {} result from pre-login query
was cached and not refetched after login
3. NostrSync did not force-remove the stale useAuthor cache entry
Fixes:
- Rewrite NostrSync with proper retry logic (up to 5 attempts, backoff)
- Tracks success/failure per pubkey, only stops on confirmed success
- Re-triggers on user.pubkey change (catches Amber polling completing)
- Forces queryClient.removeQueries on author cache before each attempt
- Tries Citrine → bootstrap relays → main pool in order
- useAuthor: reduce staleTime 5min→1min, expand fallback relay list
- Both files now use same BOOTSTRAP_RELAYS set for consistency
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
4caad49dfb
commit
6e46214747
2 changed files with 143 additions and 211 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useNostrLogin } from '@nostrify/react/login';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
|
@ -7,11 +7,10 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
// Citrine local relay (runs on Android device via the Citrine app)
|
||||
// https://github.com/greenart7c3/Citrine
|
||||
// Citrine local relay (Android app by greenart7c3)
|
||||
const CITRINE_URL = 'ws://localhost:4869';
|
||||
|
||||
// Default relays for new users
|
||||
// Default relays for brand-new users with no NIP-65 event
|
||||
const NEW_USER_RELAYS = [
|
||||
{ url: 'wss://auth.nostr1.com', read: true, write: true },
|
||||
{ url: 'wss://wotr.relatr.xyz', read: true, write: true },
|
||||
|
|
@ -20,18 +19,14 @@ const NEW_USER_RELAYS = [
|
|||
{ url: 'wss://purplepag.es', read: true, write: false },
|
||||
];
|
||||
|
||||
// Bootstrap relays — always checked for profile even if user has their own relay list.
|
||||
// purplepag.es indexes kind 0 + kind 10002 for most pubkeys worldwide.
|
||||
// Always-on profile aggregators — try these unconditionally
|
||||
const BOOTSTRAP_RELAYS = [
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.damus.io',
|
||||
];
|
||||
|
||||
/**
|
||||
* Try to connect to Citrine local relay.
|
||||
* Returns true if Citrine is reachable within 1.5 seconds.
|
||||
*/
|
||||
async function isCitrineAvailable(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
|
|
@ -45,19 +40,6 @@ async function isCitrineAvailable(): Promise<boolean> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* NostrSync - Syncs user's Nostr data on login
|
||||
*
|
||||
* This component runs globally to sync various Nostr data when the user logs in.
|
||||
* It fetches data and populates TanStack Query cache so other hooks don't need to refetch.
|
||||
*
|
||||
* Currently syncs:
|
||||
* - NIP-65 relay list (kind 10002)
|
||||
* - Profile metadata (kind 0) - cached for useAuthor hook
|
||||
*
|
||||
* New users (without a published relay list) get default relays instead of
|
||||
* inheriting from previous browser sessions.
|
||||
*/
|
||||
export function NostrSync() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
|
|
@ -65,66 +47,91 @@ export function NostrSync() {
|
|||
const { config, updateConfig } = useAppContext();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const syncedUsers = useRef<Set<string>>(new Set());
|
||||
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle');
|
||||
|
||||
// Track per-pubkey: null = never tried, false = failed, true = succeeded
|
||||
const syncState = useRef<Map<string, boolean>>(new Map());
|
||||
const retryTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
// Only sync once per user per session
|
||||
if (syncedUsers.current.has(user.pubkey)) return;
|
||||
const pubkey = user.pubkey;
|
||||
|
||||
const syncWithRetry = async <T,>(
|
||||
queryFn: () => Promise<T>,
|
||||
maxRetries: number = 5,
|
||||
initialDelayMs: number = 1000
|
||||
): Promise<T | null> => {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await queryFn();
|
||||
} catch (error) {
|
||||
console.warn(`[NostrSync] Attempt ${attempt}/${maxRetries} failed:`, error);
|
||||
if (attempt < maxRetries) {
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
|
||||
const delay = initialDelayMs * Math.pow(2, attempt - 1);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
// If already succeeded for this pubkey this session, skip
|
||||
if (syncState.current.get(pubkey) === true) return;
|
||||
|
||||
// Clear any existing retry timer for this pubkey
|
||||
const existing = retryTimers.current.get(pubkey);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
||||
// PWA needs longer to re-establish WebSocket connections after Amber redirect
|
||||
const isPWA = window.matchMedia('(display-mode: standalone)').matches;
|
||||
const initialDelay = isPWA ? 2000 : 600;
|
||||
|
||||
const timer = setTimeout(() => doSync(pubkey), initialDelay);
|
||||
retryTimers.current.set(pubkey, timer);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
// Re-run whenever user changes — this covers Amber login completing via polling
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.pubkey]);
|
||||
|
||||
const syncUserData = async () => {
|
||||
setSyncStatus('syncing');
|
||||
async function doSync(pubkey: string, attempt = 1) {
|
||||
console.log(`[NostrSync] Starting sync for ${pubkey.slice(0, 8)}... attempt ${attempt}`);
|
||||
|
||||
// Invalidate stale cache before fetching fresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] });
|
||||
const loginsKey = logins.map((l) => l.id).join(';');
|
||||
queryClient.invalidateQueries({ queryKey: ['nostr', 'logins', loginsKey] });
|
||||
// Always invalidate stale cache first so useAuthor refetches
|
||||
queryClient.invalidateQueries({ queryKey: ['author', pubkey] });
|
||||
queryClient.removeQueries({ queryKey: ['author', pubkey] });
|
||||
|
||||
// Check if Citrine local relay is running — if so, use it first.
|
||||
// Citrine stores all the user's events locally, so it's the fastest
|
||||
// and most reliable source for profile + relay list on Android.
|
||||
const citrineAvailable = await isCitrineAvailable();
|
||||
if (citrineAvailable) {
|
||||
console.log('[NostrSync] Citrine detected — using local relay first');
|
||||
}
|
||||
let profileSynced = false;
|
||||
let relaysSynced = false;
|
||||
|
||||
let relaysSynced = false;
|
||||
let profileSynced = false;
|
||||
|
||||
// Sync relay list (NIP-65)
|
||||
const syncRelays = async (relayGroup?: ReturnType<typeof nostr.group>): Promise<boolean> => {
|
||||
const source = relayGroup || nostr;
|
||||
// ── helper: fetch kind 0 from a relay source ────────────────────────────
|
||||
const fetchProfile = async (source: typeof nostr | ReturnType<typeof nostr.group>) => {
|
||||
try {
|
||||
const events = await source.query(
|
||||
[{ kinds: [10002], authors: [user.pubkey], limit: 1 }],
|
||||
{ signal: AbortSignal.timeout(8000) }
|
||||
[{ kinds: [0], authors: [pubkey], limit: 1 }],
|
||||
{ signal: AbortSignal.timeout(6000) }
|
||||
);
|
||||
|
||||
if (events.length > 0) {
|
||||
const event = events[0];
|
||||
try {
|
||||
const metadata = n.json().pipe(n.metadata()).parse(event.content);
|
||||
console.log(`[NostrSync] Got profile: ${metadata.name ?? 'unnamed'}`);
|
||||
// Populate useAuthor cache
|
||||
queryClient.setQueryData(['author', pubkey], { metadata, event });
|
||||
// Populate useLoggedInAccounts cache
|
||||
const loginsKey = logins.map((l) => l.id).join(';');
|
||||
queryClient.setQueryData(
|
||||
['nostr', 'logins', loginsKey],
|
||||
(old: Array<{ id: string; pubkey: string; metadata?: typeof metadata }> | undefined) => {
|
||||
const base = old ?? logins.map(l => ({ id: l.id, pubkey: l.pubkey }));
|
||||
return base.map(a => a.pubkey === pubkey ? { ...a, metadata, event } : a);
|
||||
}
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
queryClient.setQueryData(['author', pubkey], { event });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[NostrSync] fetchProfile failed:', e);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Only update if the event is newer than our stored data
|
||||
// ── helper: fetch kind 10002 from a relay source ────────────────────────
|
||||
const fetchRelays = async (source: typeof nostr | ReturnType<typeof nostr.group>) => {
|
||||
try {
|
||||
const events = await source.query(
|
||||
[{ kinds: [10002], authors: [pubkey], limit: 1 }],
|
||||
{ signal: AbortSignal.timeout(6000) }
|
||||
);
|
||||
if (events.length > 0) {
|
||||
const event = events[0];
|
||||
if (event.created_at > config.relayMetadata.updatedAt) {
|
||||
const fetchedRelays = event.tags
|
||||
.filter(([name]) => name === 'r')
|
||||
|
|
@ -133,15 +140,11 @@ export function NostrSync() {
|
|||
read: !marker || marker === 'read',
|
||||
write: !marker || marker === 'write',
|
||||
}));
|
||||
|
||||
if (fetchedRelays.length > 0) {
|
||||
console.log('[NostrSync] Synced relay list from NIP-65:', fetchedRelays.length, 'relays');
|
||||
updateConfig((current) => ({
|
||||
...current,
|
||||
relayMetadata: {
|
||||
relays: fetchedRelays,
|
||||
updatedAt: event.created_at,
|
||||
},
|
||||
console.log(`[NostrSync] Got ${fetchedRelays.length} relays from NIP-65`);
|
||||
updateConfig(c => ({
|
||||
...c,
|
||||
relayMetadata: { relays: fetchedRelays, updatedAt: event.created_at },
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
|
@ -149,150 +152,77 @@ export function NostrSync() {
|
|||
return true; // Already have latest
|
||||
}
|
||||
} else {
|
||||
// No NIP-65 relay list found - this is a new user!
|
||||
console.log('[NostrSync] New user - setting default relays');
|
||||
updateConfig((current) => ({
|
||||
...current,
|
||||
relayMetadata: {
|
||||
relays: NEW_USER_RELAYS,
|
||||
updatedAt: 0,
|
||||
},
|
||||
}));
|
||||
// No relay list — set defaults for new user
|
||||
if (config.relayMetadata.updatedAt === 0) {
|
||||
updateConfig(c => ({
|
||||
...c,
|
||||
relayMetadata: { relays: NEW_USER_RELAYS, updatedAt: 0 },
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Sync profile (kind 0) and populate useAuthor cache
|
||||
const syncProfile = async (relayGroup?: ReturnType<typeof nostr.group>): Promise<boolean> => {
|
||||
const querySource = relayGroup || nostr;
|
||||
const events = await querySource.query(
|
||||
[{ kinds: [0], authors: [user.pubkey], limit: 1 }],
|
||||
{ signal: AbortSignal.timeout(10000) }
|
||||
);
|
||||
|
||||
if (events.length > 0) {
|
||||
const event = events[0];
|
||||
|
||||
// FIX A: Parse metadata and populate useAuthor's TanStack Query cache
|
||||
try {
|
||||
const metadata = n.json().pipe(n.metadata()).parse(event.content);
|
||||
|
||||
// Set the cache data that useAuthor expects
|
||||
queryClient.setQueryData(['author', user.pubkey], {
|
||||
metadata,
|
||||
event
|
||||
});
|
||||
|
||||
// FIX A+: Also update useLoggedInAccounts cache so profile shows everywhere
|
||||
const loginsKey = logins.map((l) => l.id).join(';');
|
||||
queryClient.setQueryData(['nostr', 'logins', loginsKey], (oldData: Array<{ id: string; pubkey: string; metadata: typeof metadata; event?: typeof event }> | undefined) => {
|
||||
if (!oldData) {
|
||||
// Create initial data from logins
|
||||
return logins.map((login) => {
|
||||
if (login.pubkey === user.pubkey) {
|
||||
return { id: login.id, pubkey: login.pubkey, metadata, event };
|
||||
}
|
||||
return { id: login.id, pubkey: login.pubkey, metadata: {} };
|
||||
});
|
||||
}
|
||||
// Update existing data
|
||||
return oldData.map((account) => {
|
||||
if (account.pubkey === user.pubkey) {
|
||||
return { ...account, metadata, event };
|
||||
}
|
||||
return account;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
return true;
|
||||
} catch (parseError) {
|
||||
// Event exists but content couldn't be parsed - still cache the event
|
||||
queryClient.setQueryData(['author', user.pubkey], { event });
|
||||
console.warn('[NostrSync] Profile event found but metadata parse failed');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 1. Try Citrine first if available — instant local data
|
||||
if (citrineAvailable) {
|
||||
try {
|
||||
const citrineGroup = nostr.group([CITRINE_URL]);
|
||||
const [relayResult, profileResult] = await Promise.all([
|
||||
syncRelays(citrineGroup),
|
||||
syncProfile(citrineGroup),
|
||||
]);
|
||||
relaysSynced = relayResult;
|
||||
profileSynced = profileResult;
|
||||
console.log('[NostrSync] Citrine sync:', { relaysSynced, profileSynced });
|
||||
} catch (e) {
|
||||
console.warn('[NostrSync] Citrine sync failed:', e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[NostrSync] fetchRelays failed:', e);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 2. Try bootstrap relays (purplepag.es etc.) — always fast for profile/relays
|
||||
if (!profileSynced || !relaysSynced) {
|
||||
try {
|
||||
const bootstrapGroup = nostr.group(BOOTSTRAP_RELAYS);
|
||||
const [relayResult, profileResult] = await Promise.all([
|
||||
relaysSynced ? Promise.resolve(true) : syncRelays(bootstrapGroup),
|
||||
profileSynced ? Promise.resolve(true) : syncProfile(bootstrapGroup),
|
||||
]);
|
||||
if (!relaysSynced) relaysSynced = relayResult;
|
||||
if (!profileSynced) profileSynced = profileResult;
|
||||
console.log('[NostrSync] Bootstrap sync:', { relaysSynced, profileSynced });
|
||||
} catch (e) {
|
||||
console.warn('[NostrSync] Bootstrap sync failed:', e);
|
||||
}
|
||||
}
|
||||
// ── 1. Citrine (local relay — fastest, no internet needed) ───────────────
|
||||
const citrineAvailable = await isCitrineAvailable();
|
||||
if (citrineAvailable) {
|
||||
console.log('[NostrSync] Citrine available — querying local relay');
|
||||
const g = nostr.group([CITRINE_URL]);
|
||||
const [r, p] = await Promise.all([fetchRelays(g), fetchProfile(g)]);
|
||||
relaysSynced = r;
|
||||
profileSynced = p;
|
||||
}
|
||||
|
||||
// 3. Try main configured relays with retry
|
||||
if (!profileSynced || !relaysSynced) {
|
||||
const mainResult = await syncWithRetry(async () => {
|
||||
const [relayResult, profileResult] = await Promise.all([
|
||||
relaysSynced ? Promise.resolve(true) : syncRelays(),
|
||||
profileSynced ? Promise.resolve(true) : syncProfile(),
|
||||
]);
|
||||
return { relays: relayResult, profile: profileResult };
|
||||
}, 3, 1500);
|
||||
// ── 2. Bootstrap relays (always-on aggregators) ──────────────────────────
|
||||
if (!profileSynced || !relaysSynced) {
|
||||
console.log('[NostrSync] Trying bootstrap relays...');
|
||||
const g = nostr.group(BOOTSTRAP_RELAYS);
|
||||
const [r, p] = await Promise.all([
|
||||
relaysSynced ? Promise.resolve(true) : fetchRelays(g),
|
||||
profileSynced ? Promise.resolve(true) : fetchProfile(g),
|
||||
]);
|
||||
if (!relaysSynced) relaysSynced = r;
|
||||
if (!profileSynced) profileSynced = p;
|
||||
}
|
||||
|
||||
if (mainResult) {
|
||||
if (!relaysSynced) relaysSynced = mainResult.relays;
|
||||
if (!profileSynced) profileSynced = mainResult.profile;
|
||||
}
|
||||
}
|
||||
// ── 3. Main relay pool ───────────────────────────────────────────────────
|
||||
if (!profileSynced || !relaysSynced) {
|
||||
console.log('[NostrSync] Trying main relay pool...');
|
||||
const [r, p] = await Promise.all([
|
||||
relaysSynced ? Promise.resolve(true) : fetchRelays(nostr),
|
||||
profileSynced ? Promise.resolve(true) : fetchProfile(nostr),
|
||||
]);
|
||||
if (!relaysSynced) relaysSynced = r;
|
||||
if (!profileSynced) profileSynced = p;
|
||||
}
|
||||
|
||||
// Mark sync complete for this user
|
||||
syncedUsers.current.add(user.pubkey);
|
||||
const success = profileSynced && relaysSynced;
|
||||
console.log(`[NostrSync] Done — profile:${profileSynced} relays:${relaysSynced}`);
|
||||
|
||||
if (relaysSynced || profileSynced) {
|
||||
setSyncStatus('success');
|
||||
if (success) {
|
||||
syncState.current.set(pubkey, true);
|
||||
} else {
|
||||
// Retry with backoff — max 5 attempts, up to ~30s total
|
||||
if (attempt < 5) {
|
||||
const delay = Math.min(3000 * attempt, 15000);
|
||||
console.warn(`[NostrSync] Incomplete — retrying in ${delay}ms (attempt ${attempt + 1})`);
|
||||
const t = setTimeout(() => doSync(pubkey, attempt + 1), delay);
|
||||
retryTimers.current.set(pubkey, t);
|
||||
} else {
|
||||
setSyncStatus('error');
|
||||
console.error('[NostrSync] Sync failed - could not reach relays');
|
||||
syncState.current.set(pubkey, false);
|
||||
console.error('[NostrSync] All attempts failed for', pubkey.slice(0, 8));
|
||||
toast({
|
||||
title: 'Connection Issue',
|
||||
description: 'Could not sync your profile. Check your internet connection.',
|
||||
title: 'Could not load profile',
|
||||
description: 'Check your internet connection and try refreshing.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Give relay WebSocket connections time to establish, especially important
|
||||
// for PWA/Amber where the app context is fresh after returning from another app.
|
||||
// Also gives Citrine time to be detected.
|
||||
const isPWA = window.matchMedia('(display-mode: standalone)').matches;
|
||||
const delay = isPWA ? 1500 : 800;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
syncUserData();
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [user, config.relayMetadata.updatedAt, nostr, updateConfig, toast, queryClient, logins]);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import { type NostrEvent, type NostrMetadata, NSchema as n } from '@nostrify/nos
|
|||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
// Fallback relays for profile aggregation
|
||||
// Fallback relays for profile aggregation — global indexers
|
||||
const FALLBACK_RELAYS = [
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.damus.io',
|
||||
];
|
||||
|
||||
|
|
@ -57,8 +59,8 @@ export function useAuthor(pubkey: string | undefined) {
|
|||
return {};
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Keep cached data fresh for 5 minutes
|
||||
retry: 2,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), // Exponential backoff
|
||||
staleTime: 60 * 1000, // 1 minute — allows NostrSync to invalidate and refetch quickly
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 8000),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue