- 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>
150 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|