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:
parent
ba3921b15c
commit
3b25e7bcf6
3 changed files with 73 additions and 15 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue