SkateSpots/src/components/NostrSync.tsx
shakespeare.diy 0f98994c86 Fix NostrSync error toast for accounts with no profile published yet
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-17 23:38:24 +07:00

234 lines
9.1 KiB
TypeScript

import { useEffect, useRef } from 'react';
import { useNostr } from '@nostrify/react';
import { useNostrLogin } from '@nostrify/react/login';
import { useQueryClient } from '@tanstack/react-query';
import { NSchema as n } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useToast } from '@/hooks/useToast';
// Citrine local relay (Android app by greenart7c3)
const CITRINE_URL = 'ws://localhost:4869';
// 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 },
{ url: 'wss://relay.ditto.pub', read: true, write: true },
{ url: 'wss://goskatespots.nostr1.com', read: true, write: true },
{ url: 'wss://purplepag.es', read: true, write: false },
];
// Always-on profile aggregators — try these unconditionally
const BOOTSTRAP_RELAYS = [
'wss://purplepag.es',
'wss://relay.nostr.band',
'wss://nos.lol',
'wss://relay.damus.io',
];
async function isCitrineAvailable(): Promise<boolean> {
return new Promise((resolve) => {
try {
const ws = new WebSocket(CITRINE_URL);
const timer = setTimeout(() => { ws.close(); resolve(false); }, 1500);
ws.onopen = () => { clearTimeout(timer); ws.close(); resolve(true); };
ws.onerror = () => { clearTimeout(timer); resolve(false); };
} catch {
resolve(false);
}
});
}
export function NostrSync() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const { config, updateConfig } = useAppContext();
const { toast } = useToast();
const queryClient = useQueryClient();
// 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;
const pubkey = user.pubkey;
// 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]);
async function doSync(pubkey: string, attempt = 1) {
console.log(`[NostrSync] Starting sync for ${pubkey.slice(0, 8)}... attempt ${attempt}`);
// Always invalidate stale cache first so useAuthor refetches
queryClient.invalidateQueries({ queryKey: ['author', pubkey] });
queryClient.removeQueries({ queryKey: ['author', pubkey] });
let profileSynced = false;
let relaysSynced = false;
// ── 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: [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;
};
// ── 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')
.map(([, url, marker]) => ({
url,
read: !marker || marker === 'read',
write: !marker || marker === 'write',
}));
if (fetchedRelays.length > 0) {
console.log(`[NostrSync] Got ${fetchedRelays.length} relays from NIP-65`);
updateConfig(c => ({
...c,
relayMetadata: { relays: fetchedRelays, updatedAt: event.created_at },
}));
return true;
}
} else {
return true; // Already have latest
}
} else {
// 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;
}
} catch (e) {
console.warn('[NostrSync] fetchRelays failed:', e);
}
return false;
};
// ── 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;
}
// ── 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;
}
// ── 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;
}
const success = profileSynced && relaysSynced;
console.log(`[NostrSync] Done — profile:${profileSynced} relays:${relaysSynced}`);
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 {
syncState.current.set(pubkey, false);
console.error('[NostrSync] All attempts failed for', pubkey.slice(0, 8));
// Don't show error toast if profile simply doesn't exist yet —
// it's a new account, not a connection error
if (!profileSynced) {
console.log('[NostrSync] No profile found — may be a new account');
} else {
toast({
title: 'Could not load profile',
description: 'Check your internet connection and try refreshing.',
variant: 'destructive',
});
}
}
}
}
return null;
}