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 { 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<ChatPrecision>(defaultPrecision);
const [trustLevel, setTrustLevel] = useState<TrustFilterLevel>('all');
const [draft, setDraft] = useState('');
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 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({
<MessageCircle className="h-5 w-5 text-orange-500" />
Spot Chat
</CardTitle>
<Badge variant="secondary" className="font-mono text-[10px] gap-1">
<Radio className="h-3 w-3" />#{channel}
</Badge>
<div className="flex items-center gap-2">
<TrustFilter value={trustLevel} onChange={setTrustLevel} compact />
<Badge variant="secondary" className="font-mono text-[10px] gap-1">
<Radio className="h-3 w-3" />#{channel}
</Badge>
</div>
</div>
{showPrecisionToggle && (
<div className="pt-1">
@ -216,14 +251,26 @@ export function SpotChat({
{!isLoading && (!messages || messages.length === 0) && (
<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" />
<p className="text-sm text-muted-foreground max-w-[16rem]">
No messages yet in this local channel. Say hi — "anyone skating tonight?"
</p>
{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]">
No messages yet in this local channel. Say hi — "anyone skating tonight?"
</p>
)}
</div>
)}
{messages?.map((m) => (
<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 className="border-t p-3">

View file

@ -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<string, {
spots: Set<string>; shops: Set<string>; events: 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) {
@ -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<string, number>();
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,

View file

@ -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() {
<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} />
</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={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={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>
</section>