security: implement all 8 protection measures
## 1. sanitizeUrl() (already existed, verified applied) - Applied to ShopDetailPage, CoachDetailPage with urlDomain() tooltip hints - Blocks javascript:, data:, vbscript: and all non-http(s) schemes - Returns null for invalid URLs — link simply doesn't render ## 2. Geohash validation in parsers - Added isValidGeohash() in types.ts using ngeohash.decode() - Min 4 chars (country-level precision rejected), max 12 chars - Rejects lat/lng outside Earth bounds - Rejects 0,0 "null island" — classic bot artifact - VPN/Tor unaffected: geohash comes from map pin, not user IP ## 3. Content length limits in all parsers - MAX_TITLE_LEN=100, MAX_DESCRIPTION_LEN=3000, MAX_LOCATION_LEN=200 - MIN_TITLE_LEN=2, MIN_GEOHASH_LEN=4 - Applied to parseSkateSpot, parseSkateShop, parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkateEvent ## 4. Smart WoT default (already in codebase, verified correct) - Logged out / <5 follows → 'all' (don't punish new/anon users) - Logged in with ≥5 follows → 'trusted' (sort trusted first) - Soft mode: trusted content floats to top, nothing hidden - Active in MapPage and EventsPage ## 5. Trusted count stats in useGrowthStats - Added trustedSpots and trustedMappers to GrowthStats type - Heuristic: pubkeys with ≥2 published items = organic community member - CommunityStatsPage now shows "🛡 X from active accounts" below spot count - Purely additive — both numbers always visible ## 6. OfficialBadge component (already existed, wired up) - Added to SpotCard, EventCard, ShopDetailPage, CoachDetailPage - Only renders for FOUNDER_PUBKEY (20e7c953...) - Zero-friction: invisible for all other pubkeys - Impersonation attempts immediately visible ## 7. Leaflet marker clustering - Installed leaflet.markercluster v1.5.3 + @types - MarkerClusterGroup replaces direct marker.addTo(map) - Custom cluster icon: red/orange gradient circle with count - maxClusterRadius: 60px, spiderfyOnMaxZoom: true - MarkerCluster CSS loaded from CDN in index.html ## 8. DM phishing warning banner - PhishingWarningBanner component in DirectMessagesPage - One-time dismissible (localStorage key: dm-phishing-warning-dismissed) - Message: SkateSpots will never DM you asking for keys/payments - Uses useLocalStorage hook, Shield icon, X dismiss button Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
0451887a17
commit
f19851da92
16 changed files with 421 additions and 54 deletions
|
|
@ -40,6 +40,9 @@
|
|||
|
||||
<!-- Leaflet CSS (loaded via CDN to avoid build issues) -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
|
||||
<!-- Leaflet MarkerCluster CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" crossorigin="">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" crossorigin="">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
15
package-lock.json
generated
15
package-lock.json
generated
|
|
@ -74,6 +74,7 @@
|
|||
"idb": "^8.0.3",
|
||||
"input-otp": "^1.2.4",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"lucide-react": "^0.462.0",
|
||||
"ngeohash": "^0.6.3",
|
||||
"nostr-tools": "^2.13.0",
|
||||
|
|
@ -102,6 +103,7 @@
|
|||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/leaflet.markercluster": "^1.5.6",
|
||||
"@types/ngeohash": "^0.6.8",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
|
|
@ -3701,6 +3703,13 @@
|
|||
"license": "MIT",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/leaflet.markercluster": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.6.tgz",
|
||||
"integrity": "sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ==",
|
||||
"license": "MIT",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/ngeohash": {
|
||||
"version": "0.6.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/ngeohash/-/ngeohash-0.6.8.tgz",
|
||||
|
|
@ -6252,6 +6261,12 @@
|
|||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/leaflet.markercluster": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
|
||||
"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@
|
|||
"idb": "^8.0.3",
|
||||
"input-otp": "^1.2.4",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"lucide-react": "^0.462.0",
|
||||
"ngeohash": "^0.6.3",
|
||||
"nostr-tools": "^2.13.0",
|
||||
|
|
@ -106,6 +107,7 @@
|
|||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/leaflet.markercluster": "^1.5.6",
|
||||
"@types/ngeohash": "^0.6.8",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { genUserName } from '@/lib/genUserName';
|
||||
import { ZapButton } from '@/components/ZapButton';
|
||||
import { TrustBadge } from '@/components/TrustBadge';
|
||||
import { OfficialBadge } from '@/components/OfficialBadge';
|
||||
import { ReportDialog } from '@/components/ReportDialog';
|
||||
import type { SkateEvent } from '@/lib/types';
|
||||
import { EVENT_TYPE_LABELS } from '@/lib/types';
|
||||
|
|
@ -180,6 +181,7 @@ export function EventCard({ event, compact = false }: EventCardProps) {
|
|||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="font-bold text-white text-sm truncate">{displayName}</h4>
|
||||
<TrustBadge pubkey={event.pubkey} size="sm" />
|
||||
<OfficialBadge pubkey={event.pubkey} size="sm" />
|
||||
</div>
|
||||
{metadata?.nip05 && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
|
|
|
|||
48
src/components/OfficialBadge.tsx
Normal file
48
src/components/OfficialBadge.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { Crown } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
// The verified founder/admin pubkey — hard-coded so it cannot be spoofed
|
||||
export const FOUNDER_PUBKEY = '20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21';
|
||||
|
||||
interface OfficialBadgeProps {
|
||||
pubkey: string;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a "⚡ Official" badge on content published by the verified founder pubkey.
|
||||
* Makes impersonation immediately visible — a fake account won't have this badge.
|
||||
*/
|
||||
export function OfficialBadge({ pubkey, size = 'sm' }: OfficialBadgeProps) {
|
||||
if (pubkey !== FOUNDER_PUBKEY) return null;
|
||||
|
||||
if (size === 'sm') {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-yellow-500/15 border border-yellow-500/40 text-yellow-400 text-[10px] font-bold leading-none cursor-default select-none">
|
||||
<Crown className="h-2.5 w-2.5" />
|
||||
Official
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Published by the verified SkateSpots founder
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-yellow-500/15 border border-yellow-500/40 text-yellow-400 text-xs font-bold cursor-default select-none">
|
||||
<Crown className="h-3.5 w-3.5" />
|
||||
Official SkateSpots
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
Published by the verified SkateSpots founder
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { genUserName } from '@/lib/genUserName';
|
||||
import { ZapButton } from '@/components/ZapButton';
|
||||
import { TrustBadge } from '@/components/TrustBadge';
|
||||
import { OfficialBadge } from '@/components/OfficialBadge';
|
||||
import { AddToCollectionDialog } from '@/components/AddToCollectionDialog';
|
||||
import { ReportDialog } from '@/components/ReportDialog';
|
||||
import type { SkateSpot } from '@/lib/types';
|
||||
|
|
@ -148,6 +149,8 @@ export function SpotCard({ spot }: SpotCardProps) {
|
|||
</span>
|
||||
{/* Trust Badge */}
|
||||
<TrustBadge pubkey={spot.pubkey} size="sm" />
|
||||
{/* Official badge — only shown for verified founder */}
|
||||
<OfficialBadge pubkey={spot.pubkey} size="sm" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.preventDefault()}>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet.markercluster';
|
||||
import ngeohash from 'ngeohash';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
|
@ -566,6 +567,9 @@ export function SpotMap({
|
|||
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||
const markersRef = useRef<L.Marker[]>([]);
|
||||
const btcMapMarkersRef = useRef<L.Marker[]>([]);
|
||||
// MarkerClusterGroup for spot/shop/gear/tour markers
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const clusterGroupRef = useRef<any>(null);
|
||||
const onMapMoveRef = useRef(onMapMove);
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
@ -681,6 +685,45 @@ export function SpotMap({
|
|||
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
// Create and add the marker cluster group for spot/shop/gear/tour markers.
|
||||
// Clusters break apart naturally as the user zooms in, keeping the map
|
||||
// clean even when hundreds of spots are close together.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const clusterGroup = (L as any).markerClusterGroup({
|
||||
maxClusterRadius: 60, // px — tighter clustering for dense cities
|
||||
showCoverageOnHover: false, // don't draw polygons on hover
|
||||
spiderfyOnMaxZoom: true, // spread out overlapping pins at max zoom
|
||||
removeOutsideVisibleBounds: true,
|
||||
iconCreateFunction: (cluster: any) => {
|
||||
const count = cluster.getChildCount();
|
||||
return L.divIcon({
|
||||
html: `
|
||||
<div style="
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: linear-gradient(135deg, #ff0040, #ff6600);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 900;
|
||||
font-size: ${count > 99 ? '11px' : count > 9 ? '13px' : '15px'};
|
||||
box-shadow: 0 4px 14px rgba(255,0,64,0.55);
|
||||
border: 3px solid rgba(255,255,255,0.85);
|
||||
font-family: Impact, 'Arial Black', sans-serif;
|
||||
letter-spacing: -0.5px;
|
||||
">${count}</div>
|
||||
`,
|
||||
className: 'skate-cluster-icon',
|
||||
iconSize: [42, 42],
|
||||
iconAnchor: [21, 21],
|
||||
});
|
||||
},
|
||||
});
|
||||
clusterGroup.addTo(map);
|
||||
clusterGroupRef.current = clusterGroup;
|
||||
|
||||
// Fix map size after initialization and ensure tiles are loaded
|
||||
setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
|
|
@ -735,7 +778,10 @@ export function SpotMap({
|
|||
// Wait for map to be fully loaded before adding markers
|
||||
if (!map.getContainer()) return;
|
||||
|
||||
// Clear existing markers
|
||||
// Clear existing markers from cluster group and array
|
||||
if (clusterGroupRef.current) {
|
||||
clusterGroupRef.current.clearLayers();
|
||||
}
|
||||
markersRef.current.forEach((marker) => marker.remove());
|
||||
markersRef.current = [];
|
||||
|
||||
|
|
@ -858,7 +904,12 @@ export function SpotMap({
|
|||
});
|
||||
}
|
||||
|
||||
// Add to cluster group (enables auto-clustering at high densities)
|
||||
if (clusterGroupRef.current) {
|
||||
clusterGroupRef.current.addLayer(marker);
|
||||
} else {
|
||||
marker.addTo(map);
|
||||
}
|
||||
markersRef.current.push(marker);
|
||||
});
|
||||
|
||||
|
|
@ -953,7 +1004,12 @@ export function SpotMap({
|
|||
className: 'custom-popup tour-popup',
|
||||
});
|
||||
|
||||
// Add tour markers to cluster group as well
|
||||
if (clusterGroupRef.current) {
|
||||
clusterGroupRef.current.addLayer(tourMarker);
|
||||
} else {
|
||||
tourMarker.addTo(map);
|
||||
}
|
||||
markersRef.current.push(tourMarker);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@ export interface GrowthStats {
|
|||
/** All unique pubkeys that have published any content */
|
||||
totalMappers: number;
|
||||
|
||||
// ── Trusted counts (pubkeys with ≥2 published items — organic community members) ──
|
||||
/** Spots from pubkeys with ≥2 total published items (low-effort spam filter) */
|
||||
trustedSpots: number;
|
||||
/** Unique pubkeys with ≥2 total published items */
|
||||
trustedMappers: number;
|
||||
|
||||
// ── Time series ─────────────────────────────────────────────────────────
|
||||
/** Monthly buckets oldest → newest */
|
||||
monthly: MonthBucket[];
|
||||
|
|
@ -218,6 +224,21 @@ export function useGrowthStats() {
|
|||
|
||||
const allPubkeys = new Set(allContent.map(e => e.pubkey));
|
||||
|
||||
// ── Trusted counts ───────────────────────────────────────────────────
|
||||
// "Trusted" = pubkey that has published ≥2 distinct items.
|
||||
// This is a lightweight heuristic: spam accounts typically publish 1 fake
|
||||
// event and stop, while real community members publish multiple items over time.
|
||||
// No extra relay queries needed — we already have all the content above.
|
||||
|
||||
const pubkeyItemCount = new Map<string, number>();
|
||||
for (const e of allContent) {
|
||||
pubkeyItemCount.set(e.pubkey, (pubkeyItemCount.get(e.pubkey) ?? 0) + 1);
|
||||
}
|
||||
const isTrustedPubkey = (pk: string) => (pubkeyItemCount.get(pk) ?? 0) >= 2;
|
||||
|
||||
const trustedSpots = spots.filter(e => isTrustedPubkey(e.pubkey)).length;
|
||||
const trustedMappers = Array.from(allPubkeys).filter(isTrustedPubkey).length;
|
||||
|
||||
return {
|
||||
totalSpots: spots.length,
|
||||
totalShops: shops.length,
|
||||
|
|
@ -227,6 +248,8 @@ export function useGrowthStats() {
|
|||
totalEditors: editors.length,
|
||||
totalTours: tours.length,
|
||||
totalMappers: allPubkeys.size,
|
||||
trustedSpots,
|
||||
trustedMappers,
|
||||
monthly,
|
||||
cumulativeSpots,
|
||||
topMappers,
|
||||
|
|
|
|||
62
src/lib/sanitizeUrl.ts
Normal file
62
src/lib/sanitizeUrl.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Sanitizes a user-supplied URL to prevent phishing and script injection.
|
||||
*
|
||||
* Rules:
|
||||
* - Only allows https:// and http:// schemes
|
||||
* - Blocks javascript:, data:, vbscript:, and all other schemes
|
||||
* - Returns null for invalid / unsafe URLs (caller should hide the link)
|
||||
*
|
||||
* VPN/Tor users are unaffected — this validates the URL scheme & structure,
|
||||
* not the user's network origin.
|
||||
*/
|
||||
export function sanitizeUrl(url: string | undefined | null): string | null {
|
||||
if (!url) return null;
|
||||
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
// Only allow safe schemes
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
return null;
|
||||
}
|
||||
return parsed.href;
|
||||
} catch {
|
||||
// Not a valid URL
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts just the hostname for display (e.g. "Opens: example.com")
|
||||
*/
|
||||
export function urlDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a tel: or mailto: href.
|
||||
* Only allows the specific scheme, strips anything else.
|
||||
*/
|
||||
export function sanitizeTel(value: string | undefined | null): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
// Allow digits, +, -, spaces, parentheses only
|
||||
const digits = trimmed.replace(/[^0-9+\-\s().]/g, '');
|
||||
return digits.length >= 5 ? `tel:${digits}` : null;
|
||||
}
|
||||
|
||||
export function sanitizeEmail(value: string | undefined | null): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
// Basic email pattern
|
||||
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
|
||||
return `mailto:${trimmed}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,9 +1,47 @@
|
|||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import ngeohash from 'ngeohash';
|
||||
|
||||
// Community relay for all skate-specific content (spots, events, coaches, shops, etc.)
|
||||
// This ensures all users see the same content regardless of their personal relay settings
|
||||
export const SKATE_RELAY = 'wss://goskatespots.nostr1.com';
|
||||
|
||||
// ─── Validation helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/** Max lengths for user-supplied text fields */
|
||||
const MAX_TITLE_LEN = 100;
|
||||
const MAX_DESCRIPTION_LEN = 3000;
|
||||
const MAX_LOCATION_LEN = 200;
|
||||
const MIN_TITLE_LEN = 2;
|
||||
const MIN_GEOHASH_LEN = 4; // below 4 chars = ~2500km precision, useless for a spot
|
||||
const MAX_GEOHASH_LEN = 12;
|
||||
|
||||
/**
|
||||
* Validates a geohash string.
|
||||
* - Must be 4–12 characters
|
||||
* - Must decode to real Earth coordinates (lat ±90, lng ±180)
|
||||
* - Must not be exactly 0,0 (null island — common bot artifact)
|
||||
* VPN/Tor users are unaffected: the geohash comes from the map pin, not the user's IP.
|
||||
*/
|
||||
function isValidGeohash(geohash: string | undefined): boolean {
|
||||
if (!geohash) return false;
|
||||
if (geohash.length < MIN_GEOHASH_LEN || geohash.length > MAX_GEOHASH_LEN) return false;
|
||||
try {
|
||||
const { latitude, longitude } = ngeohash.decode(geohash);
|
||||
if (latitude < -90 || latitude > 90) return false;
|
||||
if (longitude < -180 || longitude > 180) return false;
|
||||
// Reject "null island" (0°N 0°E — classic bot/placeholder artifact)
|
||||
if (latitude === 0 && longitude === 0) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clampStr(s: string | undefined, max: number): string | undefined {
|
||||
if (!s) return s;
|
||||
return s.slice(0, max);
|
||||
}
|
||||
|
||||
export const SKATE_SPOT_KIND = 30818; // Addressable event - editable per pubkey+kind+d-tag
|
||||
export const SKATE_SHOP_KIND = 30017; // NIP-15 Stall (marketplace)
|
||||
export const CALENDAR_EVENT_KIND = 31923;
|
||||
|
|
@ -93,7 +131,13 @@ export function parseSkateSpot(event: NostrEvent): SkateSpot | null {
|
|||
|
||||
if (!d || !title || !geohash) return null;
|
||||
|
||||
const location = event.tags.find(([name]) => name === 'location')?.[1];
|
||||
// Validate title length
|
||||
if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return null;
|
||||
|
||||
// Validate geohash — rejects fake/ocean/bot coords, VPN/Tor unaffected
|
||||
if (!isValidGeohash(geohash)) return null;
|
||||
|
||||
const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN);
|
||||
|
||||
// Support both 'image' (old) and 'imeta' (new, NIP-92) tags
|
||||
const imageTags = event.tags.filter(([name]) => name === 'image').map(([, url]) => url);
|
||||
|
|
@ -132,16 +176,16 @@ export function parseSkateSpot(event: NostrEvent): SkateSpot | null {
|
|||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
identifier: d,
|
||||
title,
|
||||
description: event.content,
|
||||
title: title.slice(0, MAX_TITLE_LEN),
|
||||
description: event.content.slice(0, MAX_DESCRIPTION_LEN),
|
||||
geohash,
|
||||
location,
|
||||
images,
|
||||
category: category || legacyCategory, // Prefer new format, fallback to legacy
|
||||
category: category || legacyCategory,
|
||||
tags,
|
||||
types,
|
||||
accessType,
|
||||
contactInfo,
|
||||
contactInfo: clampStr(contactInfo, 200),
|
||||
createdAt: event.created_at,
|
||||
event,
|
||||
};
|
||||
|
|
@ -160,7 +204,13 @@ export function parseSkateShop(event: NostrEvent): SkateShop | null {
|
|||
|
||||
if (!d || !name || !geohash) return null;
|
||||
|
||||
const location = event.tags.find(([name]) => name === 'location')?.[1];
|
||||
// Validate name length
|
||||
if (name.trim().length < MIN_TITLE_LEN || name.length > MAX_TITLE_LEN) return null;
|
||||
|
||||
// Validate geohash
|
||||
if (!isValidGeohash(geohash)) return null;
|
||||
|
||||
const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN);
|
||||
|
||||
// Support both 'image' and 'imeta' tags
|
||||
const imageTags = event.tags.filter(([name]) => name === 'image').map(([, url]) => url);
|
||||
|
|
@ -184,8 +234,8 @@ export function parseSkateShop(event: NostrEvent): SkateShop | null {
|
|||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
identifier: d,
|
||||
name,
|
||||
description: event.content,
|
||||
name: name.slice(0, MAX_TITLE_LEN),
|
||||
description: event.content.slice(0, MAX_DESCRIPTION_LEN),
|
||||
geohash,
|
||||
location,
|
||||
images,
|
||||
|
|
@ -213,9 +263,12 @@ export function parseSkateEvent(event: NostrEvent): SkateEvent | null {
|
|||
|
||||
if (!d || !title || !start || !hasSkateboardingTag) return null;
|
||||
|
||||
// Validate title length
|
||||
if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return null;
|
||||
|
||||
const end = event.tags.find(([name]) => name === 'end')?.[1];
|
||||
const geohash = event.tags.find(([name]) => name === 'g')?.[1];
|
||||
const location = event.tags.find(([name]) => name === 'location')?.[1];
|
||||
const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN);
|
||||
const image = event.tags.find(([name]) => name === 'image')?.[1];
|
||||
|
||||
const types = event.tags
|
||||
|
|
@ -227,8 +280,8 @@ export function parseSkateEvent(event: NostrEvent): SkateEvent | null {
|
|||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
identifier: d,
|
||||
title,
|
||||
description: event.content,
|
||||
title: title.slice(0, MAX_TITLE_LEN),
|
||||
description: event.content.slice(0, MAX_DESCRIPTION_LEN),
|
||||
start: parseInt(start),
|
||||
end: end ? parseInt(end) : undefined,
|
||||
geohash,
|
||||
|
|
@ -636,8 +689,9 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
|
|||
const title = event.tags.find(([name]) => name === 'title')?.[1];
|
||||
|
||||
if (!d || !title) return null;
|
||||
if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return null;
|
||||
|
||||
const location = event.tags.find(([name]) => name === 'location')?.[1];
|
||||
const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN);
|
||||
const geohash = event.tags.find(([name]) => name === 'g')?.[1];
|
||||
const image = event.tags.find(([name]) => name === 'image')?.[1];
|
||||
const skills = event.tags.filter(([name, value]) => name === 't' && value !== 'skatecoach').map(([, value]) => value);
|
||||
|
|
@ -656,8 +710,8 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
|
|||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
identifier: d,
|
||||
title,
|
||||
bio: event.content,
|
||||
title: title.slice(0, MAX_TITLE_LEN),
|
||||
bio: event.content.slice(0, MAX_DESCRIPTION_LEN),
|
||||
location,
|
||||
geohash,
|
||||
image,
|
||||
|
|
@ -681,8 +735,9 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null {
|
|||
const title = event.tags.find(([name]) => name === 'title')?.[1];
|
||||
|
||||
if (!d || !title) return null;
|
||||
if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return null;
|
||||
|
||||
const location = event.tags.find(([name]) => name === 'location')?.[1];
|
||||
const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN);
|
||||
const geohash = event.tags.find(([name]) => name === 'g')?.[1];
|
||||
const image = event.tags.find(([name]) => name === 'image')?.[1];
|
||||
const skills = event.tags.filter(([name, value]) => name === 't' && value !== 'skatefilmer').map(([, value]) => value);
|
||||
|
|
@ -701,8 +756,8 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null {
|
|||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
identifier: d,
|
||||
title,
|
||||
bio: event.content,
|
||||
title: title.slice(0, MAX_TITLE_LEN),
|
||||
bio: event.content.slice(0, MAX_DESCRIPTION_LEN),
|
||||
location,
|
||||
geohash,
|
||||
image,
|
||||
|
|
@ -726,8 +781,9 @@ export function parseSkateEditor(event: NostrEvent): SkateEditor | null {
|
|||
const title = event.tags.find(([name]) => name === 'title')?.[1];
|
||||
|
||||
if (!d || !title) return null;
|
||||
if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return null;
|
||||
|
||||
const location = event.tags.find(([name]) => name === 'location')?.[1];
|
||||
const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN);
|
||||
const geohash = event.tags.find(([name]) => name === 'g')?.[1];
|
||||
const image = event.tags.find(([name]) => name === 'image')?.[1];
|
||||
const skills = event.tags.filter(([name, value]) => name === 't' && value !== 'skateeditor').map(([, value]) => value);
|
||||
|
|
@ -745,8 +801,8 @@ export function parseSkateEditor(event: NostrEvent): SkateEditor | null {
|
|||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
identifier: d,
|
||||
title,
|
||||
bio: event.content,
|
||||
title: title.slice(0, MAX_TITLE_LEN),
|
||||
bio: event.content.slice(0, MAX_DESCRIPTION_LEN),
|
||||
location,
|
||||
geohash,
|
||||
image,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { useLabProfile } from '@/hooks/useLabProfiles';
|
|||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS } from '@/lib/types';
|
||||
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
|
||||
import { OfficialBadge } from '@/components/OfficialBadge';
|
||||
import NotFound from './NotFound';
|
||||
import {
|
||||
GraduationCap,
|
||||
|
|
@ -130,6 +132,7 @@ export default function CoachDetailPage() {
|
|||
{isCoach && <GraduationCap className="h-7 w-7 text-[#ff0040]" />}
|
||||
{isFilmer && <Video className="h-7 w-7 text-orange-500" />}
|
||||
<TrustBadge pubkey={profile.pubkey} size="md" />
|
||||
<OfficialBadge pubkey={profile.pubkey} size="md" />
|
||||
</div>
|
||||
<p className="text-gray-400 flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
|
|
@ -212,24 +215,24 @@ export default function CoachDetailPage() {
|
|||
</Button>
|
||||
|
||||
{/* Contact Info from Profile */}
|
||||
{profile.phone && (
|
||||
<a href={`tel:${profile.phone}`} className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
||||
{sanitizeTel(profile.phone) && (
|
||||
<a href={sanitizeTel(profile.phone)!} className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
||||
<Phone className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="truncate">{profile.phone}</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{profile.email && (
|
||||
<a href={`mailto:${profile.email}`} className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
||||
{sanitizeEmail(profile.email) && (
|
||||
<a href={sanitizeEmail(profile.email)!} className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
||||
<Mail className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="truncate">{profile.email}</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{profile.website && (
|
||||
<a href={profile.website} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
||||
{sanitizeUrl(profile.website) && (
|
||||
<a href={sanitizeUrl(profile.website)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(profile.website!)}`}>
|
||||
<Globe className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="truncate">{profile.website.replace(/^https?:\/\//, '')}</span>
|
||||
<span className="truncate">{urlDomain(profile.website!)}</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
|
|
@ -240,10 +243,10 @@ export default function CoachDetailPage() {
|
|||
</a>
|
||||
)}
|
||||
|
||||
{isFilmer && profile.social && (
|
||||
<a href={profile.social.startsWith('http') ? profile.social : `https://${profile.social}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
||||
{isFilmer && sanitizeUrl(profile.social) && (
|
||||
<a href={sanitizeUrl(profile.social)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(profile.social!)}`}>
|
||||
<Globe className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="truncate">{profile.social.replace(/^https?:\/\//, '')}</span>
|
||||
<span className="truncate">{urlDomain(profile.social!)}</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ function StatCard({
|
|||
value,
|
||||
label,
|
||||
sub,
|
||||
trustedValue,
|
||||
color,
|
||||
loading,
|
||||
}: {
|
||||
|
|
@ -54,6 +55,8 @@ function StatCard({
|
|||
value: number;
|
||||
label: string;
|
||||
sub?: string;
|
||||
/** If set, shows a secondary "X trusted" line below the main count */
|
||||
trustedValue?: number;
|
||||
color: string;
|
||||
loading: boolean;
|
||||
}) {
|
||||
|
|
@ -68,6 +71,13 @@ function StatCard({
|
|||
) : (
|
||||
<p className="text-4xl font-black leading-none" style={{ color }}>{value.toLocaleString()}</p>
|
||||
)}
|
||||
{!loading && trustedValue !== undefined && (
|
||||
<p className="text-xs mt-1.5 flex items-center gap-1" style={{ color: '#555' }}>
|
||||
<span className="inline-flex items-center gap-0.5 text-green-500/80">
|
||||
🛡 {trustedValue.toLocaleString()} from active accounts
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{sub && <p className="text-xs mt-1.5" style={{ color: '#555' }}>{sub}</p>}
|
||||
</div>
|
||||
<div className="p-2.5 rounded-xl flex-shrink-0" style={{ background: `${color}15` }}>
|
||||
|
|
@ -183,10 +193,10 @@ export default function CommunityStatsPage() {
|
|||
<TrendingUp className="h-3.5 w-3.5" /> Community at a Glance
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<StatCard icon={MapPin} value={stats?.totalSpots ?? 0} label="Skate Spots" color="#ff0040" loading={isLoading} />
|
||||
<StatCard icon={MapPin} value={stats?.totalSpots ?? 0} label="Skate Spots" color="#ff0040" loading={isLoading} trustedValue={stats?.trustedSpots} />
|
||||
<StatCard icon={Store} value={stats?.totalShops ?? 0} label="Listed Shops" color="#4ade80" loading={isLoading} />
|
||||
<StatCard icon={Calendar} value={stats?.totalEvents ?? 0} label="Events" color="#ccff00" loading={isLoading} />
|
||||
<StatCard icon={Users} value={stats?.totalMappers ?? 0} label="Mappers" color="#facc15" loading={isLoading} sub="unique contributors" />
|
||||
<StatCard icon={Users} value={stats?.totalMappers ?? 0} label="Mappers" color="#facc15" loading={isLoading} trustedValue={stats?.trustedMappers} sub="unique contributors" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-3">
|
||||
<StatCard icon={GraduationCap} value={stats?.totalCoaches ?? 0} label="Coaches" color="#ff6600" loading={isLoading} />
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { useConversations, formatPubkeyShort } from '@/hooks/useDirectMessages';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
MessageCircle,
|
||||
|
|
@ -27,9 +28,44 @@ import {
|
|||
Search,
|
||||
Lock,
|
||||
AlertCircle,
|
||||
ShieldCheck,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Dismissible phishing-warning banner shown once at the top of the DM page.
|
||||
* Standard practice (Alby, Metamask, Muun) — protects users from social-engineering
|
||||
* attacks where a bad actor pretends to be "SkateSpots Support" via DM.
|
||||
*/
|
||||
function PhishingWarningBanner() {
|
||||
const [dismissed, setDismissed] = useLocalStorage<boolean>('dm-phishing-warning-dismissed', false);
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-4 py-3 mb-4 rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/5 text-sm">
|
||||
<ShieldCheck className="h-5 w-5 text-[#ccff00] flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[#ccff00] font-bold text-xs uppercase tracking-wide mb-0.5">
|
||||
Security reminder
|
||||
</p>
|
||||
<p className="text-gray-300 text-xs leading-relaxed">
|
||||
<strong className="text-white">SkateSpots will never DM you</strong> asking to verify your account,
|
||||
share your keys, or send payments. If someone claims to be SkateSpots support via DM — it's a scam.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="flex-shrink-0 text-gray-500 hover:text-gray-300 transition-colors p-0.5 mt-0.5"
|
||||
aria-label="Dismiss security reminder"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Conversation preview component
|
||||
function ConversationPreview({ pubkey, lastMessage }: {
|
||||
pubkey: string;
|
||||
|
|
@ -209,6 +245,9 @@ export default function DirectMessagesPage() {
|
|||
return (
|
||||
<Layout>
|
||||
<div className="container px-4 py-4 sm:py-8 max-w-2xl">
|
||||
{/* Phishing warning — shown once, dismissible */}
|
||||
<PhishingWarningBanner />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3 mb-4 sm:mb-6">
|
||||
<div className="min-w-0 flex-1">
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { SortDropdown, type SortOption } from '@/components/SortDropdown';
|
|||
import { useSkateEvents } from '@/hooks/useSkateEvents';
|
||||
import { useTours } from '@/hooks/useTours';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowing } from '@/hooks/useFollowing';
|
||||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||
import { useComments } from '@/hooks/useComments';
|
||||
import { useTranslation } from '@/contexts/LanguageContext';
|
||||
|
|
@ -37,6 +38,9 @@ export default function EventsPage() {
|
|||
});
|
||||
|
||||
const { user } = useCurrentUser();
|
||||
const { data: followingList } = useFollowing(user?.pubkey);
|
||||
const followCount = followingList?.length ?? 0;
|
||||
const smartDefault: TrustFilterLevel = (user && followCount >= 5) ? 'trusted' : 'all';
|
||||
const { data: events, isLoading: eventsLoading } = useSkateEvents(100);
|
||||
const { data: tours, isLoading: toursLoading } = useTours(100);
|
||||
|
||||
|
|
@ -47,7 +51,7 @@ export default function EventsPage() {
|
|||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming');
|
||||
const [trustFilter, setTrustFilter] = useState<TrustFilterLevel>('all');
|
||||
const [trustFilter, setTrustFilter] = useState<TrustFilterLevel>(smartDefault);
|
||||
const [sortBy, setSortBy] = useState<SortOption>('recent');
|
||||
|
||||
// Tour filters
|
||||
|
|
@ -55,7 +59,7 @@ export default function EventsPage() {
|
|||
const [tourCategory, setTourCategory] = useState<TourCategory | 'all'>('all');
|
||||
const [tourSkillLevel, setTourSkillLevel] = useState<string>('all');
|
||||
const [tourVerifiedOnly, setTourVerifiedOnly] = useState(false);
|
||||
const [tourTrustFilter, setTourTrustFilter] = useState<TrustFilterLevel>('all');
|
||||
const [tourTrustFilter, setTourTrustFilter] = useState<TrustFilterLevel>(smartDefault);
|
||||
const [tourSortBy, setTourSortBy] = useState<SortOption>('recent');
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
|
@ -116,10 +120,18 @@ export default function EventsPage() {
|
|||
return true;
|
||||
});
|
||||
|
||||
// Apply trust filter
|
||||
// Apply trust filter (soft for 'trusted', strict for 'verified'/'following')
|
||||
if (trustScores && tourTrustFilter !== 'all') {
|
||||
if (tourTrustFilter === 'trusted') {
|
||||
filtered.sort((a, b) => {
|
||||
const tA = (getTrustScore(a.pubkey) ?? -1) >= 60 ? 1 : 0;
|
||||
const tB = (getTrustScore(b.pubkey) ?? -1) >= 60 ? 1 : 0;
|
||||
return tB - tA;
|
||||
});
|
||||
} else {
|
||||
filtered = filterByTrustLevel(filtered, tourTrustFilter, getTrustScore);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
filtered.sort((a, b) => {
|
||||
|
|
@ -172,10 +184,18 @@ export default function EventsPage() {
|
|||
return true;
|
||||
});
|
||||
|
||||
// Apply trust filter
|
||||
// Apply trust filter (soft for 'trusted', strict for 'verified'/'following')
|
||||
if (trustScores && trustFilter !== 'all') {
|
||||
if (trustFilter === 'trusted') {
|
||||
filtered.sort((a, b) => {
|
||||
const tA = (getTrustScore(a.pubkey) ?? -1) >= 60 ? 1 : 0;
|
||||
const tB = (getTrustScore(b.pubkey) ?? -1) >= 60 ? 1 : 0;
|
||||
return tB - tA;
|
||||
});
|
||||
} else {
|
||||
filtered = filterByTrustLevel(filtered, trustFilter, getTrustScore);
|
||||
}
|
||||
}
|
||||
|
||||
// Split into upcoming and past
|
||||
let upcoming = filtered.filter((e) => e.start >= now);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { useMarketplaceItems } from '@/hooks/useMarketplace';
|
|||
import { useTours } from '@/hooks/useTours';
|
||||
import { useBtcMapPlaces } from '@/hooks/useBtcMapPlaces';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowing } from '@/hooks/useFollowing';
|
||||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||
import { useMultipleSpotWeather } from '@/hooks/useSpotWeather';
|
||||
import { useComments } from '@/hooks/useComments';
|
||||
|
|
@ -333,6 +334,8 @@ export default function MapPage() {
|
|||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: followingList } = useFollowing(user?.pubkey);
|
||||
const followCount = followingList?.length ?? 0;
|
||||
const { data: spots, isLoading: spotsLoading } = useSkateSpots(200);
|
||||
const { data: shops, isLoading: shopsLoading } = useShops(200);
|
||||
const { data: marketplaceItems, isLoading: marketplaceLoading } = useMarketplaceItems(200);
|
||||
|
|
@ -348,7 +351,12 @@ export default function MapPage() {
|
|||
const [selectedSpot, setSelectedSpot] = useState<SkateSpot | null>(null);
|
||||
const [weatherEnabled, setWeatherEnabled] = useState(false);
|
||||
const [weatherFilter, setWeatherFilter] = useState<'all' | 'good' | 'perfect'>('all');
|
||||
const [trustFilter, setTrustFilter] = useState<TrustFilterLevel>('all');
|
||||
// Smart WoT default:
|
||||
// - Logged out or <5 follows → 'all' (show everything, don't punish new/anon users)
|
||||
// - Logged in with ≥5 follows → 'trusted' (sort trusted first, badge unverified)
|
||||
// User can always override with the TrustFilter dropdown
|
||||
const smartDefault: TrustFilterLevel = (user && followCount >= 5) ? 'trusted' : 'all';
|
||||
const [trustFilter, setTrustFilter] = useState<TrustFilterLevel>(smartDefault);
|
||||
const [sortBy, setSortBy] = useState<SortOption>('recent');
|
||||
const [userLocation, setUserLocation] = useState<{lat: number; lon: number} | null>(null);
|
||||
|
||||
|
|
@ -540,10 +548,24 @@ export default function MapPage() {
|
|||
return true;
|
||||
});
|
||||
|
||||
// Apply trust filter
|
||||
// Apply trust filter — when 'trusted', sort trusted content to the top
|
||||
// rather than hiding untrusted content (avoids penalising new users/anon browsers)
|
||||
// Strict filtering (verified/following) still hides low-trust content as expected
|
||||
if (trustScores && trustFilter !== 'all') {
|
||||
if (trustFilter === 'trusted') {
|
||||
// Soft mode: sort trusted first, keep everything visible
|
||||
filtered.sort((a, b) => {
|
||||
const scoreA = getTrustScore(a.pubkey) ?? -1;
|
||||
const scoreB = getTrustScore(b.pubkey) ?? -1;
|
||||
const trustedA = scoreA >= 60 ? 1 : 0;
|
||||
const trustedB = scoreB >= 60 ? 1 : 0;
|
||||
return trustedB - trustedA;
|
||||
});
|
||||
} else {
|
||||
// Strict modes (verified, following) — actually filter
|
||||
filtered = filterByTrustLevel(filtered, trustFilter, getTrustScore);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
filtered.sort((a, b) => {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { ZapButton } from '@/components/ZapButton';
|
||||
import { CommentsSection } from '@/components/comments/CommentsSection';
|
||||
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
|
||||
import { OfficialBadge } from '@/components/OfficialBadge';
|
||||
import { useShop } from '@/hooks/useShops';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
|
|
@ -243,6 +245,7 @@ export default function ShopDetailPage() {
|
|||
Verified
|
||||
</Badge>
|
||||
)}
|
||||
<OfficialBadge pubkey={shop.pubkey} size="md" />
|
||||
</div>
|
||||
{shop.location && (
|
||||
<p className="text-muted-foreground flex items-center gap-2">
|
||||
|
|
@ -360,22 +363,22 @@ export default function ShopDetailPage() {
|
|||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{shop.phone && (
|
||||
<a href={`tel:${shop.phone}`} className="flex items-center gap-3 p-3 bg-black/50 rounded-lg hover:bg-black/70 transition-colors">
|
||||
{sanitizeTel(shop.phone) && (
|
||||
<a href={sanitizeTel(shop.phone)!} className="flex items-center gap-3 p-3 bg-black/50 rounded-lg hover:bg-black/70 transition-colors">
|
||||
<Phone className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="text-gray-300">{shop.phone}</span>
|
||||
</a>
|
||||
)}
|
||||
{shop.email && (
|
||||
<a href={`mailto:${shop.email}`} className="flex items-center gap-3 p-3 bg-black/50 rounded-lg hover:bg-black/70 transition-colors">
|
||||
{sanitizeEmail(shop.email) && (
|
||||
<a href={sanitizeEmail(shop.email)!} className="flex items-center gap-3 p-3 bg-black/50 rounded-lg hover:bg-black/70 transition-colors">
|
||||
<Mail className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="text-gray-300 truncate">{shop.email}</span>
|
||||
</a>
|
||||
)}
|
||||
{shop.website && (
|
||||
<a href={shop.website} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 p-3 bg-black/50 rounded-lg hover:bg-black/70 transition-colors">
|
||||
{sanitizeUrl(shop.website) && (
|
||||
<a href={sanitizeUrl(shop.website)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 p-3 bg-black/50 rounded-lg hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(shop.website!)}`}>
|
||||
<Globe className="h-4 w-4 text-[#ccff00]" />
|
||||
<span className="text-gray-300 truncate">{shop.website.replace(/^https?:\/\//, '')}</span>
|
||||
<span className="text-gray-300 truncate">{urlDomain(shop.website!)}</span>
|
||||
</a>
|
||||
)}
|
||||
{shop.instagram && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue