"use client"; import { useQuery } from "@tanstack/react-query"; import { useAtom } from "jotai"; import { AlertCircle, AlertTriangle, BellDot, Check, CheckCheck, CheckCircle2, ChevronLeft, ChevronRight, History, Inbox, LayoutGrid, ListFilter, MessageSquare, Search, X, } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHandle, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { isCommentReplyMetadata, isConnectorIndexingMetadata, isNewMentionMetadata, isPageLimitExceededMetadata, } from "@/contracts/types/inbox.types"; import type { InboxItem } from "@/hooks/use-inbox"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useMediaQuery } from "@/hooks/use-media-query"; import { notificationsApiService } from "@/lib/apis/notifications-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cn } from "@/lib/utils"; import { useSidebarContextSafe } from "../../hooks"; // Sidebar width constants const SIDEBAR_COLLAPSED_WIDTH = 60; const SIDEBAR_EXPANDED_WIDTH = 240; /** * Get initials from name or email for avatar fallback */ function getInitials(name: string | null | undefined, email: string | null | undefined): string { if (name) { return name .split(" ") .map((n) => n[0]) .join("") .toUpperCase() .slice(0, 2); } if (email) { const localPart = email.split("@")[0]; return localPart.slice(0, 2).toUpperCase(); } return "U"; } /** * Format count for display: shows numbers up to 999, then "1k+", "2k+", etc. */ function formatInboxCount(count: number): string { if (count <= 999) { return count.toString(); } const thousands = Math.floor(count / 1000); return `${thousands}k+`; } /** * Get display name for connector type */ function getConnectorTypeDisplayName(connectorType: string): string { const displayNames: Record = { GITHUB_CONNECTOR: "GitHub", GOOGLE_CALENDAR_CONNECTOR: "Google Calendar", GOOGLE_GMAIL_CONNECTOR: "Gmail", GOOGLE_DRIVE_CONNECTOR: "Google Drive", COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Composio Google Drive", COMPOSIO_GMAIL_CONNECTOR: "Composio Gmail", COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Composio Google Calendar", LINEAR_CONNECTOR: "Linear", NOTION_CONNECTOR: "Notion", SLACK_CONNECTOR: "Slack", TEAMS_CONNECTOR: "Microsoft Teams", DISCORD_CONNECTOR: "Discord", JIRA_CONNECTOR: "Jira", CONFLUENCE_CONNECTOR: "Confluence", BOOKSTACK_CONNECTOR: "BookStack", CLICKUP_CONNECTOR: "ClickUp", AIRTABLE_CONNECTOR: "Airtable", LUMA_CONNECTOR: "Luma", ELASTICSEARCH_CONNECTOR: "Elasticsearch", WEBCRAWLER_CONNECTOR: "Web Crawler", YOUTUBE_CONNECTOR: "YouTube", CIRCLEBACK_CONNECTOR: "Circleback", MCP_CONNECTOR: "MCP", OBSIDIAN_CONNECTOR: "Obsidian", TAVILY_API: "Tavily", SEARXNG_API: "SearXNG", LINKUP_API: "Linkup", BAIDU_SEARCH_API: "Baidu", }; return ( displayNames[connectorType] || connectorType .replace(/_/g, " ") .replace(/CONNECTOR|API/gi, "") .trim() ); } type InboxTab = "comments" | "status"; type InboxFilter = "all" | "unread"; // Tab-specific data source with independent pagination interface TabDataSource { items: InboxItem[]; unreadCount: number; loading: boolean; loadingMore?: boolean; hasMore?: boolean; loadMore?: () => void; } interface InboxSidebarProps { open: boolean; onOpenChange: (open: boolean) => void; /** Mentions tab data source with independent pagination */ mentions: TabDataSource; /** Status tab data source with independent pagination */ status: TabDataSource; /** Combined unread count for mark all as read */ totalUnreadCount: number; markAsRead: (id: number) => Promise; markAllAsRead: () => Promise; onCloseMobileSidebar?: () => void; /** Whether the inbox is docked (permanent) or floating */ isDocked?: boolean; /** Callback to toggle docked state */ onDockedChange?: (docked: boolean) => void; } export function InboxSidebar({ open, onOpenChange, mentions, status, totalUnreadCount, markAsRead, markAllAsRead, onCloseMobileSidebar, isDocked = false, onDockedChange, }: InboxSidebarProps) { const t = useTranslations("sidebar"); const router = useRouter(); const params = useParams(); const isMobile = !useMediaQuery("(min-width: 640px)"); const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null; // Comments collapsed state (desktop only, when docked) const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom); // Target comment for navigation - also ensures comments panel is visible const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); const [searchQuery, setSearchQuery] = useState(""); const debouncedSearch = useDebouncedValue(searchQuery, 300); const isSearchMode = !!debouncedSearch.trim(); const [activeTab, setActiveTab] = useState("comments"); const [activeFilter, setActiveFilter] = useState("all"); const [selectedConnector, setSelectedConnector] = useState(null); const [mounted, setMounted] = useState(false); // Dropdown state for filter menu (desktop only) const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null); // Scroll shadow state for connector list const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top"); const handleConnectorScroll = useCallback((e: React.UIEvent) => { const el = e.currentTarget; const atTop = el.scrollTop <= 2; const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); }, []); // Drawer state for filter menu (mobile only) const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [markingAsReadId, setMarkingAsReadId] = useState(null); // Prefetch trigger ref - placed on item near the end const prefetchTriggerRef = useRef(null); // Server-side search query (enabled only when user is typing a search) // Determines which notification types to search based on active tab const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined; const { data: searchResponse, isLoading: isSearchLoading } = useQuery({ queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab), queryFn: () => notificationsApiService.getNotifications({ queryParams: { search_space_id: searchSpaceId ?? undefined, type: searchTypeFilter, search: debouncedSearch.trim(), limit: 50, }, }), staleTime: 30 * 1000, // 30 seconds (search results don't need to be super fresh) enabled: isSearchMode && open, }); useEffect(() => { setMounted(true); }, []); useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape" && open) { onOpenChange(false); } }; document.addEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape); }, [open, onOpenChange]); // Only lock body scroll on mobile when inbox is open useEffect(() => { if (!open || !isMobile) return; // Store original overflow to restore on cleanup const originalOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = originalOverflow; }; }, [open, isMobile]); // Reset connector filter when switching away from status tab useEffect(() => { if (activeTab !== "status") { setSelectedConnector(null); } }, [activeTab]); // Each tab uses its own data source for independent pagination // Comments tab: uses mentions data source (fetches only mention/reply types from server) const commentsItems = mentions.items; // Status tab: filters status data source (fetches all types) to status-specific types const statusItems = useMemo( () => status.items.filter( (item) => item.type === "connector_indexing" || item.type === "document_processing" || item.type === "page_limit_exceeded" || item.type === "connector_deletion" ), [status.items] ); // Pagination switches based on active tab const loading = activeTab === "comments" ? mentions.loading : status.loading; const loadingMore = activeTab === "comments" ? (mentions.loadingMore ?? false) : (status.loadingMore ?? false); const hasMore = activeTab === "comments" ? (mentions.hasMore ?? false) : (status.hasMore ?? false); const loadMore = activeTab === "comments" ? mentions.loadMore : status.loadMore; // Get unique connector types from status items for filtering const uniqueConnectorTypes = useMemo(() => { const connectorTypes = new Set(); statusItems .filter((item) => item.type === "connector_indexing") .forEach((item) => { // Use type guard for safe metadata access if (isConnectorIndexingMetadata(item.metadata)) { connectorTypes.add(item.metadata.connector_type); } }); return Array.from(connectorTypes).map((type) => ({ type, displayName: getConnectorTypeDisplayName(type), })); }, [statusItems]); // Get items for current tab const displayItems = activeTab === "comments" ? commentsItems : statusItems; // Filter items based on filter type, connector filter, and search mode // When searching: use server-side API results (searches ALL notifications) // When not searching: use Electric real-time items (fast, local) const filteredItems = useMemo(() => { // In search mode, use API results let items: InboxItem[] = isSearchMode ? (searchResponse?.items ?? []) : displayItems; // For status tab search results, filter to status-specific types if (isSearchMode && activeTab === "status") { items = items.filter( (item) => item.type === "connector_indexing" || item.type === "document_processing" || item.type === "page_limit_exceeded" || item.type === "connector_deletion" ); } // Apply read/unread filter if (activeFilter === "unread") { items = items.filter((item) => !item.read); } // Apply connector filter (only for status tab) if (activeTab === "status" && selectedConnector) { items = items.filter((item) => { if (item.type === "connector_indexing") { // Use type guard for safe metadata access if (isConnectorIndexingMetadata(item.metadata)) { return item.metadata.connector_type === selectedConnector; } return false; } return false; // Hide document_processing when a specific connector is selected }); } return items; }, [displayItems, searchResponse, isSearchMode, activeFilter, activeTab, selectedConnector]); // Intersection Observer for infinite scroll with prefetching // Re-runs when active tab changes so each tab gets its own pagination // Disabled during server-side search (search results are not paginated via infinite scroll) useEffect(() => { if (!loadMore || !hasMore || loadingMore || !open || isSearchMode) return; const observer = new IntersectionObserver( (entries) => { // When trigger element is visible, load more if (entries[0]?.isIntersecting) { loadMore(); } }, { root: null, // viewport rootMargin: "100px", // Start loading 100px before visible threshold: 0, } ); if (prefetchTriggerRef.current) { observer.observe(prefetchTriggerRef.current); } return () => observer.disconnect(); }, [loadMore, hasMore, loadingMore, open, isSearchMode, activeTab]); // Unread counts from server-side accurate totals (passed via props) const unreadCommentsCount = mentions.unreadCount; const unreadStatusCount = status.unreadCount; const handleItemClick = useCallback( async (item: InboxItem) => { if (!item.read) { setMarkingAsReadId(item.id); await markAsRead(item.id); setMarkingAsReadId(null); } if (item.type === "new_mention") { if (isNewMentionMetadata(item.metadata)) { const searchSpaceId = item.search_space_id; const threadId = item.metadata.thread_id; const commentId = item.metadata.comment_id; if (searchSpaceId && threadId) { if (commentId) { setTargetCommentId(commentId); } const url = commentId ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}` : `/dashboard/${searchSpaceId}/new-chat/${threadId}`; onOpenChange(false); onCloseMobileSidebar?.(); router.push(url); } } } else if (item.type === "comment_reply") { if (isCommentReplyMetadata(item.metadata)) { const searchSpaceId = item.search_space_id; const threadId = item.metadata.thread_id; const replyId = item.metadata.reply_id; if (searchSpaceId && threadId) { if (replyId) { setTargetCommentId(replyId); } const url = replyId ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${replyId}` : `/dashboard/${searchSpaceId}/new-chat/${threadId}`; onOpenChange(false); onCloseMobileSidebar?.(); router.push(url); } } } else if (item.type === "page_limit_exceeded") { // Navigate to the upgrade/more-pages page if (isPageLimitExceededMetadata(item.metadata)) { const actionUrl = item.metadata.action_url; if (actionUrl) { onOpenChange(false); onCloseMobileSidebar?.(); router.push(actionUrl); } } } }, [markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId] ); const handleMarkAllAsRead = useCallback(async () => { await markAllAsRead(); }, [markAllAsRead]); const handleClearSearch = useCallback(() => { setSearchQuery(""); }, []); const formatTime = (dateString: 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`; if (diffHours < 24) return `${diffHours}h`; if (diffDays < 7) return `${diffDays}d`; return `${Math.floor(diffDays / 7)}w`; } catch { return "now"; } }; const getStatusIcon = (item: InboxItem) => { // For mentions and comment replies, show the author's avatar if (item.type === "new_mention" || item.type === "comment_reply") { const metadata = item.type === "new_mention" ? isNewMentionMetadata(item.metadata) ? item.metadata : null : isCommentReplyMetadata(item.metadata) ? item.metadata : null; if (metadata) { return ( {metadata.author_avatar_url && ( )} {getInitials(metadata.author_name, metadata.author_email)} ); } return ( {getInitials(null, null)} ); } // For page limit exceeded, show a warning icon with amber/orange color if (item.type === "page_limit_exceeded") { return (
); } // For status items (connector/document), show status icons // Safely access status from metadata const metadata = item.metadata as Record; const status = typeof metadata?.status === "string" ? metadata.status : undefined; switch (status) { case "in_progress": return (
); case "completed": return (
); case "failed": return (
); default: return (
); } }; const getEmptyStateMessage = () => { if (activeTab === "comments") { return { title: t("no_comments") || "No comments", hint: t("no_comments_hint") || "You'll see mentions and replies here", }; } return { title: t("no_status_updates") || "No status updates", hint: t("no_status_updates_hint") || "Document and connector updates will appear here", }; }; // Get sidebar collapsed state from context (provided by LayoutShell) const sidebarContext = useSidebarContextSafe(); const isCollapsed = sidebarContext?.isCollapsed ?? false; // Calculate the left position for the inbox panel (relative to sidebar) const sidebarWidth = isCollapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH; if (!mounted) return null; // Shared content component for both docked and floating modes const inboxContent = ( <>
{/* Back button - mobile only */} {isMobile && ( )}

