"use client"; import { useAtom } from "jotai"; import { AlertCircle, AlertTriangle, AtSign, BellDot, Check, CheckCheck, CheckCircle2, ChevronLeft, ChevronRight, History, Inbox, LayoutGrid, ListFilter, Search, X, } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { 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 { 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 { isConnectorIndexingMetadata, isNewMentionMetadata, isPageLimitExceededMetadata, } from "@/contracts/types/inbox.types"; import type { InboxItem } from "@/hooks/use-inbox"; import { useMediaQuery } from "@/hooks/use-media-query"; 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 = "mentions" | "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 isMobile = !useMediaQuery("(min-width: 640px)"); // 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 [activeTab, setActiveTab] = useState("mentions"); 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); // 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); 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]); // Get current tab's data source - each tab has independent data and pagination const currentDataSource = activeTab === "mentions" ? mentions : status; const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource; // Status tab includes: connector indexing, document processing, page limit exceeded // Filter to only show status notification types const statusItems = useMemo( () => status.items.filter( (item) => item.type === "connector_indexing" || item.type === "document_processing" || item.type === "page_limit_exceeded" ), [status.items] ); // 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 - mentions use their source directly, status uses filtered items const displayItems = activeTab === "mentions" ? mentions.items : statusItems; // Filter items based on filter type, connector filter, and search query const filteredItems = useMemo(() => { let items = displayItems; // 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 }); } // Apply search query if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); items = items.filter( (item) => item.title.toLowerCase().includes(query) || item.message.toLowerCase().includes(query) ); } return items; }, [displayItems, activeFilter, activeTab, selectedConnector, searchQuery]); // Intersection Observer for infinite scroll with prefetching // Only active when not searching (search results are client-side filtered) useEffect(() => { if (!loadMore || !hasMore || loadingMore || !open || searchQuery.trim()) 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, searchQuery]); // Use unread counts from data sources (more accurate than client-side counting) const unreadMentionsCount = 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") { // Use type guard for safe metadata access 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) { // Pre-set target comment ID before navigation // This also ensures comments panel is not collapsed 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 === "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, show the author's avatar with initials fallback if (item.type === "new_mention") { // Use type guard for safe metadata access if (isNewMentionMetadata(item.metadata)) { const authorName = item.metadata.author_name; const avatarUrl = item.metadata.author_avatar_url; const authorEmail = item.metadata.author_email; return ( {avatarUrl && } {getInitials(authorName, authorEmail)} ); } // Fallback for invalid metadata 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 === "mentions") { return { title: t("no_mentions") || "No mentions", hint: t("no_mentions_hint") || "You'll see mentions from others 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 = ( <>

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

{/* Mobile: Button that opens bottom drawer */} {isMobile ? ( <> {t("filter") || "Filter"} {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 && } ))} )} )} {t("mark_all_read") || "Mark all as read"} {/* Close button - mobile only */} {isMobile && ( {t("close") || "Close"} )} {/* 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("mentions") || "Mentions"} {formatInboxCount(unreadMentionsCount)} {t("status") || "Status"} {formatInboxCount(unreadStatusCount)}
{loading ? (
) : filteredItems.length > 0 ? (
{filteredItems.map((item, index) => { const isMarkingAsRead = markingAsReadId === item.id; // Place prefetch trigger on 5th item from end (only if not searching) const isPrefetchTrigger = !searchQuery && hasMore && index === filteredItems.length - 5; return (

{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 */} {!searchQuery && filteredItems.length < 5 && hasMore && (
)}
) : searchQuery ? (

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

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

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

{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}
)}
); }