SkateSpots/src/hooks/useSpotStatus.ts
SkateSpots e016ee20bd fix: tier 3 — medium bugs & hardening
- Tour sponsor URL/logo sanitized before href/src (SponsorSlot) — same XSS
  guard EventSponsors already uses.
- Official SkateSpots badge only shows when the NIP-05 actually verifies
  (NIP19Page), not on a self-asserted handle.
- Community stats reads are null-guarded on persisted-cache fields
  (monthly/cumulativeSpots/topMappers) — no more crash on an old cache.
- Overpass response no longer assumes .elements (food layer).
- user-listings query key is memoized (was Date.now() → refetch storm).
- Pro grant requires the l=pro label (was accepted with none).
- Verify card RSVP maps accepted/tentative (was going/interested → showed
  'No RSVP' on a valid ticket).
- Expired listings can't be bought via a stale/shared URL.
- Batch spot photo/status hooks include the relay suffix and use
  querySkateContent (relay-policy parity with useSpotReports).
- Offline queue requeues rows stuck in 'publishing' after a crash.
- querySkateContent/queryCanonical dedupe replaceable events by author+kind
  (not id) so a stale profile/relay list can't win.
- Route offline cache key includes the origin (was destination+mode only).
- Internal links use <Link> (MarketplacePage); EventCountdown reacts to
  language changes.
2026-10-02 22:26:46 +07:00

