314 lines
12 KiB
TypeScript
314 lines
12 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { useNostr } from '@nostrify/react';
|
|
import { useNostrLogin } from '@/lib/nostrLogin';
|
|
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';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import { isRetiredRelay, withCommunityRelay } from '@/lib/relays';
|
|
import { isInteractiveSigner } from '@/lib/signerCapabilities';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { SKATE_RELAY } from '@/lib/types';
|
|
|
|
// 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://relay.ditto.pub', read: true, write: true },
|
|
{ url: 'wss://relay.skate-spots.org', 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 { t } = useTranslation();
|
|
const { nostr } = useNostr();
|
|
const { user } = useCurrentUser();
|
|
const { logins } = useNostrLogin();
|
|
const { config, updateConfig } = useAppContext();
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
const { mutate: publishEvent } = useNostrPublish();
|
|
const communityHandled = useRef(false);
|
|
|
|
// 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]);
|
|
|
|
// Ensure the community relay is in the user's NIP-65 list. Users with a silent
|
|
// signer also get the list republished once so OTHER Nostr clients see the skate
|
|
// scene; Amber users keep it local (they can republish from Settings → Relays).
|
|
useEffect(() => {
|
|
if (!user || communityHandled.current) return;
|
|
communityHandled.current = true;
|
|
if (config.useSkateRelay === false) return;
|
|
|
|
const has = config.relayMetadata.relays.some(
|
|
(r) => r.url.toLowerCase() === SKATE_RELAY.toLowerCase(),
|
|
);
|
|
if (has) return;
|
|
|
|
const list = withCommunityRelay(config.relayMetadata.relays);
|
|
updateConfig((c) => ({
|
|
...c,
|
|
relayMetadata: { ...(c.relayMetadata ?? { relays: [], updatedAt: 0 }), relays: list },
|
|
}));
|
|
|
|
// Amber prompts per op — never publish unprompted. And only republish for a
|
|
// user who has actually published a NIP-65 list before.
|
|
if (isInteractiveSigner(user, logins)) return;
|
|
if (config.relayMetadata.updatedAt === 0) return;
|
|
|
|
const tags = list
|
|
.map((r) =>
|
|
r.read && r.write
|
|
? ['r', r.url]
|
|
: r.read
|
|
? ['r', r.url, 'read']
|
|
: r.write
|
|
? ['r', r.url, 'write']
|
|
: null,
|
|
)
|
|
.filter((t): t is string[] => t !== null);
|
|
publishEvent({ kind: 10002, content: '', tags });
|
|
// Run once per logged-in user.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [user?.pubkey]);
|
|
|
|
async function doSync(pubkey: string, attempt = 1) {
|
|
// 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);
|
|
// 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) {
|
|
let fetchedRelays = event.tags
|
|
.filter(([name]) => name === 'r')
|
|
.map(([, url, marker]) => ({
|
|
url,
|
|
read: !marker || marker === 'read',
|
|
write: !marker || marker === 'write',
|
|
}))
|
|
.filter((r) => !isRetiredRelay(r.url));
|
|
if (fetchedRelays.length > 0) {
|
|
// Never let a fetched list drop the community relay.
|
|
if (config.useSkateRelay !== false) {
|
|
fetchedRelays = withCommunityRelay(fetchedRelays);
|
|
}
|
|
updateConfig(c => ({
|
|
...c,
|
|
relayMetadata: {
|
|
...(c.relayMetadata ?? { relays: [], updatedAt: 0 }),
|
|
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: {
|
|
...(c.relayMetadata ?? { relays: [], updatedAt: 0 }),
|
|
relays: NEW_USER_RELAYS,
|
|
updatedAt: 0,
|
|
},
|
|
}));
|
|
}
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
console.warn('[NostrSync] fetchRelays failed:', e);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// ── helper: fetch kind 10050 (NIP-17 DM relays) ────────────────────────
|
|
const fetchDmRelays = async (source: typeof nostr | ReturnType<typeof nostr.group>) => {
|
|
try {
|
|
const events = await source.query(
|
|
[{ kinds: [10050], authors: [pubkey], limit: 1 }],
|
|
{ signal: AbortSignal.timeout(6000) },
|
|
);
|
|
if (events.length === 0) return false;
|
|
const urls = events[0].tags
|
|
.filter(([name]) => name === 'relay')
|
|
.map(([, url]) => url)
|
|
.filter((u): u is string => !!u);
|
|
if (urls.length === 0) return false;
|
|
updateConfig((c) => ({
|
|
...c,
|
|
relayMetadata: {
|
|
...(c.relayMetadata ?? { relays: [], updatedAt: 0 }),
|
|
dmRelays: urls,
|
|
},
|
|
}));
|
|
return true;
|
|
} catch (e) {
|
|
console.warn('[NostrSync] fetchDmRelays failed:', e);
|
|
return false;
|
|
}
|
|
};
|
|
// ── 1. Citrine (local relay — fastest, no internet needed) ───────────────
|
|
const citrineAvailable = await isCitrineAvailable();
|
|
if (citrineAvailable) {
|
|
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) {
|
|
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) {
|
|
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;
|
|
}
|
|
|
|
// DM relays (kind 10050) are optional — fetch without gating sync on them.
|
|
void fetchDmRelays(nostr);
|
|
|
|
const success = profileSynced && 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);
|
|
// Don't show error toast if profile simply doesn't exist yet —
|
|
// it's a new account, not a connection error
|
|
if (!profileSynced) {
|
|
// New account — no profile published yet, nothing to sync.
|
|
} else {
|
|
toast({
|
|
title: t('toastsUi.couldNotLoadProfile'),
|
|
description: t('toastsUi.couldNotLoadProfileDesc'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|