- 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.
140 lines
4.9 KiB
TypeScript
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>
|
|
);
|
|
}
|