SkateSpots/src/components/ActivityNotificationCard.tsx
shakespeare.diy 719dd2c4a8 Route event notifications to the event, not the actor (1.2.35)
Audited the RSVP / event-update notification flow end to end. The data
layer was already solid and secure — RSVPs p-tag the host, updates
p-tag every RSVPer, and useActivityNotifications guards event-update
spoofing by requiring the author to be the event's host (from the `a`
coordinate) or an admin. The gap was on the rendering side.

Fixes:

- ActivityNotificationCard hardwired every notification to link to the
  ACTOR's profile. For an RSVP ("Alice is going to your event") the host
  wants the event + attendee list, and for an event update ("moved to
  Sunday") the attendee wants to READ the update — not visit a profile.
  Both events carry the event's `a` coordinate (31923:pubkey:identifier),
  so these two types now route to /event/:pubkey/:identifier. Everything
  else still lands on the actor's profile. d-tags containing ':' are
  reconstructed correctly.

- useRsvp only invalidated the single-event RSVP query, so the grid /
  calendar "going" counts lagged behind by up to the 30s staleTime after
  RSVPing. Now also invalidates the batch-event-rsvps query so counts
  update promptly.

- Social-tab empty-state copy omitted RSVPs and host updates even though
  they land there; made it honest.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-07 22:56:20 +07:00

140 lines
4.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,
});
// Type-aware destination. For event RSVPs (a host is notified) and event
// updates (an attendee is notified), the useful place to land is the EVENT —
// not the actor's profile. Both carry the event's addressable coordinate in
// an `a` tag ("31923:pubkey:identifier"), which maps to /event/:pubkey/:id.
const eventCoordTag =
notification.type === 'rsvp' || notification.type === 'event-update'
? notification.event.tags.find(([t]) => t === 'a')?.[1]
: undefined;
const eventPath = (() => {
if (!eventCoordTag) return null;
const [kind, pubkey, ...idParts] = eventCoordTag.split(':');
const identifier = idParts.join(':'); // d-tags may contain ':'
if (kind !== '31923' || !pubkey || !identifier) return null;
return `/event/${pubkey}/${identifier}`;
})();
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>
);
// Prefer landing on the event for RSVP / event-update notifications; fall back
// to the actor's profile for everything else (the always-valid destination).
const destination = eventPath ?? (npub ? `/${npub}` : null);
if (!destination) return body;
return (
<Link to={destination} onClick={onNavigate} className="block">
{body}
</Link>
);
}