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:
shakespeare.diy 2026-09-03 10:21:15 +07:00
parent 35915557bb
commit c34f194d8f
4 changed files with 495 additions and 52 deletions

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

View file

@ -1,13 +1,17 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { CrewInviteCard } from '@/components/CrewInviteCard';
import { JoinRequestCard } from '@/components/JoinRequestCard';
import { ActivityNotificationCard } from '@/components/ActivityNotificationCard';
import { useCrewNotifications, useIsPendingMember } from '@/hooks/useCrewNotifications';
import { useActivityNotifications } from '@/hooks/useActivityNotifications';
import { useNotificationsRead } from '@/hooks/useNotificationsRead';
import { useConfirmMembership } from '@/hooks/useMembershipAttestation';
import { useAuthor } from '@/hooks/useAuthor';
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:
* - structured crew invites (accept / decline)
* - "pending confirmation" crews (someone listed you — confirm to join)
* Header notification bell — the user's whole activity center, not just crews:
*
* 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 }) {
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 total = useMemo(
const crewTotal = useMemo(
() => invites.length + listedCrews.length + joinRequests.length,
[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;
return (
@ -144,67 +180,132 @@ export function NotificationBell({ className }: { className?: string }) {
size="icon"
className={cn('relative text-gray-400 hover:text-teal-400 hover:bg-teal-500/10', className)}
title="Notifications"
aria-label={`Notifications${total > 0 ? ` (${total} new)` : ''}`}
aria-label={`Notifications${badgeTotal > 0 ? ` (${badgeTotal} new)` : ''}`}
>
<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">
{total > 9 ? '9+' : total}
{badgeTotal > 9 ? '9+' : badgeTotal}
</span>
)}
</Button>
</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">
<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
</h3>
{total > 0 && (
<span className="rounded-full bg-teal-500/20 px-2 py-0.5 text-[10px] font-black uppercase text-teal-300">
{total} new
</span>
)}
<button
type="button"
onClick={markAllRead}
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>
<ScrollArea className="max-h-[70vh]">
<div className="space-y-2.5 p-3">
{isLoading && total === 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>
)}
<Tabs defaultValue="all" className="w-full">
<TabsList className="grid w-full grid-cols-3 rounded-none border-b border-white/10 bg-transparent p-0">
<TabsTrigger
value="all"
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"
>
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 && (
<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" />
<ScrollArea className="max-h-[65vh]">
{/* ─── ALL ─────────────────────────────────────────────────── */}
<TabsContent value="all" className="m-0 p-3">
{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>
<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">
Crew invites and membership requests will show up here.
</p>
)}
{!isLoading && crewTotal === 0 && activity.length === 0 && (
<EmptyState label="Zaps, reactions, replies and crew activity 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} />
))}
{activity.map((n) => (
<ActivityNotificationCard
key={n.id}
notification={n}
unread={n.createdAt > lastRead}
onNavigate={() => setOpen(false)}
/>
))}
</div>
)}
</TabsContent>
{joinRequests.map(({ request, crew, endorsements }) => (
<JoinRequestCard
key={request.id}
request={request}
crew={crew}
endorsements={endorsements}
compact
/>
))}
{/* ─── SOCIAL ──────────────────────────────────────────────── */}
<TabsContent value="social" className="m-0 p-3">
{activityLoading && activity.length === 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>
)}
{!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) => (
<CrewInviteCard key={invite.id} invite={invite} compact />
))}
{listedCrews.map((crew) => (
<PendingConfirmCard key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />
))}
</div>
</ScrollArea>
{/* ─── CREWS ───────────────────────────────────────────────── */}
<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">
<Loader2 className="h-4 w-4 animate-spin" /> Checking for updates…
</div>
)}
{!crewLoading && crewTotal === 0 && (
<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">
<Button

View 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';
}
}

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