diff --git a/src/components/nostr/NotificationsCenter.tsx b/src/components/nostr/NotificationsCenter.tsx new file mode 100644 index 0000000..0503438 --- /dev/null +++ b/src/components/nostr/NotificationsCenter.tsx @@ -0,0 +1,238 @@ +import { Bell, BellRing, Heart, MessageCircle, Repeat2, UserPlus, Zap } from 'lucide-react'; +import { useState, type ComponentProps } 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, rootReference } 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, + className, + ...props +}: { unreadCount: number } & ComponentProps<'button'>) { + 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.kind === 'mention' || notification.kind === 'reply' + ? notification.event.id + : rootReference(notification.event); + + 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 ? ( ( const serialize = serializer?.serialize || JSON.stringify; const deserialize = serializer?.deserialize || JSON.parse; - const [state, setState] = useState(() => { + const readValue = () => { try { const item = localStorage.getItem(key); return item ? deserialize(item) : defaultValue; @@ -37,7 +37,14 @@ export function useLocalStorage( console.warn(`Failed to load ${key} from localStorage:`, error); return defaultValue; } - }); + }; + + const [state, setState] = useState(readValue); + const [storageKey, setStorageKey] = useState(key); + if (storageKey !== key) { + setStorageKey(key); + setState(readValue()); + } const setValue = useCallback( (value: T | ((prev: T) => T)) => { diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts new file mode 100644 index 0000000..407208d --- /dev/null +++ b/src/hooks/useNotifications.ts @@ -0,0 +1,86 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { isReply } from '@/lib/nostrUtils'; +import { useCurrentUser } from './useCurrentUser'; +import { useLocalStorage } from './useLocalStorage'; + +/** Nostr event kinds that commonly represent activity directed at a person. */ +const NOTIFICATION_KINDS = [1, 3, 6, 7, 16, 9735] as const; + +export type NotificationKind = 'mention' | 'reply' | 'reaction' | 'repost' | 'follow' | 'zap'; + +export interface Notification { + event: NostrEvent; + kind: NotificationKind; +} + +function notificationKind(event: NostrEvent): NotificationKind { + switch (event.kind) { + case 3: + return 'follow'; + case 7: + return 'reaction'; + case 6: + case 16: + return 'repost'; + case 9735: + return 'zap'; + case 1: + return isReply(event) ? '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) => { + if (event.pubkey === user.pubkey || seen.has(event.id)) return false; + seen.add(event.id); + return true; + }) + .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)), + }; +}