SkateSpots/src/hooks/useListingRestrictions.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

291 lines
9.8 KiB
TypeScript

import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { MARKETPLACE_ITEM_KIND, SKATE_SPOT_KIND, SKATE_RELAY } from '@/lib/types';
import { verifyNip05 } from '@/lib/nip05';
// Platform fee destination (single source of truth: src/lib/platform.ts)
import { PLATFORM_LIGHTNING_ADDRESS } from '@/lib/platform';
export { PLATFORM_LIGHTNING_ADDRESS };
// Platform owner pubkeys - always trusted, never pay fees
export const PLATFORM_OWNER_PUBKEYS = [
'20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21', // npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx
'caba497f7beda0650d6b78ab211216e4d2ebabeec9ca6eb2a05cee8e55c6871b', // npub1e2ayjlmmaksx2rtt0z4jzyskunfwh2lwe89xav4qtnhgu4wxsudsp98a4l
];
// Trust levels based on activity
export type TrustLevel = 'unknown' | 'newcomer' | 'active' | 'trusted';
// Listing limits per trust level (per 30-day period)
export const LISTING_LIMITS: Record<TrustLevel, number> = {
unknown: 0, // No NIP-05 = can't list at all
newcomer: 3, // Has NIP-05 but no activity yet - REQUIRES FEE
active: 10, // Has spots or 2+ listings - NO FEE
trusted: 50, // Established member (spots + listings) - NO FEE
};
// Cooldown in milliseconds (24 hours for unknown users)
export const COOLDOWN_MS = 24 * 60 * 60 * 1000;
// Fee in sats for unknown users
export const LISTING_FEE_SATS = 1000;
interface ListingRestrictions {
trustLevel: TrustLevel;
listingLimit: number;
listingsThisMonth: number;
remainingListings: number;
canCreateListing: boolean;
requiresFee: boolean;
feeRecipient: string | null; // Lightning address - spot owner or platform
feeRecipientName: string | null; // Name for display
cooldownEndsAt: number | null;
isInCooldown: boolean;
timeUntilCooldownEnds: number | null; // milliseconds
hasNip05: boolean;
isPlatformOwner: boolean; // Platform owners bypass all restrictions
hasSpotsCreated: boolean;
hasListingsCreated: boolean;
isLoading: boolean;
}
interface LastListingRecord {
timestamp: number;
spotOwnerPubkey?: string;
}
/**
* Hook to manage listing restrictions based on user trust level
*/
export function useListingRestrictions(linkedSpotPubkey?: string): ListingRestrictions {
const { nostr } = useNostr();
const { user, metadata } = useCurrentUser();
const [lastListingRecord] = useLocalStorage<LastListingRecord | null>(
'last-listing-record',
null
);
// SECURITY: NIP-05 must be VERIFIED against the user's own key, not merely
// present in their self-authored kind-0. Otherwise anyone can type a fake
// handle and skip the new-account listing fee / limit.
const { data: nip05Status } = useQuery({
queryKey: ['nip05-verify', user?.pubkey, metadata?.nip05],
enabled: !!user?.pubkey && !!metadata?.nip05,
staleTime: 10 * 60 * 1000,
queryFn: async (c) => {
if (!user?.pubkey || !metadata?.nip05) return { status: 'none' as const };
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
return verifyNip05(metadata.nip05, user.pubkey, signal).catch(() => ({
status: 'unreachable' as const,
}));
},
});
const hasNip05 = nip05Status?.status === 'verified';
// Check if user is a platform owner (bypasses all restrictions)
const isPlatformOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
// Query user's spots
const { data: userSpots, isLoading: spotsLoading } = useQuery({
queryKey: ['user-spots', user?.pubkey],
queryFn: async (c) => {
if (!user?.pubkey) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
const relay = nostr.relay(SKATE_RELAY);
const events = await relay.query([{
kinds: [SKATE_SPOT_KIND],
authors: [user.pubkey],
limit: 50,
}], { signal });
return events;
},
enabled: !!user?.pubkey,
staleTime: 60000,
});
// Query user's listings (last 30 days)
// Day-granular + memoized: computing this from Date.now() each render changed
// the query key continuously and caused a refetch storm.
const thirtyDaysAgo = useMemo(
() => Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000),
[],
);
const { data: userListings, isLoading: listingsLoading } = useQuery({
queryKey: ['user-listings', user?.pubkey, thirtyDaysAgo],
queryFn: async (c) => {
if (!user?.pubkey) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
const relay = nostr.relay(SKATE_RELAY);
const events = await relay.query([{
kinds: [MARKETPLACE_ITEM_KIND],
authors: [user.pubkey],
since: thirtyDaysAgo,
limit: 100,
}], { signal });
return events;
},
enabled: !!user?.pubkey,
staleTime: 30000,
});
// Query spot owner's metadata if we have a linked spot
const { data: spotOwnerData } = useQuery({
queryKey: ['spot-owner', linkedSpotPubkey],
queryFn: async (c) => {
if (!linkedSpotPubkey) return null;
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
const events = await nostr.query([{
kinds: [0],
authors: [linkedSpotPubkey],
limit: 1,
}], { signal });
if (events.length === 0) return null;
try {
const metadata = JSON.parse(events[0].content);
return {
pubkey: linkedSpotPubkey,
lud16: metadata.lud16 || null,
name: metadata.name || metadata.display_name || null,
};
} catch {
return null;
}
},
enabled: !!linkedSpotPubkey,
staleTime: 300000,
});
const isLoading = spotsLoading || listingsLoading;
// Calculate trust level
const trustLevel = useMemo((): TrustLevel => {
if (!user) return 'unknown';
// Platform owners are ALWAYS trusted - no exceptions, no other checks needed
// This must be checked FIRST before any other conditions
if (PLATFORM_OWNER_PUBKEYS.includes(user.pubkey)) {
return 'trusted';
}
// For non-platform-owners, NIP-05 is required
if (!hasNip05) return 'unknown';
// While loading, assume user is at least active if they have NIP-05
// This prevents showing the fee payment UI while data loads
// Better to temporarily give benefit of the doubt than show fee to trusted users
if (isLoading && hasNip05) {
return 'active';
}
const spotsCreated = userSpots?.length || 0;
const listingsCreated = userListings?.length || 0;
// Trusted: Has created spots AND has successful listings
if (spotsCreated >= 1 && listingsCreated >= 3) {
return 'trusted';
}
// Active: Has created spots OR has multiple listings
if (spotsCreated >= 1 || listingsCreated >= 2) {
return 'active';
}
// Newcomer: Has NIP-05 but limited activity
if (hasNip05) {
return 'newcomer';
}
return 'unknown';
}, [user, hasNip05, userSpots, userListings, isLoading]);
// Calculate cooldown status (only for unknown users)
const cooldownStatus = useMemo(() => {
if (trustLevel !== 'unknown' || !lastListingRecord) {
return { isInCooldown: false, cooldownEndsAt: null, timeUntilCooldownEnds: null };
}
const cooldownEndsAt = lastListingRecord.timestamp + COOLDOWN_MS;
const now = Date.now();
const isInCooldown = now < cooldownEndsAt;
const timeUntilCooldownEnds = isInCooldown ? cooldownEndsAt - now : null;
return { isInCooldown, cooldownEndsAt, timeUntilCooldownEnds };
}, [trustLevel, lastListingRecord]);
// Calculate listings this month
const listingsThisMonth = userListings?.length || 0;
const listingLimit = LISTING_LIMITS[trustLevel];
const remainingListings = Math.max(0, listingLimit - listingsThisMonth);
// Determine if fee is required - only for newcomers (NIP-05 but no activity yet)
// Users without NIP-05 can't list at all, so we don't need to handle that case here
const requiresFee = trustLevel === 'newcomer';
// Determine fee recipient
const feeRecipient = useMemo(() => {
if (!requiresFee) return null;
// If spot has owner with lightning address, pay them
if (spotOwnerData?.lud16) {
return spotOwnerData.lud16;
}
// Otherwise pay platform
return PLATFORM_LIGHTNING_ADDRESS;
}, [requiresFee, spotOwnerData]);
const feeRecipientName = useMemo(() => {
if (!requiresFee) return null;
if (spotOwnerData?.lud16) {
return spotOwnerData.name || 'Spot Owner';
}
return 'SkateSpots';
}, [requiresFee, spotOwnerData]);
// Can create listing?
const canCreateListing = useMemo(() => {
if (!user) return false;
// Platform owners can always create listings
if (isPlatformOwner) return true;
if (!hasNip05) return false;
if (remainingListings <= 0) return false;
if (cooldownStatus.isInCooldown) return false;
return true;
}, [user, isPlatformOwner, hasNip05, remainingListings, cooldownStatus.isInCooldown]);
return {
trustLevel,
listingLimit,
listingsThisMonth,
remainingListings,
canCreateListing,
requiresFee,
feeRecipient,
feeRecipientName,
cooldownEndsAt: cooldownStatus.cooldownEndsAt,
isInCooldown: cooldownStatus.isInCooldown,
timeUntilCooldownEnds: cooldownStatus.timeUntilCooldownEnds,
hasNip05,
isPlatformOwner,
hasSpotsCreated: (userSpots?.length || 0) > 0,
hasListingsCreated: (userListings?.length || 0) > 0,
isLoading,
};
}
/**
* Record a listing creation for cooldown tracking
*/
export function recordListingCreation(
setLastListingRecord: (record: LastListingRecord) => void,
spotOwnerPubkey?: string
) {
setLastListingRecord({
timestamp: Date.now(),
spotOwnerPubkey,
});
}