From c34f194d8f7d630861a94d280f875deb2d4e6ce5 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 3 Sep 2026 10:21:15 +0700 Subject: [PATCH] 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 --- src/components/ActivityNotificationCard.tsx | 116 +++++++++++ src/components/NotificationBell.tsx | 205 +++++++++++++++----- src/hooks/useActivityNotifications.ts | 205 ++++++++++++++++++++ src/hooks/useNotificationsRead.ts | 21 ++ 4 files changed, 495 insertions(+), 52 deletions(-) create mode 100644 src/components/ActivityNotificationCard.tsx create mode 100644 src/hooks/useActivityNotifications.ts create mode 100644 src/hooks/useNotificationsRead.ts diff --git a/src/components/ActivityNotificationCard.tsx b/src/components/ActivityNotificationCard.tsx new file mode 100644 index 0000000..c248fa3 --- /dev/null +++ b/src/components/ActivityNotificationCard.tsx @@ -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 = ( +
+ {/* Actor avatar with a type badge */} +
+ + + + {name.slice(0, 2).toUpperCase()} + + + + + +
+ +
+

+ {name}{' '} + {activityVerb(notification)} +

+ {snippet && ( +

"{snippet}"

+ )} +

{timeAgo}

+
+ + {unread && ( + + )} +
+ ); + + // Link to the actor's profile (the always-valid in-app destination). + if (!npub) return body; + + return ( + + {body} + + ); +} diff --git a/src/components/NotificationBell.tsx b/src/components/NotificationBell.tsx index e938c1c..aec691b 100644 --- a/src/components/NotificationBell.tsx +++ b/src/components/NotificationBell.tsx @@ -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 ( +
+
+ +
+

You're all caught up

+

{label}

+
+ ); +} + /** - * 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)` : ''}`} > - {total > 0 && ( + {badgeTotal > 0 && ( - {total > 9 ? '9+' : total} + {badgeTotal > 9 ? '9+' : badgeTotal} )} - +

Notifications

- {total > 0 && ( - - {total} new - - )} +
- -
- {isLoading && total === 0 && ( -
- Checking for updates… -
- )} + + + + All + {badgeTotal > 0 && {badgeTotal}} + + + Social + + + Crews + {crewTotal > 0 && {crewTotal}} + + - {!isLoading && total === 0 && ( -
-
- + + {/* ─── ALL ─────────────────────────────────────────────────── */} + + {isLoading && badgeTotal === 0 && ( +
+ Checking for updates…
-

You're all caught up

-

- Crew invites and membership requests will show up here. -

+ )} + {!isLoading && crewTotal === 0 && activity.length === 0 && ( + + )} +
+ {joinRequests.map(({ request, crew, endorsements }) => ( + + ))} + {invites.map((invite) => ( + + ))} + {listedCrews.map((crew) => ( + + ))} + {activity.map((n) => ( + lastRead} + onNavigate={() => setOpen(false)} + /> + ))}
- )} +
- {joinRequests.map(({ request, crew, endorsements }) => ( - - ))} + {/* ─── SOCIAL ──────────────────────────────────────────────── */} + + {activityLoading && activity.length === 0 && ( +
+ Checking for updates… +
+ )} + {!activityLoading && activity.length === 0 && ( + + )} +
+ {activity.map((n) => ( + lastRead} + onNavigate={() => setOpen(false)} + /> + ))} +
+
- {invites.map((invite) => ( - - ))} - - {listedCrews.map((crew) => ( - - ))} -
- + {/* ─── CREWS ───────────────────────────────────────────────── */} + + {crewLoading && crewTotal === 0 && ( +
+ Checking for updates… +
+ )} + {!crewLoading && crewTotal === 0 && ( + + )} +
+ {joinRequests.map(({ request, crew, endorsements }) => ( + + ))} + {invites.map((invite) => ( + + ))} + {listedCrews.map((crew) => ( + + ))} +
+
+ +