diff --git a/src/hooks/useBatchSpotCommunityScores.ts b/src/hooks/useBatchSpotCommunityScores.ts index 23e1693..b563cf7 100644 --- a/src/hooks/useBatchSpotCommunityScores.ts +++ b/src/hooks/useBatchSpotCommunityScores.ts @@ -36,7 +36,7 @@ const blankSignals = (): AccountSignals => ({ export function useBatchSpotCommunityScores( spots: Array<{ id: string; pubkey: string; identifier: string }>, ) { - const { querySkateContent } = useSkateRelays(); + const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const refs = useMemo( () => spots.map((s) => `${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`), @@ -49,7 +49,7 @@ export function useBatchSpotCommunityScores( }, [spots]); return useQuery>({ - queryKey: ['batch-spot-community', refs.slice().sort().join(',')], + queryKey: ['batch-spot-community', refs.slice().sort().join(','), getQueryKeySuffix()], enabled: refs.length > 0, staleTime: 2 * 60 * 1000, queryFn: async (c) => { diff --git a/src/hooks/useBatchTrustScores.ts b/src/hooks/useBatchTrustScores.ts index 4a59cbd..c8d0877 100644 --- a/src/hooks/useBatchTrustScores.ts +++ b/src/hooks/useBatchTrustScores.ts @@ -15,7 +15,7 @@ export function useBatchTrustScores(pubkeys: string[]) { const { user } = useCurrentUser(); return useQuery({ - queryKey: ['batch-trust-scores', pubkeys.sort().join(','), user?.pubkey], + queryKey: ['batch-trust-scores', [...pubkeys].sort().join(','), user?.pubkey], queryFn: async (c) => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); const scores = new Map(); diff --git a/src/hooks/useGrowthStats.ts b/src/hooks/useGrowthStats.ts index 65b8375..5762c7a 100644 --- a/src/hooks/useGrowthStats.ts +++ b/src/hooks/useGrowthStats.ts @@ -174,8 +174,14 @@ function fillMonthGaps(keys: string[]): string[] { // ─── Hook ──────────────────────────────────────────────────────────────────── -export function useGrowthStats() { - // Community stats read from the CANONICAL community relay (goskatespots) only, +/** Stable, short hash of a string — keeps a query key small with many hidden items. */ +function tinyHash(input: string): number { + let h = 0; + for (let i = 0; i < input.length; i++) h = (h * 31 + input.charCodeAt(i)) | 0; + return h; +} + +export function useGrowthStats() { // Community stats read from the CANONICAL community relay (goskatespots) only, // so the numbers always match the authoritative community dataset — not a // blend of the user's personal relays that would drift per-device. const { queryCanonical } = useSkateRelays(); @@ -183,7 +189,11 @@ export function useGrowthStats() { // it, so a spot the community removed can't inflate the numbers. The moderation // set is part of the query key so the stats recompute once the labels load. const hidden = useAdminModeration(); - const hiddenSig = `${hidden.coords.size}:${hidden.ids.size}`; + // Sig must change when the SET changes, not just its size — hiding one item + // while unhiding another (same count) must still recompute the stats. + const hiddenSig = `${hidden.coords.size}:${hidden.ids.size}:${tinyHash( + `${[...hidden.coords].sort().join(',')}#${[...hidden.ids].sort().join(',')}`, + )}`; return useQuery({ queryKey: ['growth-stats', 'canonical', hiddenSig], diff --git a/src/hooks/useLocalReminders.ts b/src/hooks/useLocalReminders.ts index 7683a21..4e4e17a 100644 --- a/src/hooks/useLocalReminders.ts +++ b/src/hooks/useLocalReminders.ts @@ -26,7 +26,7 @@ function useMyRsvpCoords(): Set { const { user } = useCurrentUser(); const query = useQuery({ - queryKey: ['my-rsvp-coords', user?.pubkey, getQueryKeySuffix()], + queryKey: ['my-rsvp-coords', 'reminders', user?.pubkey, getQueryKeySuffix()], enabled: !!user?.pubkey, staleTime: 60_000, queryFn: async (c): Promise => { diff --git a/src/hooks/useNewsletterIssues.ts b/src/hooks/useNewsletterIssues.ts index f5b1446..77ff49f 100644 --- a/src/hooks/useNewsletterIssues.ts +++ b/src/hooks/useNewsletterIssues.ts @@ -22,7 +22,7 @@ export function useNewsletterIssues(limit = 30) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); return useQuery({ - queryKey: ['newsletter-issues', getQueryKeySuffix()], + queryKey: ['newsletter-issues', limit, getQueryKeySuffix()], staleTime: 5 * 60 * 1000, gcTime: 30 * 60 * 1000, queryFn: async (c): Promise => { diff --git a/src/hooks/usePreferences.ts b/src/hooks/usePreferences.ts index 324c96d..3e96995 100644 --- a/src/hooks/usePreferences.ts +++ b/src/hooks/usePreferences.ts @@ -59,13 +59,13 @@ export function usePreferences() { const setPreferences = useCallback( (patch: Partial>) => { const now = Math.floor(Date.now() / 1000); - setLocal((prev) => { - const next: Preferences = { ...prev, ...patch, updatedAt: now }; - void synced.patch(next); - return next; - }); + // Compute OUTSIDE the updater: a state updater must be pure (React may run + // it twice), and `synced.patch` publishes an encrypted event. + const next: Preferences = { ...local, ...patch, updatedAt: now }; + setLocal(next); + void synced.patch(next); }, - [setLocal, synced], + [local, setLocal, synced], ); return { preferences: local, setPreferences }; diff --git a/src/hooks/useSellerSales.ts b/src/hooks/useSellerSales.ts index 3e3886e..f95d807 100644 --- a/src/hooks/useSellerSales.ts +++ b/src/hooks/useSellerSales.ts @@ -86,7 +86,7 @@ export function useBatchSellerSales(pubkeys: string[]) { const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); return useQuery({ - queryKey: ['batch-seller-sales', pubkeys.sort().join(','), getQueryKeySuffix()], + queryKey: ['batch-seller-sales', [...pubkeys].sort().join(','), getQueryKeySuffix()], queryFn: async () => { if (pubkeys.length === 0) return new Map(); diff --git a/src/hooks/useSkateRelays.ts b/src/hooks/useSkateRelays.ts index 48e5b01..75d55bb 100644 --- a/src/hooks/useSkateRelays.ts +++ b/src/hooks/useSkateRelays.ts @@ -179,7 +179,7 @@ export function useSkateRelays() { */ const getQueryKeySuffix = (): string => { const relays = getReadRelays(); - return relays.sort().join(','); + return [...relays].sort().join(','); }; return { diff --git a/src/hooks/useSpotCommunityScore.ts b/src/hooks/useSpotCommunityScore.ts index ed7bd4c..100a4d7 100644 --- a/src/hooks/useSpotCommunityScore.ts +++ b/src/hooks/useSpotCommunityScore.ts @@ -124,11 +124,11 @@ export function communityLevel(breakdown: SpotCommunityBreakdown, score: number) export function useSpotCommunityScore( spot: Pick<{ pubkey: string; identifier: string; id: string }, 'pubkey' | 'identifier' | 'id'> | null | undefined, ) { - const { querySkateContent } = useSkateRelays(); + const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const coord = spot ? `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}` : undefined; const query = useQuery({ - queryKey: ['spot-community-score', coord, spot?.id], + queryKey: ['spot-community-score', coord, spot?.id, getQueryKeySuffix()], enabled: !!spot && !!coord, staleTime: 2 * 60 * 1000, queryFn: async () => { diff --git a/src/hooks/useZaps.ts b/src/hooks/useZaps.ts index 9c8e110..666b582 100644 --- a/src/hooks/useZaps.ts +++ b/src/hooks/useZaps.ts @@ -61,6 +61,7 @@ export function useZaps( const events = await nostr.query([{ kinds: [9735], '#a': [`${actualTarget.kind}:${actualTarget.pubkey}:${identifier}`], + limit: 500, }], { signal }); return events; } else { @@ -68,6 +69,7 @@ export function useZaps( const events = await nostr.query([{ kinds: [9735], '#e': [actualTarget.id], + limit: 500, }], { signal }); return events; } @@ -92,9 +94,12 @@ export function useZaps( // Method 1: amount tag (from zap request, sometimes copied to receipt) const amountTag = zap.tags.find(([name]) => name === 'amount')?.[1]; if (amountTag) { - const millisats = parseInt(amountTag); - sats += Math.floor(millisats / 1000); - return; + const millisats = Number(amountTag); + if (Number.isFinite(millisats) && millisats > 0) { + sats += Math.floor(millisats / 1000); + return; + } + // Malformed amount — fall through to the bolt11 method. } // Method 2: Extract from bolt11 invoice diff --git a/src/lib/types.ts b/src/lib/types.ts index 957cbe5..22a727b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1020,6 +1020,11 @@ export function parseSkateEvent(event: NostrEvent): SkateEvent | null { if (!d || !title || !start || !hasSkateboardingTag) return null; + // Reject a malformed timestamp — NaN in `start` poisons sorting and date + // rendering for the whole feed. + const startNum = Number(start); + if (!Number.isFinite(startNum) || startNum <= 0) return null; + // Validate title length if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return null; @@ -1039,8 +1044,8 @@ export function parseSkateEvent(event: NostrEvent): SkateEvent | null { identifier: d, title: title.slice(0, MAX_TITLE_LEN), description: event.content.slice(0, MAX_DESCRIPTION_LEN), - start: parseInt(start), - end: end ? parseInt(end) : undefined, + start: startNum, + end: end && Number.isFinite(Number(end)) ? Number(end) : undefined, geohash, location, image, @@ -3252,13 +3257,16 @@ export function parseSkateTour(event: NostrEvent): SkateTour | null { const geohash = event.tags.find(([name]) => name === 'g')?.[1]; const meetingPoint = event.tags.find(([name]) => name === 'meeting-point')?.[1]; const meetingCoordsTag = event.tags.find(([name]) => name === 'meeting-point-coords'); - const meetingPointCoords = meetingCoordsTag - ? { lat: parseFloat(meetingCoordsTag[1]), lon: parseFloat(meetingCoordsTag[2]) } + const mpLat = Number(meetingCoordsTag?.[1]); + const mpLon = Number(meetingCoordsTag?.[2]); + const meetingPointCoords = meetingCoordsTag && Number.isFinite(mpLat) && Number.isFinite(mpLon) + ? { lat: mpLat, lon: mpLon } : undefined; const image = event.tags.find(([name]) => name === 'image')?.[1]; const durationTag = event.tags.find(([name]) => name === 'duration'); - const duration = durationTag ? parseFloat(durationTag[1]) : undefined; + const durationNum = durationTag ? Number(durationTag[1]) : undefined; + const duration = durationNum !== undefined && Number.isFinite(durationNum) ? durationNum : undefined; const durationUnit = durationTag?.[2] as SkateTour['durationUnit'] || 'hours'; const skillLevel = event.tags.find(([name]) => name === 'skill-level')?.[1] as SkateTour['skillLevel'];