From 8824bb759f857337c17d930ffa3118102228583f Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:03:29 +0200 Subject: [PATCH] Add notifications center --- src/components/nostr/NotificationsCenter.tsx | 227 +++++++++++++++++++ src/components/os/MenuBar.tsx | 2 + src/components/os/MobileAppShell.tsx | 3 + src/hooks/useNotifications.ts | 81 +++++++ 4 files changed, 313 insertions(+) create mode 100644 src/components/nostr/NotificationsCenter.tsx create mode 100644 src/hooks/useNotifications.ts diff --git a/src/components/nostr/NotificationsCenter.tsx b/src/components/nostr/NotificationsCenter.tsx new file mode 100644 index 0000000..5d0dfc7 --- /dev/null +++ b/src/components/nostr/NotificationsCenter.tsx @@ -0,0 +1,227 @@ +import { Bell, BellRing, Heart, MessageCircle, Repeat2, UserPlus, Zap } from 'lucide-react'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} from '@/components/ui/popover'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '@/components/ui/sheet'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useNotificationReadState, useNotifications, type Notification, type NotificationKind } from '@/hooks/useNotifications'; +import { useWindowManager } from '@/os/useWindowManager'; +import { displayName, relativeTime } from '@/lib/nostrUtils'; +import { cn } from '@/lib/utils'; + +const ICONS: Record = { + mention: MessageCircle, + reply: MessageCircle, + reaction: Heart, + repost: Repeat2, + follow: UserPlus, + zap: Zap, +}; + +const LABELS: Record = { + mention: 'mentioned you', + reply: 'replied to you', + reaction: 'reacted to your note', + repost: 'reposted your note', + follow: 'followed you', + zap: 'sent you a zap', +}; + +/** A desktop popover and mobile sheet backed by the same notification feed. */ +export function NotificationsPopover() { + const state = useNotificationState(); + if (!state) return null; + + return ( + + + + + + + + Notifications + + + + + + ); +} + +/** On compact layouts a sheet gives notification rows room to breathe. */ +export function NotificationsSheet() { + const state = useNotificationState(); + const [open, setOpen] = useState(false); + if (!state) return null; + + return ( + + + + + + + Notifications + Recent activity around your notes and profile. + + setOpen(false)} showHeading={false} /> + + + ); +} + +function useNotificationState() { + const query = useNotifications(); + const { lastReadAt, markAllRead } = useNotificationReadState(); + const notifications = query.data ?? []; + const unreadCount = notifications.filter(({ event }) => event.created_at > lastReadAt).length; + + return query.isFetching || query.isError || notifications.length > 0 || query.isSuccess + ? { ...query, notifications, unreadCount, lastReadAt, markAllRead } + : null; +} + +function NotificationButton({ unreadCount }: { unreadCount: number }) { + const label = unreadCount > 0 + ? `Notifications, ${unreadCount > 99 ? '99 or more' : unreadCount} unread` + : 'Notifications, no unread notifications'; + + return ( + + ); +} + +type NotificationState = NonNullable>; + +function NotificationFeed({ + state, + onNavigate, + showHeading = true, +}: { + state: NotificationState; + onNavigate?: () => void; + showHeading?: boolean; +}) { + return ( +
+ {showHeading && ( +
+ + Notifications + Activity around your notes and profile. + + +
+ )} + {!showHeading && ( +
+ +
+ )} + +
+ {state.isLoading ? : null} + {state.isError ? : null} + {state.isSuccess && state.notifications.length === 0 ? : null} + {state.isSuccess && state.notifications.map((notification) => ( + state.lastReadAt} + onNavigate={onNavigate} + /> + ))} +
+
+ ); +} + +function MarkAllReadButton({ state }: { state: NotificationState }) { + if (state.unreadCount === 0) return null; + return ( + + ); +} + +function NotificationRow({ notification, unread, onNavigate }: { notification: Notification; unread: boolean; onNavigate?: () => void }) { + const { openApp } = useWindowManager(); + const { data } = useAuthor(notification.event.pubkey); + const Icon = ICONS[notification.kind]; + const author = displayName(notification.event.pubkey, data?.metadata); + const preview = notification.event.content.replace(/\s+/g, ' ').trim(); + const targetEvent = notification.event.tags.find(([name]) => name === 'e')?.[1]; + + const openNotification = () => { + if (targetEvent) openApp('notes', { id: targetEvent }); + else openApp('profile', { pubkey: notification.event.pubkey }); + onNavigate?.(); + }; + + return ( + + ); +} + +function LoadingNotifications() { + return
; +} + +function EmptyNotifications() { + return
No notifications yet. When someone interacts with your notes or profile, they’ll appear here.
; +} + +function NotificationError() { + return
Notifications could not be loaded. Check your relay connection and try again.
; +} diff --git a/src/components/os/MenuBar.tsx b/src/components/os/MenuBar.tsx index ea539c4..80e2310 100644 --- a/src/components/os/MenuBar.tsx +++ b/src/components/os/MenuBar.tsx @@ -12,6 +12,7 @@ import { } from '@/components/ui/menubar'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { LoginArea } from '@/components/auth/LoginArea'; +import { NotificationsPopover } from '@/components/nostr/NotificationsCenter'; import { MenuBarClock } from './MenuBarClock'; import { useWindowManager } from '@/os/useWindowManager'; import { APPS, getApp } from '@/os/registry'; @@ -158,6 +159,7 @@ export function MenuBar({ onOpenCommandPalette }: MenuBarProps) {
openApp('relays')} /> +
diff --git a/src/components/os/MobileAppShell.tsx b/src/components/os/MobileAppShell.tsx index ada5899..7a4e67a 100644 --- a/src/components/os/MobileAppShell.tsx +++ b/src/components/os/MobileAppShell.tsx @@ -3,6 +3,7 @@ import { ChevronLeft, LayoutGrid, Zap } from 'lucide-react'; import { Skeleton } from '@/components/ui/skeleton'; import { ErrorBoundary } from '@/components/ErrorBoundary'; import { LoginArea } from '@/components/auth/LoginArea'; +import { NotificationsSheet } from '@/components/nostr/NotificationsCenter'; import { Sheet, SheetContent, @@ -78,6 +79,8 @@ export function MobileAppShell() { {active?.title} + + {windows.length > 0 ? ( name === 'e') ? 'reply' : 'mention'; + default: + return 'mention'; + } +} + +/** + * Fetches activity that explicitly tags the current user. Relays index the + * single-letter `p` tag, so this stays a narrow query instead of downloading a + * general timeline and filtering it in the browser. + */ +export function useNotifications() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + + return useQuery({ + queryKey: ['nostr', 'notifications', user?.pubkey ?? ''], + enabled: Boolean(user), + queryFn: async ({ signal }) => { + if (!user) return []; + const events = await nostr.query( + [{ kinds: [...NOTIFICATION_KINDS], '#p': [user.pubkey], limit: 100 }], + { + signal: AbortSignal.any([ + signal, + AbortSignal.timeout(6000), + ]), + }, + ); + + const seen = new Set(); + return events + .filter((event) => event.pubkey !== user.pubkey && !seen.has(event.id) && seen.add(event.id)) + .sort((left, right) => right.created_at - left.created_at) + .map((event) => ({ event, kind: notificationKind(event) })); + }, + staleTime: 30_000, + }); +} + +/** Persists the most recent timestamp the account has explicitly acknowledged. */ +export function useNotificationReadState() { + const { user } = useCurrentUser(); + const [lastReadAt, setLastReadAt] = useLocalStorage( + `nostr:notifications:last-read:${user?.pubkey ?? 'anonymous'}`, + 0, + ); + + return { + lastReadAt, + markAllRead: () => setLastReadAt(Math.floor(Date.now() / 1000)), + }; +}