Fix profile/relay loading after Amber login + add Citrine support

Bug 1 - AppProvider was wiping NIP-65 relay list:
- Relay migration check was comparing stored relays vs defaults and
  resetting them if they differed — overwriting the user's own NIP-65 list
- Fix: only reset relays to defaults if updatedAt === 0 (never synced)
  and relays array is empty

Bug 2 - NostrSync not finding profile on mobile:
- Add Citrine local relay detection (ws://localhost:4869)
  - If Citrine is running, query it first — instant local data
- Add BOOTSTRAP_RELAYS (purplepag.es, relay.nostr.band, nos.lol)
  - Always try these for profile+relays before main pool
- syncRelays() now accepts optional relay group param
- Increase initial delay to 1500ms in PWA mode (800ms in browser)
  to give WebSocket connections time to establish after Amber redirect

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-17 23:15:54 +07:00
parent 65914303f1
commit 4caad49dfb
2 changed files with 96 additions and 50 deletions

View file

@ -55,35 +55,29 @@ export function AppProvider(props: AppProviderProps) {
// Merge configs with special handling for relay metadata
const config = { ...defaultConfig, ...rawConfig };
// Force migration to correct relays and dark theme
// Force dark theme and seed default relays only for brand-new users
useEffect(() => {
let needsUpdate = false;
const updates: Partial<AppConfig> = {};
// Check relay configuration
if (config.relayMetadata.relays) {
const currentRelayUrls = config.relayMetadata.relays.map(r => r.url).sort().join(',');
const expectedRelayUrls = defaultConfig.relayMetadata.relays.map(r => r.url).sort().join(',');
if (currentRelayUrls !== expectedRelayUrls) {
console.log('Migrating relays to:', defaultConfig.relayMetadata.relays.map(r => r.url));
// Only reset relays to defaults if the user has never synced their own NIP-65
// relay list (updatedAt === 0 means no NIP-65 event has ever been fetched).
// If updatedAt > 0, the user has their own relay list — never overwrite it.
if (!config.relayMetadata.relays || config.relayMetadata.relays.length === 0) {
if (config.relayMetadata.updatedAt === 0) {
updates.relayMetadata = defaultConfig.relayMetadata;
needsUpdate = true;
}
}
// Force dark theme (app is dark-only now)
// Force dark theme (app is dark-only)
if (config.theme !== 'dark') {
console.log('Forcing dark theme');
updates.theme = 'dark';
needsUpdate = true;
}
if (needsUpdate) {
updateConfig((current) => ({
...current,
...updates,
}));
updateConfig((current) => ({ ...current, ...updates }));
}
}, []);

View file

@ -7,22 +7,44 @@ 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
const CITRINE_URL = 'ws://localhost:4869';
// Default relays for new users
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 }, // Profile aggregator
{ url: 'wss://purplepag.es', read: true, write: false },
];
// Fallback relays for fetching profile data (aggregators that index many profiles)
const FALLBACK_RELAYS = [
// 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.
const BOOTSTRAP_RELAYS = [
'wss://purplepag.es',
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol',
];
/**
* 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 {
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);
}
});
}
/**
* NostrSync - Syncs user's Nostr data on login
*
@ -75,22 +97,28 @@ export function NostrSync() {
const syncUserData = async () => {
setSyncStatus('syncing');
// FIX B: Invalidate any stale cached data for this user BEFORE fetching
// This ensures we don't use old cached empty results
// Invalidate stale cache before fetching fresh data
queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] });
// Also invalidate the logins cache so useLoggedInAccounts refetches
const loginsKey = logins.map((l) => l.id).join(';');
queryClient.invalidateQueries({ queryKey: ['nostr', 'logins', loginsKey] });
// 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 relaysSynced = false;
let profileSynced = false;
// Sync relay list (NIP-65)
const syncRelays = async (): Promise<boolean> => {
const events = await nostr.query(
const syncRelays = async (relayGroup?: ReturnType<typeof nostr.group>): Promise<boolean> => {
const source = relayGroup || nostr;
const events = await source.query(
[{ kinds: [10002], authors: [user.pubkey], limit: 1 }],
{ signal: AbortSignal.timeout(10000) }
{ signal: AbortSignal.timeout(8000) }
);
if (events.length > 0) {
@ -189,30 +217,51 @@ export function NostrSync() {
return false;
};
// Try main relays first with retry
const mainResult = await syncWithRetry(async () => {
const [relayResult, profileResult] = await Promise.all([
syncRelays(),
syncProfile()
]);
return { relays: relayResult, profile: profileResult };
}, 3, 1500);
if (mainResult) {
relaysSynced = mainResult.relays;
profileSynced = mainResult.profile;
// 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);
}
}
// If profile not found on main relays, try fallback relays
if (!profileSynced) {
const fallbackResult = await syncWithRetry(async () => {
const fallbackGroup = nostr.group(FALLBACK_RELAYS);
return await syncProfile(fallbackGroup);
}, 3, 2000);
// 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);
}
}
if (fallbackResult) {
profileSynced = true;
// 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);
if (mainResult) {
if (!relaysSynced) relaysSynced = mainResult.relays;
if (!profileSynced) profileSynced = mainResult.profile;
}
}
@ -232,12 +281,15 @@ export function NostrSync() {
}
};
// FIX C: Longer initial delay to allow relay connections to establish
// This helps with external signers like Amber where PWA context may need time
// to re-establish WebSocket connections after returning from the signer app
// 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();
}, 800);
}, delay);
return () => clearTimeout(timeoutId);
}, [user, config.relayMetadata.updatedAt, nostr, updateConfig, toast, queryClient, logins]);