The entire skate-spots codebase was accidentally copied into src/src/ and public/public/ instead of src/ and public/ directly. This left the real src/ directory as blank MKStack template stubs, explaining why the landing page showed the default template instead of SkateSpots. Changes made: - Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx, all pages, components, hooks, contexts, lib, i18n, test) - Copied all files from public/public/ up to public/ (images, manifest, etc.) - Deleted src/src/ and public/public/ nested directories - Removed HelloWorld.tsx template stub from pages/ - Retained the previously fixed index.css (Tailwind v3 directives) Full skate-spots feature set is now active: - All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.) - NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler - Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.) - Outfit Variable font, Amber pre-check in main.tsx - All hooks (useSkateSpots, useCoaches, useZaps, etc.) - i18n support (en/es) Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
251 lines
9.3 KiB
TypeScript
251 lines
9.3 KiB
TypeScript
import { useEffect, useRef, useState } 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';
|
|
|
|
// Default relays for new users
|
|
const NEW_USER_RELAYS = [
|
|
{ url: 'wss://relay.ditto.pub', read: true, write: true },
|
|
{ url: 'wss://goskatespots.nostr1.com', read: true, write: true },
|
|
{ url: 'wss://wotr.relatr.xyz', read: true, write: true },
|
|
{ url: 'wss://purplepag.es', read: true, write: false }, // Profile aggregator
|
|
];
|
|
|
|
// Fallback relays for fetching profile data (aggregators that index many profiles)
|
|
const FALLBACK_RELAYS = [
|
|
'wss://purplepag.es',
|
|
'wss://relay.nostr.band',
|
|
'wss://relay.damus.io',
|
|
'wss://nos.lol',
|
|
];
|
|
|
|
/**
|
|
* 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();
|
|
const { logins } = useNostrLogin();
|
|
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');
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
|
|
// Only sync once per user per session
|
|
if (syncedUsers.current.has(user.pubkey)) return;
|
|
|
|
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;
|
|
};
|
|
|
|
const syncUserData = async () => {
|
|
setSyncStatus('syncing');
|
|
console.log('[NostrSync] Starting sync for user:', user.pubkey.slice(0, 8) + '...');
|
|
|
|
// FIX B: Invalidate any stale cached data for this user BEFORE fetching
|
|
// This ensures we don't use old cached empty results
|
|
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] });
|
|
|
|
let relaysSynced = false;
|
|
let profileSynced = false;
|
|
|
|
// Sync relay list (NIP-65)
|
|
const syncRelays = async (): Promise<boolean> => {
|
|
const events = await nostr.query(
|
|
[{ kinds: [10002], authors: [user.pubkey], limit: 1 }],
|
|
{ signal: AbortSignal.timeout(10000) }
|
|
);
|
|
|
|
if (events.length > 0) {
|
|
const event = events[0];
|
|
|
|
// Only update if the event is newer than our stored data
|
|
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] Synced relay list from NIP-65:', fetchedRelays.length, 'relays');
|
|
updateConfig((current) => ({
|
|
...current,
|
|
relayMetadata: {
|
|
relays: fetchedRelays,
|
|
updatedAt: event.created_at,
|
|
},
|
|
}));
|
|
return true;
|
|
}
|
|
} else {
|
|
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,
|
|
},
|
|
}));
|
|
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;
|
|
console.log('[NostrSync] Querying profile for pubkey:', user.pubkey.slice(0, 8) + '...');
|
|
const events = await querySource.query(
|
|
[{ kinds: [0], authors: [user.pubkey], limit: 1 }],
|
|
{ signal: AbortSignal.timeout(10000) }
|
|
);
|
|
console.log('[NostrSync] Profile query returned', events.length, 'events');
|
|
|
|
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;
|
|
});
|
|
});
|
|
|
|
console.log('[NostrSync] Profile synced and cached:', metadata.name || 'unnamed');
|
|
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;
|
|
};
|
|
|
|
// 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;
|
|
}
|
|
|
|
// If profile not found on main relays, try fallback relays
|
|
if (!profileSynced) {
|
|
console.log('[NostrSync] Profile not found on main relays, trying fallbacks...');
|
|
|
|
const fallbackResult = await syncWithRetry(async () => {
|
|
const fallbackGroup = nostr.group(FALLBACK_RELAYS);
|
|
return await syncProfile(fallbackGroup);
|
|
}, 3, 2000);
|
|
|
|
if (fallbackResult) {
|
|
profileSynced = true;
|
|
}
|
|
}
|
|
|
|
// Mark sync complete for this user
|
|
syncedUsers.current.add(user.pubkey);
|
|
|
|
if (relaysSynced || profileSynced) {
|
|
setSyncStatus('success');
|
|
console.log('[NostrSync] Sync completed - relays:', relaysSynced, 'profile:', profileSynced);
|
|
} else {
|
|
setSyncStatus('error');
|
|
console.error('[NostrSync] Sync failed - could not reach relays');
|
|
toast({
|
|
title: 'Connection Issue',
|
|
description: 'Could not sync your profile. Check your internet connection.',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
// 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
|
|
const timeoutId = setTimeout(() => {
|
|
syncUserData();
|
|
}, 800);
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
}, [user, config.relayMetadata.updatedAt, nostr, updateConfig, toast, queryClient, logins]);
|
|
|
|
return null;
|
|
}
|