281 lines
9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import {
SPOT_STATUS_KIND,
SKATE_SPOT_KIND,
parseSpotStatusReport,
type SpotStatusReport,
type SpotStatusValue,
} from '@/lib/types';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateRelays } from '@/hooks/useSkateRelays';
/** Reports older than this are ignored entirely (spots do change back). */
const REPORT_MAX_AGE_SECONDS = 180 * 24 * 60 * 60; // 180 days
/** Half-life for recency weighting — a report loses half its weight every 30 days. */
const RECENCY_HALF_LIFE_SECONDS = 30 * 24 * 60 * 60;
export interface AggregatedSpotStatus {
/** The winning (highest-weighted) status, or null if no reports. */
status: SpotStatusValue | null;
/** Confidence 0–1 = winning weight / total weight. */
confidence: number;
/** Number of distinct reporters contributing. */
reporterCount: number;
/** Timestamp of the most recent report used. */
lastReportedAt: number | null;
/** Per-status weighted tallies (for a breakdown UI if desired). */
tallies: Record<SpotStatusValue, number>;
}
function spotRefFor(pubkey: string, identifier: string): string {
return `${SKATE_SPOT_KIND}:${pubkey}:${identifier}`;
}
/**
* Compute a recency weight in (0, 1]. A brand-new report = 1; a report one
* half-life old = 0.5; and so on. This makes fresh reports dominate stale ones,
* so a spot that was "gone" but got rebuilt naturally recovers over time.
*/
function recencyWeight(createdAt: number, now: number): number {
const age = Math.max(0, now - createdAt);
return Math.pow(0.5, age / RECENCY_HALF_LIFE_SECONDS);
}
/**
* Aggregate a list of reports into a single consensus status.
*
* Each report contributes `recencyWeight × trustWeight`. Trust weight comes from
* the reporter's trust score (0–100) mapped to a floor of 0.2 so that even
* untrusted users have *some* voice, but followed/verified users dominate.
* Only the most recent report from each reporter counts (people change their
* minds; we don't let one person stuff the ballot).
*/
function aggregate(
reports: SpotStatusReport[],
trustScores: Map<string, number> | undefined,
now: number,
): AggregatedSpotStatus {
const empty: AggregatedSpotStatus = {
status: null,
confidence: 0,
reporterCount: 0,
lastReportedAt: null,
tallies: { active: 0, bust: 0, skate_stopped: 0, closed: 0, gone: 0 },
};
if (reports.length === 0) return empty;
// Keep only the most recent report per reporter, within the max age window.
const latestByReporter = new Map<string, SpotStatusReport>();
for (const r of reports) {
if (now - r.createdAt > REPORT_MAX_AGE_SECONDS) continue;
const existing = latestByReporter.get(r.pubkey);
if (!existing || r.createdAt > existing.createdAt) {
latestByReporter.set(r.pubkey, r);
}
}
if (latestByReporter.size === 0) return empty;
const tallies: Record<SpotStatusValue, number> = {
active: 0,
bust: 0,
skate_stopped: 0,
closed: 0,
gone: 0,
};
let totalWeight = 0;
let lastReportedAt = 0;
for (const r of latestByReporter.values()) {
const trust = trustScores?.get(r.pubkey) ?? 0;
// Map trust 0–100 → 0.2–1.0 so everyone counts a little, trusted users more.
const trustWeight = 0.2 + (Math.min(100, Math.max(0, trust)) / 100) * 0.8;
const weight = recencyWeight(r.createdAt, now) * trustWeight;
tallies[r.status] += weight;
totalWeight += weight;
if (r.createdAt > lastReportedAt) lastReportedAt = r.createdAt;
}
// Find the winning status.
let winning: SpotStatusValue = 'active';
let winningWeight = -1;
(Object.keys(tallies) as SpotStatusValue[]).forEach((s) => {
if (tallies[s] > winningWeight) {
winningWeight = tallies[s];
winning = s;
}
});
return {
status: winning,
confidence: totalWeight > 0 ? winningWeight / totalWeight : 0,
reporterCount: latestByReporter.size,
lastReportedAt: lastReportedAt || null,
tallies,
};
}
/**
* Fetch raw status reports for a SINGLE spot (used on the detail page).
*/
export function useSpotStatusReports(spotPubkey: string, spotIdentifier: string) {
const { nostr } = useNostr();
const spotRef = spotRefFor(spotPubkey, spotIdentifier);
return useQuery({
queryKey: ['spot-status-reports', spotRef],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
const events = await nostr.query(
[{ kinds: [SPOT_STATUS_KIND], '#a': [spotRef], limit: 200 }],
{ signal },
);
return events
.map(parseSpotStatusReport)
.filter((r): r is SpotStatusReport => r !== null)
.sort((a, b) => b.createdAt - a.createdAt);
},
enabled: !!spotPubkey && !!spotIdentifier,
staleTime: 60_000,
});
}
/**
* Aggregated consensus status for a SINGLE spot, trust- and recency-weighted.
*/
export function useSpotStatus(spotPubkey: string, spotIdentifier: string) {
const { data: reports, isLoading } = useSpotStatusReports(spotPubkey, spotIdentifier);
const reporterPubkeys = useMemo(
() => Array.from(new Set((reports ?? []).map((r) => r.pubkey))),
[reports],
);
const { data: trustScores } = useBatchTrustScores(reporterPubkeys);
const aggregated = useMemo(
() => aggregate(reports ?? [], trustScores, Math.floor(Date.now() / 1000)),
[reports, trustScores],
);
return { aggregated, reports: reports ?? [], isLoading };
}
/**
* Batch: aggregated status for MANY spots at once (used on the map + cards).
* Returns a Map keyed by spot coordinate (`30818:pubkey:dtag`).
*/
export function useBatchSpotStatus(spots: Array<{ pubkey: string; identifier: string }>) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const spotRefs = useMemo(
() => spots.map((s) => spotRefFor(s.pubkey, s.identifier)),
[spots],
);
const reportsQuery = useQuery({
queryKey: ['batch-spot-status', getQueryKeySuffix(), spotRefs.slice().sort().join(',')],
queryFn: async (c) => {
if (spotRefs.length === 0) return [] as NostrEvent[];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
// Relays index single-letter tags, so '#a' with many refs is one query.
const events = await querySkateContent(
[{ kinds: [SPOT_STATUS_KIND], '#a': spotRefs, limit: 1000 }],
{ signal },
);
return events;
},
enabled: spotRefs.length > 0,
staleTime: 60_000,
});
const reportsByRef = useMemo(() => {
const map = new Map<string, SpotStatusReport[]>();
for (const ev of reportsQuery.data ?? []) {
const report = parseSpotStatusReport(ev);
if (!report) continue;
const list = map.get(report.spotRef) ?? [];
list.push(report);
map.set(report.spotRef, list);
}
return map;
}, [reportsQuery.data]);
const reporterPubkeys = useMemo(() => {
const set = new Set<string>();
for (const list of reportsByRef.values()) {
for (const r of list) set.add(r.pubkey);
}
return Array.from(set);
}, [reportsByRef]);
const { data: trustScores } = useBatchTrustScores(reporterPubkeys);
const statusByRef = useMemo(() => {
const now = Math.floor(Date.now() / 1000);
const map = new Map<string, AggregatedSpotStatus>();
for (const [ref, list] of reportsByRef.entries()) {
map.set(ref, aggregate(list, trustScores, now));
}
return map;
}, [reportsByRef, trustScores]);
return {
statusByRef,
isLoading: reportsQuery.isLoading,
getStatus: (pubkey: string, identifier: string) =>
statusByRef.get(spotRefFor(pubkey, identifier)) ?? null,
};
}
/**
* Publish a new status report for a spot.
*/
export function usePublishSpotStatus() {
const { mutateAsync: publishEvent } = useNostrPublish();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (params: {
spotPubkey: string;
spotIdentifier: string;
spotTitle: string;
status: SpotStatusValue;
note?: string;
}) => {
if (!user) throw new Error('You must be logged in to report a spot status.');
const spotRef = spotRefFor(params.spotPubkey, params.spotIdentifier);
await publishEvent({
kind: SPOT_STATUS_KIND,
content: (params.note ?? '').slice(0, 500),
tags: [
['a', spotRef],
['K', String(SKATE_SPOT_KIND)],
['status', params.status],
['t', 'spot-status'],
[
'alt',
`Spot status report: "${params.spotTitle}" is ${params.status.replace('_', '-')}`,
],
],
});
return { spotRef };
},
onSuccess: ({ spotRef }) => {
queryClient.invalidateQueries({ queryKey: ['spot-status-reports', spotRef] });
queryClient.invalidateQueries({ queryKey: ['batch-spot-status'] });
},
});
}