{t("inbox") || "Inbox"}

{/* Mobile: Button that opens bottom drawer */} {isMobile ? ( <> {t("filter") || "Filter"}
{/* Filter section */}

{t("filter") || "Filter"}

{/* Connectors section - only for status tab */} {activeTab === "status" && uniqueConnectorTypes.length > 0 && (

{t("connectors") || "Connectors"}

{uniqueConnectorTypes.map((connector) => ( ))}
)}
) : ( /* Desktop: Dropdown menu */ setOpenDropdown(isOpen ? "filter" : null)} > {t("filter") || "Filter"} {t("filter") || "Filter"} setActiveFilter("all")} className="flex items-center justify-between" > {t("all") || "All"} {activeFilter === "all" && } setActiveFilter("unread")} className="flex items-center justify-between" > {t("unread") || "Unread"} {activeFilter === "unread" && } {activeTab === "status" && uniqueConnectorTypes.length > 0 && ( <> {t("connectors") || "Connectors"}
setSelectedConnector(null)} className="flex items-center justify-between" > {t("all_connectors") || "All connectors"} {selectedConnector === null && } {uniqueConnectorTypes.map((connector) => ( setSelectedConnector(connector.type)} className="flex items-center justify-between" > {getConnectorIcon(connector.type, "h-4 w-4")} {connector.displayName} {selectedConnector === connector.type && } ))}
)}
)} {isMobile ? ( ) : ( {t("mark_all_read") || "Mark all as read"} )} {/* Dock/Undock button - desktop only */} {!isMobile && onDockedChange && ( {isDocked ? "Collapse panel" : "Expand panel"} )}
setSearchQuery(e.target.value)} className="pl-9 pr-8 h-9" /> {searchQuery && ( )}
setActiveTab(value as InboxTab)} className="shrink-0 mx-4" > {t("comments") || "Comments"} {formatInboxCount(unreadCommentsCount)} {t("status") || "Status"} {formatInboxCount(unreadStatusCount)}
{(isSearchMode ? isSearchLoading : loading) ? (
{activeTab === "comments" ? /* Comments skeleton: avatar + two-line text + time */ [85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
)) : /* Status skeleton: status icon circle + two-line text + time */ [75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
))}
) : filteredItems.length > 0 ? (
{filteredItems.map((item, index) => { const isMarkingAsRead = markingAsReadId === item.id; // Place prefetch trigger on 5th item from end (only when not searching) const isPrefetchTrigger = !isSearchMode && hasMore && index === filteredItems.length - 5; return (
{isMobile ? ( ) : (

{item.title}

{convertRenderedToDisplay(item.message)}

)} {/* Time and unread dot - fixed width to prevent content shift */}
{formatTime(item.created_at)} {!item.read && }
); })} {/* Fallback trigger at the very end if less than 5 items and not searching */} {!isSearchMode && filteredItems.length < 5 && hasMore && (
)} {/* Loading more skeletons at the bottom during infinite scroll */} {loadingMore && (activeTab === "comments" ? [80, 60, 90].map((titleWidth, i) => (
)) : [70, 85, 55].map((titleWidth, i) => (
)))}
) : isSearchMode ? (

{t("no_results_found") || "No results found"}

{t("try_different_search") || "Try a different search term"}

) : (
{activeTab === "comments" ? ( ) : ( )}

{getEmptyStateMessage().title}

{getEmptyStateMessage().hint}

)}
); // DOCKED MODE: Render as a static flex child (no animation, no click-away) if (isDocked && open && !isMobile) { return ( ); } // FLOATING MODE: Render with animation and click-away layer return ( {open && ( <> {/* Click-away layer - only covers the content area, not the sidebar */} onOpenChange(false)} aria-hidden="true" /> {/* Clip container - positioned at sidebar edge with overflow hidden */}
{inboxContent}
)}
); }