-
Notifications
You must be signed in to change notification settings - Fork 77
feat(notifications): implement /me/notifications with global unread sync #401
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,152 +1,5 @@ | ||
| 'use client'; | ||
| import { redirect } from 'next/navigation'; | ||
|
|
||
| import { useState } from 'react'; | ||
| import { useNotifications } from '@/hooks/useNotifications'; | ||
| import { useNotificationPolling } from '@/hooks/use-notification-polling'; | ||
| import { NotificationList } from '@/components/notifications/NotificationList'; | ||
| import { Button } from '@/components/ui/button'; | ||
| import { Skeleton } from '@/components/ui/skeleton'; | ||
| import { toast } from 'sonner'; | ||
| import { ChevronLeft, ChevronRight } from 'lucide-react'; | ||
| import { AuthGuard } from '@/components/auth'; | ||
| import Loading from '@/components/Loading'; | ||
|
|
||
| export default function NotificationsPage() { | ||
| const [page, setPage] = useState(1); | ||
| const limit = 20; | ||
|
|
||
| const notificationsHook = useNotifications({ | ||
| page, | ||
| limit, | ||
| autoFetch: true, | ||
| }); | ||
|
|
||
| const { | ||
| notifications, | ||
| loading, | ||
| error, | ||
| total, | ||
| unreadCount, | ||
| markAllAsRead, | ||
| markNotificationAsRead, | ||
| setCurrentPage, | ||
| } = notificationsHook; | ||
|
|
||
| // Enable polling for real-time updates | ||
| useNotificationPolling(notificationsHook, { | ||
| interval: 30000, | ||
| enabled: true, | ||
| }); | ||
|
|
||
| const totalPages = Math.ceil(total / limit); | ||
|
|
||
| const handleMarkAllAsRead = async () => { | ||
| try { | ||
| await markAllAsRead(); | ||
| toast.success('All notifications marked as read'); | ||
| } catch { | ||
| toast.error('Failed to mark all as read'); | ||
| } | ||
| }; | ||
|
|
||
| const handlePageChange = (newPage: number) => { | ||
| setCurrentPage(newPage); | ||
| setPage(newPage); | ||
| window.scrollTo({ top: 0, behavior: 'smooth' }); | ||
| }; | ||
|
|
||
| if (error) { | ||
| return ( | ||
| <div className='container mx-auto max-w-4xl p-6'> | ||
| <div className='rounded-lg border border-red-800/50 bg-red-950/20 p-8 text-center'> | ||
| <p className='text-lg font-semibold text-red-400'> | ||
| Error loading notifications | ||
| </p> | ||
| <p className='mt-2 text-sm text-zinc-400'>{error.message}</p> | ||
| <Button | ||
| onClick={() => notificationsHook.refetch()} | ||
| className='mt-4' | ||
| variant='outline' | ||
| > | ||
| Try Again | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <AuthGuard redirectTo='/auth?mode=signin' fallback={<Loading />}> | ||
| <div className='container mx-auto max-w-4xl p-6'> | ||
| <div className='mb-6 flex items-center justify-between'> | ||
| <div> | ||
| <h1 className='text-3xl font-bold text-white'>Notifications</h1> | ||
| <div className='mt-1 text-sm text-zinc-400'> | ||
| {loading ? ( | ||
| <Skeleton className='h-4 w-48' /> | ||
| ) : ( | ||
| <> | ||
| {unreadCount} unread of {total} total | ||
| </> | ||
| )} | ||
| </div> | ||
| </div> | ||
| {unreadCount > 0 && !loading && ( | ||
| <Button | ||
| onClick={handleMarkAllAsRead} | ||
| variant='outline' | ||
| className='border-primary/30 text-primary hover:bg-primary/10' | ||
| > | ||
| Mark all as read | ||
| </Button> | ||
| )} | ||
| </div> | ||
|
|
||
| <NotificationList | ||
| notifications={notifications} | ||
| loading={loading} | ||
| onNotificationClick={notification => { | ||
| if (!notification.read) { | ||
| markNotificationAsRead([notification.id]).catch(() => { | ||
| // Silently handle error - user feedback already provided | ||
| }); | ||
| } | ||
| }} | ||
| onMarkAsRead={id => { | ||
| markNotificationAsRead([id]).catch(() => { | ||
| // Silently handle error - user feedback already provided | ||
| }); | ||
| }} | ||
| /> | ||
|
|
||
| {totalPages > 1 && !loading && ( | ||
| <div className='mt-8 flex items-center justify-center gap-4'> | ||
| <Button | ||
| onClick={() => handlePageChange(page - 1)} | ||
| disabled={page === 1} | ||
| variant='outline' | ||
| size='sm' | ||
| className='border-zinc-800/50' | ||
| > | ||
| <ChevronLeft className='mr-1 h-4 w-4' /> | ||
| Previous | ||
| </Button> | ||
| <span className='text-sm text-zinc-400'> | ||
| Page {page} of {totalPages} | ||
| </span> | ||
| <Button | ||
| onClick={() => handlePageChange(page + 1)} | ||
| disabled={page === totalPages} | ||
| variant='outline' | ||
| size='sm' | ||
| className='border-zinc-800/50' | ||
| > | ||
| Next | ||
| <ChevronRight className='ml-1 h-4 w-4' /> | ||
| </Button> | ||
| </div> | ||
| )} | ||
| </div> | ||
| </AuthGuard> | ||
| ); | ||
| export default function LegacyNotificationsPage() { | ||
| redirect('/me/notifications'); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,179 @@ | ||||||||||||||
| 'use client'; | ||||||||||||||
|
|
||||||||||||||
| import { useMemo, useState } from 'react'; | ||||||||||||||
| import { AnimatePresence, motion } from 'framer-motion'; | ||||||||||||||
| import { toast } from 'sonner'; | ||||||||||||||
| import { NotificationList } from '@/components/notifications/NotificationList'; | ||||||||||||||
| import { Button } from '@/components/ui/button'; | ||||||||||||||
| import { Skeleton } from '@/components/ui/skeleton'; | ||||||||||||||
| import BoundlessSheet from '@/components/sheet/boundless-sheet'; | ||||||||||||||
| import { useNotificationCenter } from '@/components/providers/notification-provider'; | ||||||||||||||
| import type { Notification } from '@/types/notifications'; | ||||||||||||||
|
|
||||||||||||||
| const formatMetadataValue = (value: unknown): string => { | ||||||||||||||
| if (value === null || value === undefined) { | ||||||||||||||
| return ''; | ||||||||||||||
| } | ||||||||||||||
| if (typeof value === 'string' || typeof value === 'number') { | ||||||||||||||
| return String(value); | ||||||||||||||
| } | ||||||||||||||
| if (typeof value === 'boolean') { | ||||||||||||||
| return value ? 'Yes' : 'No'; | ||||||||||||||
| } | ||||||||||||||
| try { | ||||||||||||||
| return JSON.stringify(value); | ||||||||||||||
| } catch { | ||||||||||||||
| return String(value); | ||||||||||||||
| } | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| export default function MeNotificationsPage() { | ||||||||||||||
| const { | ||||||||||||||
| notifications, | ||||||||||||||
| unreadCount, | ||||||||||||||
| loading, | ||||||||||||||
| error, | ||||||||||||||
| markAllAsRead, | ||||||||||||||
| markNotificationAsRead, | ||||||||||||||
| refetch, | ||||||||||||||
| } = useNotificationCenter(); | ||||||||||||||
|
|
||||||||||||||
| const [selectedNotification, setSelectedNotification] = | ||||||||||||||
| useState<Notification | null>(null); | ||||||||||||||
| const [sheetOpen, setSheetOpen] = useState(false); | ||||||||||||||
|
|
||||||||||||||
| const metadataEntries = useMemo(() => { | ||||||||||||||
| if (!selectedNotification) { | ||||||||||||||
| return []; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return Object.entries(selectedNotification.data).filter( | ||||||||||||||
| ([, value]) => value !== undefined && value !== null && value !== '' | ||||||||||||||
| ); | ||||||||||||||
| }, [selectedNotification]); | ||||||||||||||
|
|
||||||||||||||
| const handleMarkAllAsRead = async () => { | ||||||||||||||
| try { | ||||||||||||||
| await markAllAsRead(); | ||||||||||||||
| toast.success('All notifications marked as read'); | ||||||||||||||
| } catch { | ||||||||||||||
| toast.error('Failed to mark all as read'); | ||||||||||||||
| } | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| const handleOpenDetails = (notification: Notification) => { | ||||||||||||||
| setSelectedNotification(notification); | ||||||||||||||
| setSheetOpen(true); | ||||||||||||||
|
|
||||||||||||||
| if (!notification.read) { | ||||||||||||||
| markNotificationAsRead([notification.id]).catch(() => { | ||||||||||||||
| toast.error('Failed to update notification state'); | ||||||||||||||
| }); | ||||||||||||||
| } | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| if (error) { | ||||||||||||||
| return ( | ||||||||||||||
| <div className='mx-auto w-full max-w-5xl p-6'> | ||||||||||||||
| <div className='rounded-lg border border-red-800/50 bg-red-950/20 p-8 text-center'> | ||||||||||||||
| <p className='text-lg font-semibold text-red-400'> | ||||||||||||||
| Error loading notifications | ||||||||||||||
| </p> | ||||||||||||||
| <p className='mt-2 text-sm text-zinc-400'>{error.message}</p> | ||||||||||||||
| <Button onClick={() => refetch()} className='mt-4' variant='outline'> | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🔧 Suggested change- <Button onClick={() => refetch()} className='mt-4' variant='outline'>
+ <Button
+ onClick={() => refetch().catch(() => toast.error('Retry failed'))}
+ className='mt-4'
+ variant='outline'
+ >📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| Try again | ||||||||||||||
| </Button> | ||||||||||||||
| </div> | ||||||||||||||
| </div> | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return ( | ||||||||||||||
| <> | ||||||||||||||
| <div className='mx-auto w-full max-w-5xl p-6'> | ||||||||||||||
| <div className='mb-6 flex items-start justify-between gap-4'> | ||||||||||||||
| <div> | ||||||||||||||
| <h1 className='text-3xl font-bold text-white'>Notifications</h1> | ||||||||||||||
| <div className='mt-1 text-sm text-zinc-400'> | ||||||||||||||
| {loading ? ( | ||||||||||||||
| <Skeleton className='h-4 w-48' /> | ||||||||||||||
| ) : ( | ||||||||||||||
| <> | ||||||||||||||
| {unreadCount} unread of {notifications.length} loaded | ||||||||||||||
| </> | ||||||||||||||
| )} | ||||||||||||||
| </div> | ||||||||||||||
| </div> | ||||||||||||||
|
|
||||||||||||||
| <AnimatePresence initial={false}> | ||||||||||||||
| {unreadCount > 0 && !loading && ( | ||||||||||||||
| <motion.div | ||||||||||||||
| key='mark-all-button' | ||||||||||||||
| initial={{ opacity: 0, y: -8 }} | ||||||||||||||
| animate={{ opacity: 1, y: 0 }} | ||||||||||||||
| exit={{ opacity: 0, y: -8 }} | ||||||||||||||
| transition={{ duration: 0.2 }} | ||||||||||||||
| > | ||||||||||||||
| <Button | ||||||||||||||
| onClick={handleMarkAllAsRead} | ||||||||||||||
| variant='outline' | ||||||||||||||
| className='border-primary/30 text-primary hover:bg-primary/10' | ||||||||||||||
| > | ||||||||||||||
| Mark all as read | ||||||||||||||
| </Button> | ||||||||||||||
| </motion.div> | ||||||||||||||
| )} | ||||||||||||||
| </AnimatePresence> | ||||||||||||||
| </div> | ||||||||||||||
|
|
||||||||||||||
| <NotificationList | ||||||||||||||
| notifications={notifications} | ||||||||||||||
| loading={loading} | ||||||||||||||
| onNotificationClick={handleOpenDetails} | ||||||||||||||
| /> | ||||||||||||||
| </div> | ||||||||||||||
|
|
||||||||||||||
| <BoundlessSheet | ||||||||||||||
| open={sheetOpen} | ||||||||||||||
| setOpen={setSheetOpen} | ||||||||||||||
| title={selectedNotification?.title || 'Notification details'} | ||||||||||||||
| > | ||||||||||||||
| {selectedNotification ? ( | ||||||||||||||
| <div className='space-y-5 px-4 pb-8 md:px-8'> | ||||||||||||||
| <div className='space-y-2'> | ||||||||||||||
| <p className='text-sm leading-relaxed text-zinc-300'> | ||||||||||||||
| {selectedNotification.message} | ||||||||||||||
| </p> | ||||||||||||||
| <p className='text-xs text-zinc-500'> | ||||||||||||||
| {new Date(selectedNotification.createdAt).toLocaleString()} | ||||||||||||||
| </p> | ||||||||||||||
| <p className='text-xs font-medium text-zinc-400'> | ||||||||||||||
| Type: {selectedNotification.type} | ||||||||||||||
| </p> | ||||||||||||||
| </div> | ||||||||||||||
|
|
||||||||||||||
| <div className='space-y-2'> | ||||||||||||||
| <h2 className='text-sm font-semibold text-zinc-200'>Metadata</h2> | ||||||||||||||
| {metadataEntries.length === 0 ? ( | ||||||||||||||
| <p className='text-xs text-zinc-500'> | ||||||||||||||
| No additional metadata provided. | ||||||||||||||
| </p> | ||||||||||||||
| ) : ( | ||||||||||||||
| <dl className='space-y-2 rounded-lg border border-zinc-800/60 bg-zinc-900/40 p-3'> | ||||||||||||||
| {metadataEntries.map(([key, value]) => ( | ||||||||||||||
| <div key={key} className='grid grid-cols-3 gap-2 text-xs'> | ||||||||||||||
| <dt className='text-zinc-500'>{key}</dt> | ||||||||||||||
| <dd className='col-span-2 break-all text-zinc-300'> | ||||||||||||||
| {formatMetadataValue(value)} | ||||||||||||||
| </dd> | ||||||||||||||
| </div> | ||||||||||||||
| ))} | ||||||||||||||
| </dl> | ||||||||||||||
| )} | ||||||||||||||
| </div> | ||||||||||||||
| </div> | ||||||||||||||
| ) : null} | ||||||||||||||
| </BoundlessSheet> | ||||||||||||||
| </> | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
totalfrom the context is unused; subtitle shows a potentially misleading loaded-only count.useNotificationCenterreturnstotal(server-side total), but onlynotifications.length(at most 50, the limit set in the provider) is shown in the subtitle. When a user has more than 50 notifications the display will read "3 unread of 50 loaded" instead of the real total.🔧 Suggested change
const { notifications, unreadCount, loading, error, + total, markAllAsRead, markNotificationAsRead, refetch, } = useNotificationCenter();Also applies to: 97-104
🤖 Prompt for AI Agents