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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 14:58:10 +07:00
parent ba3921b15c
commit 3b25e7bcf6
3 changed files with 73 additions and 15 deletions

View file

@ -19,7 +19,13 @@ import { NoteContent } from '@/components/NoteContent';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import {
TrustFilter,
filterByTrustLevel,
type TrustFilterLevel,
} from '@/components/TrustFilter';
import { import {
useSpotChat, useSpotChat,
useSendSpotChat, useSendSpotChat,
@ -122,14 +128,40 @@ export function SpotChat({
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { toast } = useToast(); const { toast } = useToast();
const [precision, setPrecision] = useState<ChatPrecision>(defaultPrecision); const [precision, setPrecision] = useState<ChatPrecision>(defaultPrecision);
const [trustLevel, setTrustLevel] = useState<TrustFilterLevel>('all');
const [draft, setDraft] = useState(''); const [draft, setDraft] = useState('');
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const { data: messages, isLoading } = useSpotChat(geohash, precision); const { data: rawMessages, isLoading } = useSpotChat(geohash, precision);
const { mutateAsync: sendMessage, isPending } = useSendSpotChat(); const { mutateAsync: sendMessage, isPending } = useSendSpotChat();
const channel = channelGeohash(geohash, precision); 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. // Auto-scroll to newest message when the list grows.
const lastCount = useRef(0); const lastCount = useRef(0);
useEffect(() => { useEffect(() => {
@ -147,7 +179,7 @@ export function SpotChat({
// IRC-style /who — list recent participants (easter-egg power-user command). // IRC-style /who — list recent participants (easter-egg power-user command).
if (text === '/who' || text.startsWith('/who ')) { 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({ toast({
title: `${recent.size} skater${recent.size === 1 ? '' : 's'} active here`, title: `${recent.size} skater${recent.size === 1 ? '' : 's'} active here`,
description: description:
@ -179,10 +211,13 @@ export function SpotChat({
<MessageCircle className="h-5 w-5 text-orange-500" /> <MessageCircle className="h-5 w-5 text-orange-500" />
Spot Chat Spot Chat
</CardTitle> </CardTitle>
<div className="flex items-center gap-2">
<TrustFilter value={trustLevel} onChange={setTrustLevel} compact />
<Badge variant="secondary" className="font-mono text-[10px] gap-1"> <Badge variant="secondary" className="font-mono text-[10px] gap-1">
<Radio className="h-3 w-3" />#{channel} <Radio className="h-3 w-3" />#{channel}
</Badge> </Badge>
</div> </div>
</div>
{showPrecisionToggle && ( {showPrecisionToggle && (
<div className="pt-1"> <div className="pt-1">
<ToggleGroup <ToggleGroup
@ -216,14 +251,26 @@ export function SpotChat({
{!isLoading && (!messages || messages.length === 0) && ( {!isLoading && (!messages || messages.length === 0) && (
<div className="flex h-full flex-col items-center justify-center text-center gap-2 py-8"> <div className="flex h-full flex-col items-center justify-center text-center gap-2 py-8">
<MessageCircle className="h-8 w-8 text-muted-foreground/40" /> <MessageCircle className="h-8 w-8 text-muted-foreground/40" />
{hiddenCount > 0 ? (
<p className="text-sm text-muted-foreground max-w-[16rem]">
{hiddenCount} message{hiddenCount === 1 ? '' : 's'} hidden by your trust filter.
Lower the filter to see more.
</p>
) : (
<p className="text-sm text-muted-foreground max-w-[16rem]"> <p className="text-sm text-muted-foreground max-w-[16rem]">
No messages yet in this local channel. Say hi — "anyone skating tonight?" No messages yet in this local channel. Say hi — "anyone skating tonight?"
</p> </p>
)}
</div> </div>
)} )}
{messages?.map((m) => ( {messages?.map((m) => (
<ChatMessageRow key={m.id} message={m} isOwn={m.pubkey === user?.pubkey} /> <ChatMessageRow key={m.id} message={m} isOwn={m.pubkey === user?.pubkey} />
))} ))}
{!isLoading && messages && messages.length > 0 && hiddenCount > 0 && (
<div className="pt-2 text-center text-[11px] text-muted-foreground">
{hiddenCount} message{hiddenCount === 1 ? '' : 's'} hidden by your trust filter
</div>
)}
</div> </div>
<div className="border-t p-3"> <div className="border-t p-3">

View file

@ -8,6 +8,7 @@ import {
SKATE_FILMER_KIND, SKATE_FILMER_KIND,
SKATE_EDITOR_KIND, SKATE_EDITOR_KIND,
SKATE_TOUR_KIND, SKATE_TOUR_KIND,
SKATE_CREW_KIND,
SKATE_RELAY, SKATE_RELAY,
} from '@/lib/types'; } from '@/lib/types';
@ -25,6 +26,7 @@ export interface MonthBucket {
filmers: number; filmers: number;
editors: number; editors: number;
tours: number; tours: number;
crews: number;
total: number; total: number;
/** Unique pubkeys that published anything this month */ /** Unique pubkeys that published anything this month */
activeMappers: number; activeMappers: number;
@ -45,6 +47,7 @@ export interface GrowthStats {
totalFilmers: number; totalFilmers: number;
totalEditors: number; totalEditors: number;
totalTours: number; totalTours: number;
totalCrews: number;
/** All unique pubkeys that have published any content */ /** All unique pubkeys that have published any content */
totalMappers: number; totalMappers: number;
@ -116,6 +119,7 @@ export function useGrowthStats() {
filmerEvents, filmerEvents,
editorEvents, editorEvents,
tourEvents, tourEvents,
crewEvents,
] = await Promise.all([ ] = await Promise.all([
relay.query([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []), relay.query([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_SHOP_KIND], 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_FILMER_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_EDITOR_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_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) // Deduplicate addressable events (keep newest per pubkey+kind+d)
@ -145,13 +150,14 @@ export function useGrowthStats() {
const filmers = dedup(filmerEvents); const filmers = dedup(filmerEvents);
const editors = dedup(editorEvents); const editors = dedup(editorEvents);
const tours = dedup(tourEvents); const tours = dedup(tourEvents);
const crews = dedup(crewEvents);
// ── Monthly bucketing ─────────────────────────────────────────────── // ── Monthly bucketing ───────────────────────────────────────────────
const bucketMap = new Map<string, { const bucketMap = new Map<string, {
spots: Set<string>; shops: Set<string>; events: Set<string>; spots: Set<string>; shops: Set<string>; events: Set<string>;
coaches: Set<string>; filmers: Set<string>; editors: Set<string>; coaches: Set<string>; filmers: Set<string>; editors: Set<string>;
tours: Set<string>; pubkeys: Set<string>; tours: Set<string>; crews: Set<string>; pubkeys: Set<string>;
}>(); }>();
function ensureBucket(key: string) { function ensureBucket(key: string) {
@ -159,7 +165,7 @@ export function useGrowthStats() {
bucketMap.set(key, { bucketMap.set(key, {
spots: new Set(), shops: new Set(), events: new Set(), spots: new Set(), shops: new Set(), events: new Set(),
coaches: new Set(), filmers: new Set(), editors: 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)!; 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 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 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 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 // Fill any gaps between first and last month so charts have continuous X axis
const allKeys = fillMonthGaps(Array.from(bucketMap.keys())); const allKeys = fillMonthGaps(Array.from(bucketMap.keys()));
@ -185,12 +192,13 @@ export function useGrowthStats() {
const fi = b?.filmers.size ?? 0; const fi = b?.filmers.size ?? 0;
const ed = b?.editors.size ?? 0; const ed = b?.editors.size ?? 0;
const to = b?.tours.size ?? 0; const to = b?.tours.size ?? 0;
const cr = b?.crews.size ?? 0;
return { return {
label: toMonthLabel(key), label: toMonthLabel(key),
key, key,
spots: s, shops: sh, events: ev, spots: s, shops: sh, events: ev,
coaches: co, filmers: fi, editors: ed, tours: to, coaches: co, filmers: fi, editors: ed, tours: to, crews: cr,
total: s + sh + ev + co + fi + ed + to, total: s + sh + ev + co + fi + ed + to + cr,
activeMappers: b?.pubkeys.size ?? 0, activeMappers: b?.pubkeys.size ?? 0,
}; };
}); });
@ -208,7 +216,7 @@ export function useGrowthStats() {
const mapperTotal = new Map<string, number>(); const mapperTotal = new Map<string, number>();
for (const e of spots) { mapperSpots.set(e.pubkey, (mapperSpots.get(e.pubkey) ?? 0) + 1); } 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); } for (const e of allContent) { mapperTotal.set(e.pubkey, (mapperTotal.get(e.pubkey) ?? 0) + 1); }
const topMappers: TopMapper[] = Array.from(mapperSpots.entries()) const topMappers: TopMapper[] = Array.from(mapperSpots.entries())
@ -247,6 +255,7 @@ export function useGrowthStats() {
totalFilmers: filmers.length, totalFilmers: filmers.length,
totalEditors: editors.length, totalEditors: editors.length,
totalTours: tours.length, totalTours: tours.length,
totalCrews: crews.length,
totalMappers: allPubkeys.size, totalMappers: allPubkeys.size,
trustedSpots, trustedSpots,
trustedMappers, trustedMappers,

View file

@ -19,7 +19,7 @@ import {
} from 'recharts'; } from 'recharts';
import { import {
MapPin, Store, Calendar, GraduationCap, MapPin, Store, Calendar, GraduationCap,
Video, Scissors, Route, Users, Video, Scissors, Route, Users, Users2,
TrendingUp, Radio, Zap, ExternalLink, TrendingUp, Radio, Zap, ExternalLink,
Trophy, Trophy,
} from 'lucide-react'; } from 'lucide-react';
@ -162,6 +162,7 @@ export default function CommunityStatsPage() {
{ name: 'Lab', value: stats.totalCoaches + stats.totalFilmers + stats.totalEditors, color: '#ff6600' }, { name: 'Lab', value: stats.totalCoaches + stats.totalFilmers + stats.totalEditors, color: '#ff6600' },
{ name: 'Shops', value: stats.totalShops, color: '#4ade80' }, { name: 'Shops', value: stats.totalShops, color: '#4ade80' },
{ name: 'Tours', value: stats.totalTours, color: '#c084fc' }, { name: 'Tours', value: stats.totalTours, color: '#c084fc' },
{ name: 'Crews', value: stats.totalCrews, color: '#2dd4bf' },
].filter(d => d.value > 0); ].filter(d => d.value > 0);
}, [stats]); }, [stats]);
@ -215,11 +216,12 @@ export default function CommunityStatsPage() {
<StatCard icon={Calendar} value={stats?.totalEvents ?? 0} label="Events" color="#ccff00" loading={isLoading} thisMonthDelta={thisMonth?.events} /> <StatCard icon={Calendar} value={stats?.totalEvents ?? 0} label="Events" color="#ccff00" loading={isLoading} thisMonthDelta={thisMonth?.events} />
<StatCard icon={Users} value={stats?.totalMappers ?? 0} label="Mappers" color="#facc15" loading={isLoading} trustedValue={stats?.trustedMappers} sub="unique contributors" thisMonthDelta={thisMonth?.activeMappers} /> <StatCard icon={Users} value={stats?.totalMappers ?? 0} label="Mappers" color="#facc15" loading={isLoading} trustedValue={stats?.trustedMappers} sub="unique contributors" thisMonthDelta={thisMonth?.activeMappers} />
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-3"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 mt-3">
<StatCard icon={GraduationCap} value={stats?.totalCoaches ?? 0} label="Coaches" color="#ff6600" loading={isLoading} thisMonthDelta={thisMonth?.coaches} /> <StatCard icon={GraduationCap} value={stats?.totalCoaches ?? 0} label="Coaches" color="#ff6600" loading={isLoading} thisMonthDelta={thisMonth?.coaches} />
<StatCard icon={Video} value={stats?.totalFilmers ?? 0} label="Filmers" color="#60a5fa" loading={isLoading} thisMonthDelta={thisMonth?.filmers} /> <StatCard icon={Video} value={stats?.totalFilmers ?? 0} label="Filmers" color="#60a5fa" loading={isLoading} thisMonthDelta={thisMonth?.filmers} />
<StatCard icon={Scissors} value={stats?.totalEditors ?? 0} label="Editors" color="#c084fc" loading={isLoading} thisMonthDelta={thisMonth?.editors} /> <StatCard icon={Scissors} value={stats?.totalEditors ?? 0} label="Editors" color="#c084fc" loading={isLoading} thisMonthDelta={thisMonth?.editors} />
<StatCard icon={Route} value={stats?.totalTours ?? 0} label="Tours" color="#f472b6" loading={isLoading} thisMonthDelta={thisMonth?.tours} /> <StatCard icon={Route} value={stats?.totalTours ?? 0} label="Tours" color="#f472b6" loading={isLoading} thisMonthDelta={thisMonth?.tours} />
<StatCard icon={Users2} value={stats?.totalCrews ?? 0} label="Crews" color="#2dd4bf" loading={isLoading} thisMonthDelta={thisMonth?.crews} />
</div> </div>
</section> </section>