SkateSpots/src/components/ActivityNotificationCard.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

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>
);
}