- 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>
121 lines
3.9 KiB
TypeScript
121 lines
3.9 KiB
TypeScript
import { Link } from 'react-router-dom';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
import { Heart, Repeat2, Zap, AtSign, MessageCircle, CalendarCheck, Megaphone } from 'lucide-react';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { cn } from '@/lib/utils';
|
|
import { nip19 } from 'nostr-tools';
|
|
import {
|
|
activityVerb,
|
|
type ActivityNotification,
|
|
type ActivityType,
|
|
} from '@/hooks/useActivityNotifications';
|
|
|
|
const TYPE_META: Record<
|
|
ActivityType,
|
|
{ icon: typeof Heart; color: string; ring: string }
|
|
> = {
|
|
reaction: { icon: Heart, color: 'text-pink-400', ring: 'bg-pink-500/15' },
|
|
repost: { icon: Repeat2, color: 'text-green-400', ring: 'bg-green-500/15' },
|
|
zap: { icon: Zap, color: 'text-yellow-400', ring: 'bg-yellow-500/15' },
|
|
mention: { icon: AtSign, color: 'text-sky-400', ring: 'bg-sky-500/15' },
|
|
reply: { icon: MessageCircle, color: 'text-teal-400', ring: 'bg-teal-500/15' },
|
|
rsvp: { icon: CalendarCheck, color: 'text-emerald-400', ring: 'bg-emerald-500/15' },
|
|
'event-update': { icon: Megaphone, color: 'text-[#ccff00]', ring: 'bg-[#ccff00]/15' },
|
|
};
|
|
|
|
/**
|
|
* A single "social" activity notification (reaction / repost / zap / mention /
|
|
* reply). Links to the actor's profile and — when there's a target event — to
|
|
* that note so the user can jump straight to the context.
|
|
*/
|
|
export function ActivityNotificationCard({
|
|
notification,
|
|
unread,
|
|
onNavigate,
|
|
}: {
|
|
notification: ActivityNotification;
|
|
unread?: boolean;
|
|
onNavigate?: () => void;
|
|
}) {
|
|
const author = useAuthor(notification.pubkey);
|
|
const name = author.data?.metadata?.name ?? genUserName(notification.pubkey);
|
|
const pic = author.data?.metadata?.picture;
|
|
|
|
const meta = TYPE_META[notification.type];
|
|
const Icon = meta.icon;
|
|
|
|
const npub = (() => {
|
|
try {
|
|
return nip19.npubEncode(notification.pubkey);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
const timeAgo = formatDistanceToNow(new Date(notification.createdAt * 1000), {
|
|
addSuffix: true,
|
|
});
|
|
|
|
const snippet =
|
|
(notification.type === 'reply' ||
|
|
notification.type === 'mention' ||
|
|
notification.type === 'event-update') &&
|
|
notification.content
|
|
? notification.content.slice(0, 120)
|
|
: null;
|
|
|
|
const body = (
|
|
<div
|
|
className={cn(
|
|
'flex items-start gap-3 rounded-xl border p-3 transition-colors',
|
|
unread
|
|
? 'border-teal-500/25 bg-teal-500/[0.06]'
|
|
: 'border-white/10 bg-white/[0.02]',
|
|
)}
|
|
>
|
|
{/* Actor avatar with a type badge */}
|
|
<div className="relative flex-shrink-0">
|
|
<Avatar className="h-9 w-9">
|
|
<AvatarImage src={pic} alt={name} />
|
|
<AvatarFallback className="text-[10px]">
|
|
{name.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span
|
|
className={cn(
|
|
'absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full ring-2 ring-background',
|
|
meta.ring,
|
|
)}
|
|
>
|
|
<Icon className={cn('h-3 w-3', meta.color)} />
|
|
</span>
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm leading-snug text-gray-200">
|
|
<span className="font-bold text-white">{name}</span>{' '}
|
|
<span className="text-gray-400">{activityVerb(notification)}</span>
|
|
</p>
|
|
{snippet && (
|
|
<p className="mt-0.5 line-clamp-2 text-xs text-gray-500">"{snippet}"</p>
|
|
)}
|
|
<p className="mt-0.5 text-[11px] text-gray-600">{timeAgo}</p>
|
|
</div>
|
|
|
|
{unread && (
|
|
<span className="mt-1 h-2 w-2 flex-shrink-0 rounded-full bg-teal-400" />
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// Link to the actor's profile (the always-valid in-app destination).
|
|
if (!npub) return body;
|
|
|
|
return (
|
|
<Link to={`/${npub}`} onClick={onNavigate} className="block">
|
|
{body}
|
|
</Link>
|
|
);
|
|
}
|