From 3b25e7bcf6140db587644582c4ef948d03a3000e Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 24 Jul 2026 14:58:10 +0700 Subject: [PATCH] Add trust filter to Spot Chat + include crews in community stats Spot Chat: - Compact TrustFilter dropdown in the chat header (All / Verified / Trusted / Following) - Filters messages by author trust via useBatchTrustScores; own messages always stay visible; shows a "N hidden by your trust filter" indicator Community stats: - useGrowthStats now queries kind 35006 crews (was missing entirely) - totalCrews added to totals + monthly buckets + content-mix donut - New "Crews" StatCard on the Community Stats page Co-authored-by: shakespeare.diy --- src/components/SpotChat.tsx | 63 ++++++++++++++++++++++++++++---- src/hooks/useGrowthStats.ts | 19 +++++++--- src/pages/CommunityStatsPage.tsx | 6 ++- 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/src/components/SpotChat.tsx b/src/components/SpotChat.tsx index 73ebb1f..4f8f52e 100644 --- a/src/components/SpotChat.tsx +++ b/src/components/SpotChat.tsx @@ -19,7 +19,13 @@ import { NoteContent } from '@/components/NoteContent'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useToast } from '@/hooks/useToast'; +import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { genUserName } from '@/lib/genUserName'; +import { + TrustFilter, + filterByTrustLevel, + type TrustFilterLevel, +} from '@/components/TrustFilter'; import { useSpotChat, useSendSpotChat, @@ -122,14 +128,40 @@ export function SpotChat({ const { user } = useCurrentUser(); const { toast } = useToast(); const [precision, setPrecision] = useState(defaultPrecision); + const [trustLevel, setTrustLevel] = useState('all'); const [draft, setDraft] = useState(''); const scrollRef = useRef(null); - const { data: messages, isLoading } = useSpotChat(geohash, precision); + const { data: rawMessages, isLoading } = useSpotChat(geohash, precision); const { mutateAsync: sendMessage, isPending } = useSendSpotChat(); const channel = channelGeohash(geohash, precision); + // Trust scores for everyone who posted in this channel. + const authorPubkeys = useMemo( + () => Array.from(new Set((rawMessages ?? []).map((m) => m.pubkey))), + [rawMessages], + ); + const { data: trustScores } = useBatchTrustScores( + trustLevel === 'all' ? [] : authorPubkeys, + ); + + // Apply the trust filter. The user's own messages are always kept so their + // sent messages never vanish behind the filter. + const messages = useMemo(() => { + if (!rawMessages) return rawMessages; + if (trustLevel === 'all') return rawMessages; + const filtered = filterByTrustLevel( + rawMessages, + trustLevel, + (pk) => trustScores?.get(pk), + ); + const kept = new Set(filtered.map((m) => m.id)); + return rawMessages.filter((m) => kept.has(m.id) || m.pubkey === user?.pubkey); + }, [rawMessages, trustLevel, trustScores, user?.pubkey]); + + const hiddenCount = (rawMessages?.length ?? 0) - (messages?.length ?? 0); + // Auto-scroll to newest message when the list grows. const lastCount = useRef(0); useEffect(() => { @@ -147,7 +179,7 @@ export function SpotChat({ // IRC-style /who — list recent participants (easter-egg power-user command). if (text === '/who' || text.startsWith('/who ')) { - const recent = new Set((messages ?? []).map((m) => m.pubkey)); + const recent = new Set((rawMessages ?? []).map((m) => m.pubkey)); toast({ title: `${recent.size} skater${recent.size === 1 ? '' : 's'} active here`, description: @@ -179,9 +211,12 @@ export function SpotChat({ Spot Chat - - #{channel} - +
+ + + #{channel} + +
{showPrecisionToggle && (
@@ -216,14 +251,26 @@ export function SpotChat({ {!isLoading && (!messages || messages.length === 0) && (
-

- No messages yet in this local channel. Say hi — "anyone skating tonight?" -

+ {hiddenCount > 0 ? ( +

+ {hiddenCount} message{hiddenCount === 1 ? '' : 's'} hidden by your trust filter. + Lower the filter to see more. +

+ ) : ( +

+ No messages yet in this local channel. Say hi — "anyone skating tonight?" +

+ )}
)} {messages?.map((m) => ( ))} + {!isLoading && messages && messages.length > 0 && hiddenCount > 0 && ( +
+ {hiddenCount} message{hiddenCount === 1 ? '' : 's'} hidden by your trust filter +
+ )}
diff --git a/src/hooks/useGrowthStats.ts b/src/hooks/useGrowthStats.ts index 94e657e..4791628 100644 --- a/src/hooks/useGrowthStats.ts +++ b/src/hooks/useGrowthStats.ts @@ -8,6 +8,7 @@ import { SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_TOUR_KIND, + SKATE_CREW_KIND, SKATE_RELAY, } from '@/lib/types'; @@ -25,6 +26,7 @@ export interface MonthBucket { filmers: number; editors: number; tours: number; + crews: number; total: number; /** Unique pubkeys that published anything this month */ activeMappers: number; @@ -45,6 +47,7 @@ export interface GrowthStats { totalFilmers: number; totalEditors: number; totalTours: number; + totalCrews: number; /** All unique pubkeys that have published any content */ totalMappers: number; @@ -116,6 +119,7 @@ export function useGrowthStats() { filmerEvents, editorEvents, tourEvents, + crewEvents, ] = await Promise.all([ relay.query([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []), relay.query([{ kinds: [SKATE_SHOP_KIND], limit: 2000 }], { signal }).catch(() => []), @@ -124,6 +128,7 @@ export function useGrowthStats() { relay.query([{ kinds: [SKATE_FILMER_KIND], limit: 500 }], { signal }).catch(() => []), relay.query([{ kinds: [SKATE_EDITOR_KIND], limit: 500 }], { signal }).catch(() => []), relay.query([{ kinds: [SKATE_TOUR_KIND], limit: 500 }], { signal }).catch(() => []), + relay.query([{ kinds: [SKATE_CREW_KIND], limit: 500 }], { signal }).catch(() => []), ]); // Deduplicate addressable events (keep newest per pubkey+kind+d) @@ -145,13 +150,14 @@ export function useGrowthStats() { const filmers = dedup(filmerEvents); const editors = dedup(editorEvents); const tours = dedup(tourEvents); + const crews = dedup(crewEvents); // ── Monthly bucketing ─────────────────────────────────────────────── const bucketMap = new Map; shops: Set; events: Set; coaches: Set; filmers: Set; editors: Set; - tours: Set; pubkeys: Set; + tours: Set; crews: Set; pubkeys: Set; }>(); function ensureBucket(key: string) { @@ -159,7 +165,7 @@ export function useGrowthStats() { bucketMap.set(key, { spots: new Set(), shops: new Set(), events: new Set(), coaches: new Set(), filmers: new Set(), editors: new Set(), - tours: new Set(), pubkeys: new Set(), + tours: new Set(), crews: new Set(), pubkeys: new Set(), }); } return bucketMap.get(key)!; @@ -172,6 +178,7 @@ export function useGrowthStats() { for (const e of filmers) { const b = ensureBucket(toMonthKey(e.created_at)); b.filmers.add(e.id); b.pubkeys.add(e.pubkey); } for (const e of editors) { const b = ensureBucket(toMonthKey(e.created_at)); b.editors.add(e.id); b.pubkeys.add(e.pubkey); } for (const e of tours) { const b = ensureBucket(toMonthKey(e.created_at)); b.tours.add(e.id); b.pubkeys.add(e.pubkey); } + for (const e of crews) { const b = ensureBucket(toMonthKey(e.created_at)); b.crews.add(e.id); b.pubkeys.add(e.pubkey); } // Fill any gaps between first and last month so charts have continuous X axis const allKeys = fillMonthGaps(Array.from(bucketMap.keys())); @@ -185,12 +192,13 @@ export function useGrowthStats() { const fi = b?.filmers.size ?? 0; const ed = b?.editors.size ?? 0; const to = b?.tours.size ?? 0; + const cr = b?.crews.size ?? 0; return { label: toMonthLabel(key), key, spots: s, shops: sh, events: ev, - coaches: co, filmers: fi, editors: ed, tours: to, - total: s + sh + ev + co + fi + ed + to, + coaches: co, filmers: fi, editors: ed, tours: to, crews: cr, + total: s + sh + ev + co + fi + ed + to + cr, activeMappers: b?.pubkeys.size ?? 0, }; }); @@ -208,7 +216,7 @@ export function useGrowthStats() { const mapperTotal = new Map(); for (const e of spots) { mapperSpots.set(e.pubkey, (mapperSpots.get(e.pubkey) ?? 0) + 1); } - const allContent = [...spots, ...shops, ...events, ...coaches, ...filmers, ...editors, ...tours]; + const allContent = [...spots, ...shops, ...events, ...coaches, ...filmers, ...editors, ...tours, ...crews]; for (const e of allContent) { mapperTotal.set(e.pubkey, (mapperTotal.get(e.pubkey) ?? 0) + 1); } const topMappers: TopMapper[] = Array.from(mapperSpots.entries()) @@ -247,6 +255,7 @@ export function useGrowthStats() { totalFilmers: filmers.length, totalEditors: editors.length, totalTours: tours.length, + totalCrews: crews.length, totalMappers: allPubkeys.size, trustedSpots, trustedMappers, diff --git a/src/pages/CommunityStatsPage.tsx b/src/pages/CommunityStatsPage.tsx index 40f0f7d..43de710 100644 --- a/src/pages/CommunityStatsPage.tsx +++ b/src/pages/CommunityStatsPage.tsx @@ -19,7 +19,7 @@ import { } from 'recharts'; import { MapPin, Store, Calendar, GraduationCap, - Video, Scissors, Route, Users, + Video, Scissors, Route, Users, Users2, TrendingUp, Radio, Zap, ExternalLink, Trophy, } from 'lucide-react'; @@ -162,6 +162,7 @@ export default function CommunityStatsPage() { { name: 'Lab', value: stats.totalCoaches + stats.totalFilmers + stats.totalEditors, color: '#ff6600' }, { name: 'Shops', value: stats.totalShops, color: '#4ade80' }, { name: 'Tours', value: stats.totalTours, color: '#c084fc' }, + { name: 'Crews', value: stats.totalCrews, color: '#2dd4bf' }, ].filter(d => d.value > 0); }, [stats]); @@ -215,11 +216,12 @@ export default function CommunityStatsPage() {
-
+
+