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 { 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,9 +211,12 @@ 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>
|
||||||
<Badge variant="secondary" className="font-mono text-[10px] gap-1">
|
<div className="flex items-center gap-2">
|
||||||
<Radio className="h-3 w-3" />#{channel}
|
<TrustFilter value={trustLevel} onChange={setTrustLevel} compact />
|
||||||
</Badge>
|
<Badge variant="secondary" className="font-mono text-[10px] gap-1">
|
||||||
|
<Radio className="h-3 w-3" />#{channel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showPrecisionToggle && (
|
{showPrecisionToggle && (
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
|
|
@ -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" />
|
||||||
<p className="text-sm text-muted-foreground max-w-[16rem]">
|
{hiddenCount > 0 ? (
|
||||||
No messages yet in this local channel. Say hi — "anyone skating tonight?"
|
<p className="text-sm text-muted-foreground max-w-[16rem]">
|
||||||
</p>
|
{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>
|
</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">
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue