"use client"; import { useAtom } from "jotai"; import { Bell } from "lucide-react"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { isCommentReplyMetadata, isInsufficientCreditsMetadata, isNewMentionMetadata, } from "@/contracts/types/inbox.types"; import type { InboxItem } from "@/hooks/use-inbox"; import { cn } from "@/lib/utils"; export interface NotificationsDataSource { items: InboxItem[]; unreadCount: number; totalCount?: number; loading: boolean; loadingMore?: boolean; hasMore?: boolean; loadMore?: () => void; markAsRead: (id: number) => Promise; markAllAsRead: () => Promise; } export interface NotificationsDropdownData { totalUnreadCount: number; comments: NotificationsDataSource; status: NotificationsDataSource; } interface NotificationsDropdownProps { notifications: NotificationsDropdownData; onCloseMobileSidebar?: () => void; } type NotificationFilter = "all" | "mentions" | "unread"; function formatNotificationCount(count: number): string { if (count <= 999) { return count.toString(); } const thousands = Math.floor(count / 1000); return `${thousands}k+`; } function formatTime(dateString: string): string { try { const date = new Date(dateString); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / (1000 * 60)); const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffMins < 1) return "now"; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return `${Math.floor(diffDays / 7)}w ago`; } catch { return "now"; } } function isCommentNotification(item: InboxItem): boolean { return item.type === "new_mention" || item.type === "comment_reply"; } export function NotificationsDropdown({ notifications, onCloseMobileSidebar, }: NotificationsDropdownProps) { const router = useRouter(); const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); const [open, setOpen] = useState(false); const [activeFilter, setActiveFilter] = useState("all"); const [markingAsReadId, setMarkingAsReadId] = useState(null); const [markingAllAsRead, setMarkingAllAsRead] = useState(false); const scrollContainerRef = useRef(null); const loadMoreTriggerRef = useRef(null); const unreadLabel = formatNotificationCount(notifications.totalUnreadCount); const allCount = (notifications.comments.totalCount ?? 0) + (notifications.status.totalCount ?? 0); const mentionsCount = notifications.comments.totalCount ?? notifications.comments.items.length; const visibleUnreadCount = activeFilter === "mentions" ? notifications.comments.unreadCount : notifications.totalUnreadCount; const isLoading = activeFilter === "mentions" ? notifications.comments.loading : notifications.comments.loading || notifications.status.loading; const isLoadingMore = activeFilter === "mentions" ? !!notifications.comments.loadingMore : !!notifications.comments.loadingMore || !!notifications.status.loadingMore; const hasMore = activeFilter === "mentions" ? !!notifications.comments.hasMore : !!notifications.comments.hasMore || !!notifications.status.hasMore; const items = useMemo(() => { const sourceItems = activeFilter === "mentions" ? notifications.comments.items : [...notifications.comments.items, ...notifications.status.items]; return sourceItems .filter((item) => activeFilter !== "unread" || !item.read) .toSorted((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); }, [activeFilter, notifications.comments.items, notifications.status.items]); const loadMoreForActiveFilter = useCallback(() => { if (isLoadingMore) return; if (activeFilter === "mentions") { if (notifications.comments.hasMore) { notifications.comments.loadMore?.(); } return; } if (notifications.comments.hasMore) { notifications.comments.loadMore?.(); } if (notifications.status.hasMore) { notifications.status.loadMore?.(); } }, [activeFilter, isLoadingMore, notifications.comments, notifications.status]); useEffect(() => { if (!open || isLoading || isLoadingMore || !hasMore) return; const root = scrollContainerRef.current; const target = loadMoreTriggerRef.current; if (!root || !target) return; const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting) { loadMoreForActiveFilter(); } }, { root, rootMargin: "120px", threshold: 0, } ); observer.observe(target); return () => observer.disconnect(); }, [hasMore, isLoading, isLoadingMore, loadMoreForActiveFilter, open]); const markItemAsRead = useCallback( async (item: InboxItem) => { if (item.read) return; setMarkingAsReadId(item.id); try { await (isCommentNotification(item) ? notifications.comments.markAsRead(item.id) : notifications.status.markAsRead(item.id)); } finally { setMarkingAsReadId(null); } }, [notifications.comments, notifications.status] ); const handleItemClick = useCallback( async (item: InboxItem) => { await markItemAsRead(item); if (item.type === "new_mention" && isNewMentionMetadata(item.metadata)) { const threadId = item.metadata.thread_id; const commentId = item.metadata.comment_id; if (item.workspace_id && threadId) { if (commentId) setTargetCommentId(commentId); setOpen(false); onCloseMobileSidebar?.(); router.push( commentId ? `/dashboard/${item.workspace_id}/new-chat/${threadId}?commentId=${commentId}` : `/dashboard/${item.workspace_id}/new-chat/${threadId}` ); } return; } if (item.type === "comment_reply" && isCommentReplyMetadata(item.metadata)) { const threadId = item.metadata.thread_id; const replyId = item.metadata.reply_id; if (item.workspace_id && threadId) { if (replyId) setTargetCommentId(replyId); setOpen(false); onCloseMobileSidebar?.(); router.push( replyId ? `/dashboard/${item.workspace_id}/new-chat/${threadId}?commentId=${replyId}` : `/dashboard/${item.workspace_id}/new-chat/${threadId}` ); } return; } if (item.type === "insufficient_credits" && isInsufficientCreditsMetadata(item.metadata)) { if (item.metadata.action_url) { setOpen(false); onCloseMobileSidebar?.(); router.push(item.metadata.action_url); } } }, [markItemAsRead, onCloseMobileSidebar, router, setTargetCommentId] ); const handleMarkAllAsRead = useCallback(async () => { if (visibleUnreadCount === 0 || markingAllAsRead) return; setMarkingAllAsRead(true); try { if (activeFilter === "mentions") { await notifications.comments.markAllAsRead(); } else { await Promise.all([ notifications.comments.markAllAsRead(), notifications.status.markAllAsRead(), ]); } } finally { setMarkingAllAsRead(false); } }, [activeFilter, markingAllAsRead, notifications, visibleUnreadCount]); const emptyStateCopy = activeFilter === "mentions" ? { title: "No mentions", description: "Mentions and replies will appear here.", } : activeFilter === "unread" ? { title: "No unread notifications", description: "New mentions and status updates will appear here.", } : { title: "No notifications", description: "Mentions, replies, and status updates will appear here.", }; const tabs: { value: NotificationFilter; label: string; count: number }[] = [ { value: "all", label: "All", count: allCount }, { value: "mentions", label: "Mentions", count: mentionsCount }, { value: "unread", label: "Unread", count: notifications.totalUnreadCount }, ]; return ( Notifications

Notifications

{tabs.map((tab) => { const isActive = activeFilter === tab.value; return ( ); })}
{isLoading ? (
{[82, 64, 74].map((width) => (
))}
) : items.length > 0 ? (
{items.map((item) => { const isMarkingAsRead = markingAsReadId === item.id; return ( ); })} {hasMore ? (
{isLoadingMore ? : null}
) : null}
) : (

{emptyStateCopy.title}

{emptyStateCopy.description}

{hasMore ? ( ) : null}
)}
); }