feat(notifications): header notification bell
This commit is contained in:
@@ -4,6 +4,7 @@ import type { ThemeToggleProps } from '@spoon/ui';
|
|||||||
import { ThemeToggle } from '@spoon/ui';
|
import { ThemeToggle } from '@spoon/ui';
|
||||||
|
|
||||||
import { AvatarDropdown } from './AvatarDropdown';
|
import { AvatarDropdown } from './AvatarDropdown';
|
||||||
|
import { NotificationBell } from './notification-bell';
|
||||||
|
|
||||||
export const Controls = (themeToggleProps?: ThemeToggleProps) => {
|
export const Controls = (themeToggleProps?: ThemeToggleProps) => {
|
||||||
return (
|
return (
|
||||||
@@ -16,6 +17,7 @@ export const Controls = (themeToggleProps?: ThemeToggleProps) => {
|
|||||||
...themeToggleProps?.buttonProps,
|
...themeToggleProps?.buttonProps,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<NotificationBell />
|
||||||
<AvatarDropdown />
|
<AvatarDropdown />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Bell } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useConvexAuth, useMutation, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@spoon/ui';
|
||||||
|
|
||||||
|
const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||||
|
['year', 365 * 24 * 60 * 60 * 1000],
|
||||||
|
['month', 30 * 24 * 60 * 60 * 1000],
|
||||||
|
['day', 24 * 60 * 60 * 1000],
|
||||||
|
['hour', 60 * 60 * 1000],
|
||||||
|
['minute', 60 * 1000],
|
||||||
|
];
|
||||||
|
|
||||||
|
const relativeFormatter = new Intl.RelativeTimeFormat(undefined, {
|
||||||
|
numeric: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatRelativeTime = (timestamp: number): string => {
|
||||||
|
const delta = timestamp - Date.now();
|
||||||
|
const magnitude = Math.abs(delta);
|
||||||
|
for (const [unit, ms] of RELATIVE_UNITS) {
|
||||||
|
if (magnitude >= ms) {
|
||||||
|
return relativeFormatter.format(Math.round(delta / ms), unit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'just now';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NotificationBell = () => {
|
||||||
|
const { isAuthenticated } = useConvexAuth();
|
||||||
|
const unreadCount = useQuery(
|
||||||
|
api.notifications.unreadCount,
|
||||||
|
isAuthenticated ? {} : 'skip',
|
||||||
|
);
|
||||||
|
const notifications = useQuery(
|
||||||
|
api.notifications.listMine,
|
||||||
|
isAuthenticated ? { limit: 10 } : 'skip',
|
||||||
|
);
|
||||||
|
const markRead = useMutation(api.notifications.markRead);
|
||||||
|
const markAllRead = useMutation(api.notifications.markAllRead);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = unreadCount ?? 0;
|
||||||
|
const badgeLabel = count > 9 ? '9+' : String(count);
|
||||||
|
const items = notifications ?? [];
|
||||||
|
|
||||||
|
// Fire-and-forget: never block navigation on the read write, but surface a
|
||||||
|
// toast if it fails so we don't silently assume success.
|
||||||
|
const handleMarkRead = async (notificationId: Id<'notifications'>) => {
|
||||||
|
try {
|
||||||
|
await markRead({ notificationId });
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not mark the notification as read.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAllRead = async () => {
|
||||||
|
try {
|
||||||
|
await markAllRead({});
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not mark notifications as read.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
className='relative'
|
||||||
|
aria-label='Notifications'
|
||||||
|
>
|
||||||
|
<Bell className='h-5 w-5' />
|
||||||
|
{count > 0 && (
|
||||||
|
<span className='bg-primary text-primary-foreground absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] leading-none font-semibold'>
|
||||||
|
{badgeLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align='end' className='w-80'>
|
||||||
|
<div className='flex items-center justify-between gap-2 px-2 py-1.5'>
|
||||||
|
<DropdownMenuLabel className='p-0'>Notifications</DropdownMenuLabel>
|
||||||
|
{count > 0 && (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => void handleMarkAllRead()}
|
||||||
|
className='text-muted-foreground hover:text-foreground cursor-pointer text-xs'
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className='text-muted-foreground px-2 py-6 text-center text-sm'>
|
||||||
|
No notifications yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
items.map((notification) => (
|
||||||
|
<DropdownMenuItem key={notification._id} asChild>
|
||||||
|
<Link
|
||||||
|
href={notification.link ?? '#'}
|
||||||
|
onClick={() => void handleMarkRead(notification._id)}
|
||||||
|
className='flex w-full cursor-pointer flex-col items-start gap-0.5'
|
||||||
|
>
|
||||||
|
<span className='flex w-full items-center gap-2'>
|
||||||
|
<span className='flex-1 truncate text-sm font-medium'>
|
||||||
|
{notification.title}
|
||||||
|
</span>
|
||||||
|
{notification.readAt === undefined && (
|
||||||
|
<span
|
||||||
|
className='bg-primary size-2 shrink-0 rounded-full'
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className='text-muted-foreground line-clamp-2 text-xs'>
|
||||||
|
{notification.body}
|
||||||
|
</span>
|
||||||
|
<span className='text-muted-foreground text-[11px]'>
|
||||||
|
{formatRelativeTime(notification.createdAt)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { ComponentProps, ReactNode } from 'react';
|
||||||
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { NotificationBell } from '../../src/components/layout/header/controls/notification-bell';
|
||||||
|
|
||||||
|
// Stable sentinels: the real Convex `api` proxy hands back a fresh reference on
|
||||||
|
// every access, so `===` comparisons in the mocks below would never match.
|
||||||
|
vi.mock('@spoon/backend/convex/_generated/api.js', () => ({
|
||||||
|
api: {
|
||||||
|
notifications: {
|
||||||
|
unreadCount: 'notifications:unreadCount',
|
||||||
|
listMine: 'notifications:listMine',
|
||||||
|
markRead: 'notifications:markRead',
|
||||||
|
markAllRead: 'notifications:markAllRead',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockMarkAllRead, mockMarkRead, mockUseConvexAuth, mockUseQuery } =
|
||||||
|
vi.hoisted(() => ({
|
||||||
|
mockMarkAllRead: vi.fn(),
|
||||||
|
mockMarkRead: vi.fn(),
|
||||||
|
mockUseConvexAuth: vi.fn(),
|
||||||
|
mockUseQuery: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('convex/react', () => ({
|
||||||
|
useConvexAuth: mockUseConvexAuth,
|
||||||
|
useMutation: (reference: unknown) =>
|
||||||
|
reference === api.notifications.markAllRead ? mockMarkAllRead : mockMarkRead,
|
||||||
|
useQuery: mockUseQuery,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: { error: vi.fn(), success: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Render the dropdown primitives as pass-through wrappers so the menu content
|
||||||
|
// is deterministically present under jsdom (Radix pointer behavior is flaky
|
||||||
|
// here). We are exercising NotificationBell's logic, not Radix.
|
||||||
|
vi.mock('@spoon/ui', () => ({
|
||||||
|
Button: (props: ComponentProps<'button'>) => <button {...props} />,
|
||||||
|
DropdownMenu: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||||
|
DropdownMenuTrigger: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||||
|
DropdownMenuContent: ({ children }: { children?: ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
DropdownMenuItem: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||||
|
DropdownMenuLabel: ({ children }: { children?: ReactNode }) => (
|
||||||
|
<div>{children}</div>
|
||||||
|
),
|
||||||
|
DropdownMenuSeparator: () => <hr />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const notifications = [
|
||||||
|
{
|
||||||
|
_id: 'notif-1',
|
||||||
|
_creationTime: 2,
|
||||||
|
userId: 'user-1',
|
||||||
|
kind: 'agent_turn_finished',
|
||||||
|
title: 'Agent finished its turn',
|
||||||
|
body: 'useSend is ready for review.',
|
||||||
|
link: '/threads/thread-1',
|
||||||
|
createdAt: Date.now() - 60_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: 'notif-2',
|
||||||
|
_creationTime: 1,
|
||||||
|
userId: 'user-1',
|
||||||
|
kind: 'sync_failed',
|
||||||
|
title: 'Sync failed',
|
||||||
|
body: 'Could not sync useSend with upstream.',
|
||||||
|
link: '/spoons/spoon-1',
|
||||||
|
createdAt: Date.now() - 3_600_000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const seedQueries = (unreadCount: number) => {
|
||||||
|
mockUseConvexAuth.mockReturnValue({ isAuthenticated: true, isLoading: false });
|
||||||
|
mockUseQuery.mockImplementation((reference: unknown) => {
|
||||||
|
if (reference === api.notifications.unreadCount) return unreadCount;
|
||||||
|
if (reference === api.notifications.listMine) return notifications;
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NotificationBell', () => {
|
||||||
|
it('renders the unread badge with the count', () => {
|
||||||
|
seedQueries(3);
|
||||||
|
render(<NotificationBell />);
|
||||||
|
expect(screen.getByText('3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the notification titles when opened', () => {
|
||||||
|
seedQueries(3);
|
||||||
|
render(<NotificationBell />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
|
||||||
|
expect(screen.getByText('Agent finished its turn')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sync failed')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a notification read when it is clicked', () => {
|
||||||
|
seedQueries(3);
|
||||||
|
render(<NotificationBell />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
|
||||||
|
fireEvent.click(screen.getByText('Agent finished its turn'));
|
||||||
|
expect(mockMarkRead).toHaveBeenCalledWith({ notificationId: 'notif-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks all read when the action is clicked', () => {
|
||||||
|
seedQueries(3);
|
||||||
|
render(<NotificationBell />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /mark all read/i }));
|
||||||
|
expect(mockMarkAllRead).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the badge when there are no unread notifications', () => {
|
||||||
|
seedQueries(0);
|
||||||
|
render(<NotificationBell />);
|
||||||
|
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user