Polish notifications: full activity center (social + crews), not just crews
- New useActivityNotifications: single combined #p query classifies reactions, reposts, zaps, mentions and replies referencing the user; parses zap sats - New useNotificationsRead: per-account last-read timestamp for unread badge - New ActivityNotificationCard: actor avatar + type badge, verb, snippet, links to the actor's profile - Rewrite NotificationBell into a tabbed activity center (All / Social / Crews) with unread tracking, mark-all-read, and the existing crew invites/requests Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
35915557bb
commit
c34f194d8f
4 changed files with 495 additions and 52 deletions
116
src/components/ActivityNotificationCard.tsx
Normal file
116
src/components/ActivityNotificationCard.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { Heart, Repeat2, Zap, AtSign, MessageCircle } 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' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Bell, Users, Check, Loader2, EyeOff, Globe } from 'lucide-react';
|
import { Bell, Users, Check, Loader2, EyeOff, Globe, CheckCheck, Heart } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { CrewInviteCard } from '@/components/CrewInviteCard';
|
import { CrewInviteCard } from '@/components/CrewInviteCard';
|
||||||
import { JoinRequestCard } from '@/components/JoinRequestCard';
|
import { JoinRequestCard } from '@/components/JoinRequestCard';
|
||||||
|
import { ActivityNotificationCard } from '@/components/ActivityNotificationCard';
|
||||||
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
|
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
|
||||||
|
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
|
||||||
|
import { useNotificationsRead } from '@/hooks/useNotificationsRead';
|
||||||
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
|
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
|
@ -117,23 +121,55 @@ function PendingConfirmCard({ crew, onResolved }: { crew: SkateCrew; onResolved?
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EmptyState({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5">
|
||||||
|
<Bell className="h-5 w-5 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold text-gray-400">You're all caught up</p>
|
||||||
|
<p className="max-w-[220px] text-xs text-gray-600">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Header notification bell. Surfaces actionable crew notifications:
|
* Header notification bell — the user's whole activity center, not just crews:
|
||||||
* - structured crew invites (accept / decline)
|
|
||||||
* - "pending confirmation" crews (someone listed you — confirm to join)
|
|
||||||
*
|
*
|
||||||
* Shows an unread count badge. Only renders when logged in.
|
* SOCIAL: reactions, reposts, zaps, mentions and replies that reference them.
|
||||||
|
* CREWS: structured invites, "pending confirmation" crews, and (for crew
|
||||||
|
* leaders) incoming join-requests to approve.
|
||||||
|
*
|
||||||
|
* Unread state is tracked by a per-account "last read" timestamp; opening the
|
||||||
|
* bell (or hitting "mark all read") clears the badge. Only renders when logged in.
|
||||||
*/
|
*/
|
||||||
export function NotificationBell({ className }: { className?: string }) {
|
export function NotificationBell({ className }: { className?: string }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const { user, invites, listedCrews, joinRequests, isLoading } = useCrewNotifications();
|
const { user, invites, listedCrews, joinRequests, isLoading: crewLoading } = useCrewNotifications();
|
||||||
|
const { notifications: activity, isLoading: activityLoading } = useActivityNotifications();
|
||||||
|
const { lastRead, markAllRead } = useNotificationsRead();
|
||||||
|
|
||||||
// The bell only makes sense when logged in.
|
const crewTotal = useMemo(
|
||||||
const total = useMemo(
|
|
||||||
() => invites.length + listedCrews.length + joinRequests.length,
|
() => invites.length + listedCrews.length + joinRequests.length,
|
||||||
[invites, listedCrews, joinRequests],
|
[invites, listedCrews, joinRequests],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Unread social items = activity newer than the last time the bell was opened.
|
||||||
|
const unreadActivity = useMemo(
|
||||||
|
() => activity.filter((n) => n.createdAt > lastRead).length,
|
||||||
|
[activity, lastRead],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Crew items are always "actionable" so they always count toward the badge.
|
||||||
|
const badgeTotal = crewTotal + unreadActivity;
|
||||||
|
|
||||||
|
// Opening the bell marks everything as read.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) markAllRead();
|
||||||
|
}, [open, markAllRead]);
|
||||||
|
|
||||||
|
const isLoading = crewLoading || activityLoading;
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -144,67 +180,132 @@ export function NotificationBell({ className }: { className?: string }) {
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn('relative text-gray-400 hover:text-teal-400 hover:bg-teal-500/10', className)}
|
className={cn('relative text-gray-400 hover:text-teal-400 hover:bg-teal-500/10', className)}
|
||||||
title="Notifications"
|
title="Notifications"
|
||||||
aria-label={`Notifications${total > 0 ? ` (${total} new)` : ''}`}
|
aria-label={`Notifications${badgeTotal > 0 ? ` (${badgeTotal} new)` : ''}`}
|
||||||
>
|
>
|
||||||
<Bell className="h-5 w-5" />
|
<Bell className="h-5 w-5" />
|
||||||
{total > 0 && (
|
{badgeTotal > 0 && (
|
||||||
<span className="absolute -right-0.5 -top-0.5 flex min-w-[18px] items-center justify-center rounded-full bg-teal-500 px-1 text-[10px] font-black leading-[18px] text-black">
|
<span className="absolute -right-0.5 -top-0.5 flex min-w-[18px] items-center justify-center rounded-full bg-teal-500 px-1 text-[10px] font-black leading-[18px] text-black">
|
||||||
{total > 9 ? '9+' : total}
|
{badgeTotal > 9 ? '9+' : badgeTotal}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="end" className="w-[360px] max-w-[calc(100vw-1.5rem)] p-0" sideOffset={8}>
|
<PopoverContent align="end" className="w-[380px] max-w-[calc(100vw-1.5rem)] p-0" sideOffset={8}>
|
||||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
|
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
|
||||||
<h3 className="flex items-center gap-2 text-sm font-black uppercase tracking-wide text-white">
|
<h3 className="flex items-center gap-2 text-sm font-black uppercase tracking-wide text-white">
|
||||||
<Bell className="h-4 w-4 text-teal-400" /> Notifications
|
<Bell className="h-4 w-4 text-teal-400" /> Notifications
|
||||||
</h3>
|
</h3>
|
||||||
{total > 0 && (
|
<button
|
||||||
<span className="rounded-full bg-teal-500/20 px-2 py-0.5 text-[10px] font-black uppercase text-teal-300">
|
type="button"
|
||||||
{total} new
|
onClick={markAllRead}
|
||||||
</span>
|
className="flex items-center gap-1 text-[10px] font-black uppercase tracking-wide text-gray-500 transition-colors hover:text-teal-400"
|
||||||
)}
|
>
|
||||||
|
<CheckCheck className="h-3.5 w-3.5" /> Mark read
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="max-h-[70vh]">
|
<Tabs defaultValue="all" className="w-full">
|
||||||
<div className="space-y-2.5 p-3">
|
<TabsList className="grid w-full grid-cols-3 rounded-none border-b border-white/10 bg-transparent p-0">
|
||||||
{isLoading && total === 0 && (
|
<TabsTrigger
|
||||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
|
value="all"
|
||||||
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
|
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
|
||||||
</div>
|
>
|
||||||
)}
|
All
|
||||||
|
{badgeTotal > 0 && <span className="ml-1 text-teal-400">{badgeTotal}</span>}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="social"
|
||||||
|
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
|
||||||
|
>
|
||||||
|
<Heart className="mr-1 h-3 w-3" /> Social
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="crews"
|
||||||
|
className="rounded-none border-b-2 border-transparent py-2.5 text-xs font-bold data-[state=active]:border-teal-400 data-[state=active]:bg-transparent data-[state=active]:text-teal-400"
|
||||||
|
>
|
||||||
|
<Users className="mr-1 h-3 w-3" /> Crews
|
||||||
|
{crewTotal > 0 && <span className="ml-1 text-teal-400">{crewTotal}</span>}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
{!isLoading && total === 0 && (
|
<ScrollArea className="max-h-[65vh]">
|
||||||
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
{/* ─── ALL ─────────────────────────────────────────────────── */}
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5">
|
<TabsContent value="all" className="m-0 p-3">
|
||||||
<Bell className="h-5 w-5 text-gray-600" />
|
{isLoading && badgeTotal === 0 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-bold text-gray-400">You're all caught up</p>
|
)}
|
||||||
<p className="max-w-[220px] text-xs text-gray-600">
|
{!isLoading && crewTotal === 0 && activity.length === 0 && (
|
||||||
Crew invites and membership requests will show up here.
|
<EmptyState label="Zaps, reactions, replies and crew activity will show up here." />
|
||||||
</p>
|
)}
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{joinRequests.map(({ request, crew, endorsements }) => (
|
||||||
|
<JoinRequestCard key={request.id} request={request} crew={crew} endorsements={endorsements} compact />
|
||||||
|
))}
|
||||||
|
{invites.map((invite) => (
|
||||||
|
<CrewInviteCard key={invite.id} invite={invite} compact />
|
||||||
|
))}
|
||||||
|
{listedCrews.map((crew) => (
|
||||||
|
<PendingConfirmCard key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />
|
||||||
|
))}
|
||||||
|
{activity.map((n) => (
|
||||||
|
<ActivityNotificationCard
|
||||||
|
key={n.id}
|
||||||
|
notification={n}
|
||||||
|
unread={n.createdAt > lastRead}
|
||||||
|
onNavigate={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</TabsContent>
|
||||||
|
|
||||||
{joinRequests.map(({ request, crew, endorsements }) => (
|
{/* ─── SOCIAL ──────────────────────────────────────────────── */}
|
||||||
<JoinRequestCard
|
<TabsContent value="social" className="m-0 p-3">
|
||||||
key={request.id}
|
{activityLoading && activity.length === 0 && (
|
||||||
request={request}
|
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
|
||||||
crew={crew}
|
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
|
||||||
endorsements={endorsements}
|
</div>
|
||||||
compact
|
)}
|
||||||
/>
|
{!activityLoading && activity.length === 0 && (
|
||||||
))}
|
<EmptyState label="When people zap, react, repost, mention or reply to you, it lands here." />
|
||||||
|
)}
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{activity.map((n) => (
|
||||||
|
<ActivityNotificationCard
|
||||||
|
key={n.id}
|
||||||
|
notification={n}
|
||||||
|
unread={n.createdAt > lastRead}
|
||||||
|
onNavigate={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{invites.map((invite) => (
|
{/* ─── CREWS ───────────────────────────────────────────────── */}
|
||||||
<CrewInviteCard key={invite.id} invite={invite} compact />
|
<TabsContent value="crews" className="m-0 p-3">
|
||||||
))}
|
{crewLoading && crewTotal === 0 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-8 text-sm text-gray-500">
|
||||||
{listedCrews.map((crew) => (
|
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
|
||||||
<PendingConfirmCard key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
{!crewLoading && crewTotal === 0 && (
|
||||||
</ScrollArea>
|
<EmptyState label="Crew invites and membership requests will show up here." />
|
||||||
|
)}
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{joinRequests.map(({ request, crew, endorsements }) => (
|
||||||
|
<JoinRequestCard key={request.id} request={request} crew={crew} endorsements={endorsements} compact />
|
||||||
|
))}
|
||||||
|
{invites.map((invite) => (
|
||||||
|
<CrewInviteCard key={invite.id} invite={invite} compact />
|
||||||
|
))}
|
||||||
|
{listedCrews.map((crew) => (
|
||||||
|
<PendingConfirmCard key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</ScrollArea>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<div className="border-t border-white/10 p-2">
|
<div className="border-t border-white/10 p-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
205
src/hooks/useActivityNotifications.ts
Normal file
205
src/hooks/useActivityNotifications.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General, app-wide activity notifications for the logged-in user.
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* This complements the crew-specific notifications (invites / join-requests)
|
||||||
|
* with the "social" notifications any Nostr user expects: reactions, reposts,
|
||||||
|
* zaps, mentions and replies/comments that reference THEM.
|
||||||
|
*
|
||||||
|
* How it works: we do ONE combined query for events that tag the user's pubkey
|
||||||
|
* (`#p`), across the relevant kinds, then classify them in JS. This keeps relay
|
||||||
|
* load minimal (a single filter) per the project's query-efficiency rules.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ActivityType = 'reaction' | 'repost' | 'zap' | 'mention' | 'reply';
|
||||||
|
|
||||||
|
export interface ActivityNotification {
|
||||||
|
id: string;
|
||||||
|
type: ActivityType;
|
||||||
|
/** Who triggered it (reactor / reposter / zapper / author). */
|
||||||
|
pubkey: string;
|
||||||
|
createdAt: number;
|
||||||
|
/** The raw event, for rendering extra context (reaction emoji, comment text). */
|
||||||
|
event: NostrEvent;
|
||||||
|
/** For reactions: the emoji/content ("+", "🛹", …). */
|
||||||
|
content?: string;
|
||||||
|
/** The event id this activity points at (their content), if any. */
|
||||||
|
targetEventId?: string;
|
||||||
|
/** Sats amount for zaps, parsed from the bolt11/description when available. */
|
||||||
|
amountSats?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REACTION_KIND = 7;
|
||||||
|
const REPOST_KINDS = [6, 16];
|
||||||
|
const ZAP_RECEIPT_KIND = 9735;
|
||||||
|
const TEXT_NOTE_KIND = 1;
|
||||||
|
const COMMENT_KIND = 1111;
|
||||||
|
|
||||||
|
/** Parse the sats amount out of a zap receipt (9735) if we can. */
|
||||||
|
function parseZapAmount(event: NostrEvent): number | undefined {
|
||||||
|
// Zap receipts embed the original zap request in a "description" tag; that
|
||||||
|
// request usually carries an "amount" tag in millisats.
|
||||||
|
try {
|
||||||
|
const desc = event.tags.find(([t]) => t === 'description')?.[1];
|
||||||
|
if (desc) {
|
||||||
|
const req = JSON.parse(desc) as { tags?: string[][] };
|
||||||
|
const amountMsat = req.tags?.find((t) => t[0] === 'amount')?.[1];
|
||||||
|
if (amountMsat) {
|
||||||
|
const msat = parseInt(amountMsat, 10);
|
||||||
|
if (Number.isFinite(msat) && msat > 0) return Math.round(msat / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed description
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pubkey that actually SENT a zap (the zapper), from the receipt. */
|
||||||
|
function zapSenderPubkey(event: NostrEvent): string {
|
||||||
|
// Prefer the "P" (uppercase) tag = sender pubkey (NIP-57), then the embedded
|
||||||
|
// request's pubkey, then fall back to the receipt author (the LN service).
|
||||||
|
const upperP = event.tags.find(([t]) => t === 'P')?.[1];
|
||||||
|
if (upperP) return upperP;
|
||||||
|
try {
|
||||||
|
const desc = event.tags.find(([t]) => t === 'description')?.[1];
|
||||||
|
if (desc) {
|
||||||
|
const req = JSON.parse(desc) as { pubkey?: string };
|
||||||
|
if (req.pubkey) return req.pubkey;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return event.pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActivityNotifications() {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['activity-notifications', user?.pubkey],
|
||||||
|
enabled: !!user?.pubkey,
|
||||||
|
// Refresh periodically so the bell stays fresh without hammering relays.
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchInterval: 90_000,
|
||||||
|
queryFn: async (c) => {
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(4000)]);
|
||||||
|
const pubkey = user!.pubkey;
|
||||||
|
|
||||||
|
// Single combined query: everything that tags me.
|
||||||
|
const events = await nostr.query(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kinds: [
|
||||||
|
REACTION_KIND,
|
||||||
|
...REPOST_KINDS,
|
||||||
|
ZAP_RECEIPT_KIND,
|
||||||
|
TEXT_NOTE_KIND,
|
||||||
|
COMMENT_KIND,
|
||||||
|
],
|
||||||
|
'#p': [pubkey],
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
const out: ActivityNotification[] = [];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
// Never notify the user about their own actions.
|
||||||
|
if (event.pubkey === pubkey && event.kind !== ZAP_RECEIPT_KIND) continue;
|
||||||
|
|
||||||
|
const targetEventId = event.tags.find(([t]) => t === 'e')?.[1];
|
||||||
|
|
||||||
|
if (event.kind === REACTION_KIND) {
|
||||||
|
out.push({
|
||||||
|
id: event.id,
|
||||||
|
type: 'reaction',
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
event,
|
||||||
|
content: event.content || '+',
|
||||||
|
targetEventId,
|
||||||
|
});
|
||||||
|
} else if (REPOST_KINDS.includes(event.kind)) {
|
||||||
|
out.push({
|
||||||
|
id: event.id,
|
||||||
|
type: 'repost',
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
event,
|
||||||
|
targetEventId,
|
||||||
|
});
|
||||||
|
} else if (event.kind === ZAP_RECEIPT_KIND) {
|
||||||
|
const sender = zapSenderPubkey(event);
|
||||||
|
if (sender === pubkey) continue; // don't notify on self-zaps
|
||||||
|
out.push({
|
||||||
|
id: event.id,
|
||||||
|
type: 'zap',
|
||||||
|
pubkey: sender,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
event,
|
||||||
|
targetEventId,
|
||||||
|
amountSats: parseZapAmount(event),
|
||||||
|
});
|
||||||
|
} else if (event.kind === TEXT_NOTE_KIND) {
|
||||||
|
// A kind-1 that p-tags me: either a reply to my note or a mention.
|
||||||
|
const isReply = event.tags.some(([t]) => t === 'e');
|
||||||
|
out.push({
|
||||||
|
id: event.id,
|
||||||
|
type: isReply ? 'reply' : 'mention',
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
event,
|
||||||
|
content: event.content,
|
||||||
|
targetEventId,
|
||||||
|
});
|
||||||
|
} else if (event.kind === COMMENT_KIND) {
|
||||||
|
out.push({
|
||||||
|
id: event.id,
|
||||||
|
type: 'reply',
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
createdAt: event.created_at,
|
||||||
|
event,
|
||||||
|
content: event.content,
|
||||||
|
targetEventId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest first.
|
||||||
|
out.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications: query.data ?? [],
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable verb for each activity type. */
|
||||||
|
export function activityVerb(n: ActivityNotification): string {
|
||||||
|
switch (n.type) {
|
||||||
|
case 'reaction':
|
||||||
|
return n.content && n.content !== '+' ? `reacted ${n.content}` : 'liked your post';
|
||||||
|
case 'repost':
|
||||||
|
return 'reposted you';
|
||||||
|
case 'zap':
|
||||||
|
return n.amountSats ? `zapped you ${n.amountSats.toLocaleString()} sats` : 'zapped you';
|
||||||
|
case 'mention':
|
||||||
|
return 'mentioned you';
|
||||||
|
case 'reply':
|
||||||
|
return 'replied to you';
|
||||||
|
default:
|
||||||
|
return 'interacted with you';
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/hooks/useNotificationsRead.ts
Normal file
21
src/hooks/useNotificationsRead.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the timestamp (unix seconds) at which the logged-in user last opened
|
||||||
|
* their notification center. Anything newer than this is considered "unread"
|
||||||
|
* and contributes to the bell's badge count. Stored per-pubkey in localStorage
|
||||||
|
* so switching accounts keeps independent read state.
|
||||||
|
*/
|
||||||
|
export function useNotificationsRead() {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const key = user ? `notifications-last-read:${user.pubkey}` : 'notifications-last-read:anon';
|
||||||
|
const [lastRead, setLastRead] = useLocalStorage<number>(key, 0);
|
||||||
|
|
||||||
|
const markAllRead = useCallback(() => {
|
||||||
|
setLastRead(Math.floor(Date.now() / 1000));
|
||||||
|
}, [setLastRead]);
|
||||||
|
|
||||||
|
return { lastRead, markAllRead };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue