Add notifications center with unread badge (#41)

* Add notifications center

* Fix notification event selection and account read-state sync

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

* Fix notification bell triggers

* Keep notification badge within menu bar

* Place notification count beside bell

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
mroxso
2026-09-06 22:32:40 +02:00
committed by GitHub
parent adc65f9819
commit 4c20938098
5 changed files with 338 additions and 2 deletions

View File

@@ -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<NotificationKind, typeof Bell> = {
mention: MessageCircle,
reply: MessageCircle,
reaction: Heart,
repost: Repeat2,
follow: UserPlus,
zap: Zap,
};
const LABELS: Record<NotificationKind, string> = {
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 (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<NotificationButton unreadCount={state.unreadCount} />
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Notifications</TooltipContent>
</Tooltip>
<PopoverContent align="end" sideOffset={8} className="w-[min(24rem,calc(100vw-1rem))] overflow-hidden p-0">
<NotificationFeed state={state} />
</PopoverContent>
</Popover>
);
}
/** 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 (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<NotificationButton unreadCount={state.unreadCount} />
</SheetTrigger>
<SheetContent side="bottom" className="h-[calc(100dvh-3rem)] max-h-none gap-0 rounded-t-xl p-0">
<SheetHeader className="border-b px-5 py-4 pr-12">
<SheetTitle>Notifications</SheetTitle>
<SheetDescription>Recent activity around your notes and profile.</SheetDescription>
</SheetHeader>
<NotificationFeed state={state} onNavigate={() => setOpen(false)} showHeading={false} />
</SheetContent>
</Sheet>
);
}
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 (
<button
{...props}
type={props.type ?? 'button'}
className={cn(
'relative flex h-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
unreadCount > 0 ? 'gap-1 px-1.5' : 'w-7',
className,
)}
aria-label={props['aria-label'] ?? label}
>
{unreadCount > 0 ? <BellRing className="size-3.5" aria-hidden /> : <Bell className="size-3.5" aria-hidden />}
{unreadCount > 0 && (
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold leading-4 text-primary-foreground tabular-nums">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
);
}
type NotificationState = NonNullable<ReturnType<typeof useNotificationState>>;
function NotificationFeed({
state,
onNavigate,
showHeading = true,
}: {
state: NotificationState;
onNavigate?: () => void;
showHeading?: boolean;
}) {
return (
<section className="flex min-h-0 flex-1 flex-col" aria-label="Notifications">
{showHeading && (
<div className="flex items-start justify-between gap-3 border-b px-4 py-3">
<PopoverHeader>
<PopoverTitle>Notifications</PopoverTitle>
<PopoverDescription>Activity around your notes and profile.</PopoverDescription>
</PopoverHeader>
<MarkAllReadButton state={state} />
</div>
)}
{!showHeading && (
<div className="flex justify-end border-b px-4 py-2">
<MarkAllReadButton state={state} />
</div>
)}
<div className="os-scroll min-h-0 flex-1 overflow-y-auto">
{state.isLoading ? <LoadingNotifications /> : null}
{state.isError ? <NotificationError /> : null}
{state.isSuccess && state.notifications.length === 0 ? <EmptyNotifications /> : null}
{state.isSuccess && state.notifications.map((notification) => (
<NotificationRow
key={notification.event.id}
notification={notification}
unread={notification.event.created_at > state.lastReadAt}
onNavigate={onNavigate}
/>
))}
</div>
</section>
);
}
function MarkAllReadButton({ state }: { state: NotificationState }) {
if (state.unreadCount === 0) return null;
return (
<Button variant="ghost" size="sm" className="h-7 shrink-0 text-xs" onClick={state.markAllRead}>
Mark all read
</Button>
);
}
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 (
<button
type="button"
onClick={openNotification}
className={cn(
'flex w-full gap-3 border-b border-border px-4 py-3 text-left transition-colors hover:bg-muted/70 focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-ring',
unread && 'bg-primary/[0.06]',
)}
>
<span className={cn('mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground', unread && 'bg-primary/15 text-primary')}>
<Icon className="size-3.5" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-2">
<span className="truncate text-sm font-semibold">{author}</span>
<time className="ml-auto shrink-0 text-xs text-muted-foreground" dateTime={new Date(notification.event.created_at * 1000).toISOString()}>
{relativeTime(notification.event.created_at)}
</time>
</span>
<span className="mt-0.5 block text-sm text-muted-foreground">{LABELS[notification.kind]}</span>
{preview && <span className="mt-1 block line-clamp-2 text-sm text-foreground/80">{preview}</span>}
</span>
{unread && <span className="mt-2 size-1.5 shrink-0 rounded-full bg-primary" aria-label="Unread" />}
</button>
);
}
function LoadingNotifications() {
return <div className="space-y-4 p-4" aria-label="Loading notifications"><Skeleton className="h-12 w-full" /><Skeleton className="h-12 w-4/5" /><Skeleton className="h-12 w-11/12" /></div>;
}
function EmptyNotifications() {
return <div className="m-4 rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">No notifications yet. When someone interacts with your notes or profile, theyll appear here.</div>;
}
function NotificationError() {
return <div className="m-4 rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">Notifications could not be loaded. Check your relay connection and try again.</div>;
}

View File

@@ -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) {
<div className="flex items-center gap-3 pr-1 text-muted-foreground">
<RelayIndicator onOpen={() => openApp('relays')} />
<ThemeToggle />
<NotificationsPopover />
<MenuBarClock />
<LoginArea compact />
</div>

View File

@@ -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() {
<span className="mx-auto truncate text-sm font-medium">{active?.title}</span>
<NotificationsSheet />
{windows.length > 0 ? (
<Sheet open={switcherOpen} onOpenChange={setSwitcherOpen}>
<SheetTrigger

View File

@@ -29,7 +29,7 @@ export function useLocalStorage<T>(
const serialize = serializer?.serialize || JSON.stringify;
const deserialize = serializer?.deserialize || JSON.parse;
const [state, setState] = useState<T>(() => {
const readValue = () => {
try {
const item = localStorage.getItem(key);
return item ? deserialize(item) : defaultValue;
@@ -37,7 +37,14 @@ export function useLocalStorage<T>(
console.warn(`Failed to load ${key} from localStorage:`, error);
return defaultValue;
}
});
};
const [state, setState] = useState<T>(readValue);
const [storageKey, setStorageKey] = useState(key);
if (storageKey !== key) {
setStorageKey(key);
setState(readValue());
}
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {

View File

@@ -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<Notification[]>({
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<string>();
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<number>(
`nostr:notifications:last-read:${user?.pubkey ?? 'anonymous'}`,
0,
);
return {
lastReadAt,
markAllRead: () => setLastReadAt(Math.floor(Date.now() / 1000)),
};
}