SkateSpots/src/components/AttendeeList.tsx
shakespeare.diy 78f4a6d09a Host tools: attendee list w/ trust, event updates, edit-alerts, Hosting tab
- AttendeeList: tabbed Going/Interested with faces + names + a "who you trust"
  signal (reuses batch trust scores); replaces the plain avatar stack
- Event Updates (kind 7103): usePostEventUpdate + useEventUpdates; host-only
  composer with update/reschedule/relocation/cancelled categories that p-tags
  every RSVPer so they get notified; public feed on the event page
- EditEventPage: auto-post a reschedule/relocation update to notify attendees
  when the host changes time or location
- Activity bell: new 'event-update' notification type for RSVPers
- EventsPage: "Hosting" tab (logged-in) with a HostingDashboard listing the
  user's upcoming/past events, live RSVP counts, and quick edit/update actions
- Document Event Update kind 7103 in NIP.md

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 11:25:57 +07:00

150 lines
4.8 KiB
TypeScript

import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { ShieldCheck, Users } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
const TRUSTED_THRESHOLD = 60;
function AttendeeRow({ pubkey, trusted }: { pubkey: string; trusted: boolean }) {
const author = useAuthor(pubkey);
const name = author.data?.metadata?.name ?? genUserName(pubkey);
const pic = author.data?.metadata?.picture;
const npub = (() => {
try {
return nip19.npubEncode(pubkey);
} catch {
return null;
}
})();
const inner = (
<div className="flex items-center gap-3 rounded-lg px-2 py-1.5 transition-colors hover:bg-white/5">
<Avatar className="h-8 w-8">
<AvatarImage src={pic} alt={name} />
<AvatarFallback className="text-[10px] font-bold">
{name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-200">{name}</span>
{trusted && (
<span
className="flex items-center gap-1 rounded-full bg-[#ccff00]/15 px-2 py-0.5 text-[10px] font-black uppercase tracking-wide text-[#ccff00]"
title="Someone you follow or a trusted mapper"
>
<ShieldCheck className="h-3 w-3" />
Trusted
</span>
)}
</div>
);
if (!npub) return inner;
return <Link to={`/${npub}`}>{inner}</Link>;
}
/**
* Full attendee list for a host: tabbed Going / Interested, each row showing a
* face + name, plus a "trust signal" summarizing how many of the Going crowd
* are people the viewer follows or trusted mappers (skate events live or die on
* whether real locals show up).
*/
export function AttendeeList({
going,
tentative,
className,
}: {
going: string[];
tentative: string[];
className?: string;
}) {
const [tab, setTab] = useState<'going' | 'tentative'>('going');
const allPubkeys = useMemo(() => [...new Set([...going, ...tentative])], [going, tentative]);
const { data: trustScores } = useBatchTrustScores(allPubkeys);
const trustedGoing = useMemo(
() => going.filter((pk) => (trustScores?.get(pk) ?? 0) >= TRUSTED_THRESHOLD).length,
[going, trustScores],
);
if (going.length === 0 && tentative.length === 0) {
return (
<p className={cn('text-sm text-muted-foreground', className)}>
No RSVPs yet — be the first to say you're going.
</p>
);
}
const list = tab === 'going' ? going : tentative;
return (
<div className={cn('space-y-3', className)}>
{/* Trust signal */}
{going.length > 0 && (
<div className="flex items-center gap-2 rounded-lg border border-[#ccff00]/20 bg-[#ccff00]/5 px-3 py-2">
<Users className="h-4 w-4 flex-shrink-0 text-[#ccff00]" />
<p className="text-xs text-gray-300">
<span className="font-black text-white">{going.length}</span> going
{trustedGoing > 0 && (
<>
{' · '}
<span className="font-black text-[#ccff00]">{trustedGoing}</span> you trust
</>
)}
</p>
</div>
)}
{/* Tabs */}
<div className="flex gap-1.5">
<button
type="button"
onClick={() => setTab('going')}
className={cn(
'flex-1 rounded-lg py-1.5 text-xs font-black uppercase tracking-wide transition-all',
tab === 'going'
? 'bg-green-500 text-black'
: 'bg-white/5 text-gray-400 hover:text-white',
)}
>
Going ({going.length})
</button>
<button
type="button"
onClick={() => setTab('tentative')}
className={cn(
'flex-1 rounded-lg py-1.5 text-xs font-black uppercase tracking-wide transition-all',
tab === 'tentative'
? 'bg-[#ccff00] text-black'
: 'bg-white/5 text-gray-400 hover:text-white',
)}
>
Interested ({tentative.length})
</button>
</div>
{/* List */}
{list.length > 0 ? (
<div className="max-h-72 space-y-0.5 overflow-y-auto pr-1">
{list.map((pk) => (
<AttendeeRow
key={pk}
pubkey={pk}
trusted={(trustScores?.get(pk) ?? 0) >= TRUSTED_THRESHOLD}
/>
))}
</div>
) : (
<p className="py-4 text-center text-sm text-muted-foreground">
{tab === 'going' ? 'Nobody has confirmed yet.' : 'Nobody marked interested yet.'}
</p>
)}
</div>
);
}