- 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.
281 lines
9 KiB
TypeScript
281 lines
9 KiB
TypeScript
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'] });
|
||
},
|
||
});
|
||
}
|