SkateSpots/src/components/ActivityNotificationCard.tsx
SkateSpots 9357386bbd i18n(es): batch 3 part 2 — locale-aware dates & relative times
- New src/lib/dateFnsI18n.ts re-exports date-fns with formatDistanceToNow /
  formatDistanceToNowStrict / formatDistance / format defaulting to the active
  locale, plus dateLocaleCode() for native toLocale{Date,Time}String calls.
- LanguageProvider keeps the locale in sync during render, so the first paint is
  already in-language.
- Codemod: 11 date-fns imports now use the shim; ~39 native date/time calls pass
  the app locale code instead of undefined/[]/hardcoded 'en-US'. Number
  .toLocaleString() calls are deliberately untouched.
2026-10-02 15:19:38 +07:00

140 lines
4.9 KiB
TypeScript

import { Link } from 'react-router-dom';
import { formatDistanceToNow } from '@/lib/dateFnsI18n';
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>
);
}