"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 { 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 { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { isCommentReplyMetadata, isConnectorIndexingMetadata, isDocumentProcessingMetadata, isNewMentionMetadata, isPageLimitExceededMetadata, } from "@/contracts/types/inbox.types"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import type { InboxItem } from "@/hooks/use-inbox"; 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 { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; 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"; } function formatInboxCount(count: number): string { if (count <= 999) { return count.toString(); } const thousands = Math.floor(count / 1000); return `${thousands}k+`; } 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" | "errors"; const COMMENT_TYPES = new Set(["new_mention", "comment_reply"]); const STATUS_TYPES = new Set(["connector_indexing", "document_processing", "page_limit_exceeded", "connector_deletion"]); interface InboxSidebarProps { open: boolean; onOpenChange: (open: boolean) => void; items: InboxItem[]; totalUnreadCount: number; loading: boolean; loadingMore?: boolean; hasMore?: boolean; loadMore?: () => void; markAsRead: (id: number) => Promise; markAllAsRead: () => Promise; onCloseMobileSidebar?: () => void; isDocked?: boolean; onDockedChange?: (docked: boolean) => void; } export function InboxSidebar({ open, onOpenChange, items, totalUnreadCount, loading, loadingMore: loadingMoreProp = false, hasMore: hasMoreProp = false, loadMore, 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; const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom); 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 [selectedSource, setSelectedSource] = useState(null); const [mounted, setMounted] = useState(false); const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null); 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"); }, []); const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [markingAsReadId, setMarkingAsReadId] = useState(null); const prefetchTriggerRef = useRef(null); // Server-side search query 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, 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]); useEffect(() => { if (!open || !isMobile) return; const originalOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = originalOverflow; }; }, [open, isMobile]); useEffect(() => { if (activeTab !== "status") { setSelectedSource(null); } }, [activeTab]); // Split items by tab type (client-side from single data source) const commentsItems = useMemo( () => items.filter((item) => COMMENT_TYPES.has(item.type)), [items] ); const statusItems = useMemo( () => items.filter((item) => STATUS_TYPES.has(item.type)), [items] ); // Derive unread counts per tab from the items array const unreadCommentsCount = useMemo( () => commentsItems.filter((item) => !item.read).length, [commentsItems] ); const unreadStatusCount = useMemo( () => statusItems.filter((item) => !item.read).length, [statusItems] ); // Fetch source types for the status tab filter const { data: sourceTypesData } = useQuery({ queryKey: cacheKeys.notifications.sourceTypes(searchSpaceId), queryFn: () => notificationsApiService.getSourceTypes(searchSpaceId ?? undefined), staleTime: 60 * 1000, enabled: open && activeTab === "status", }); const statusSourceOptions = useMemo(() => { if (!sourceTypesData?.sources) return []; return sourceTypesData.sources.map((source) => ({ key: source.key, type: source.type, category: source.category, displayName: source.category === "connector" ? getConnectorTypeDisplayName(source.type) : getDocumentTypeLabel(source.type), })); }, [sourceTypesData]); // Client-side filter: source type const matchesSourceFilter = useCallback( (item: InboxItem): boolean => { if (!selectedSource) return true; if (selectedSource.startsWith("connector:")) { const connectorType = selectedSource.slice("connector:".length); return ( item.type === "connector_indexing" && isConnectorIndexingMetadata(item.metadata) && item.metadata.connector_type === connectorType ); } if (selectedSource.startsWith("doctype:")) { const docType = selectedSource.slice("doctype:".length); return ( item.type === "document_processing" && isDocumentProcessingMetadata(item.metadata) && item.metadata.document_type === docType ); } return true; }, [selectedSource] ); // Client-side filter: unread / errors const matchesActiveFilter = useCallback( (item: InboxItem): boolean => { if (activeFilter === "unread") return !item.read; if (activeFilter === "errors") { if (item.type === "page_limit_exceeded") return true; const meta = item.metadata as Record | undefined; return typeof meta?.status === "string" && meta.status === "failed"; } return true; }, [activeFilter] ); // Two data paths: search mode (API) or default (client-side filtered) const filteredItems = useMemo(() => { let tabItems: InboxItem[]; if (isSearchMode) { tabItems = searchResponse?.items ?? []; if (activeTab === "status") { tabItems = tabItems.filter((item) => STATUS_TYPES.has(item.type)); } else { tabItems = tabItems.filter((item) => COMMENT_TYPES.has(item.type)); } } else { tabItems = activeTab === "comments" ? commentsItems : statusItems; } // Apply filters let result = tabItems; if (activeFilter !== "all") { result = result.filter(matchesActiveFilter); } if (activeTab === "status" && selectedSource) { result = result.filter(matchesSourceFilter); } return result; }, [ isSearchMode, searchResponse, activeTab, commentsItems, statusItems, activeFilter, selectedSource, matchesActiveFilter, matchesSourceFilter, ]); // Infinite scroll useEffect(() => { if (!loadMore || !hasMoreProp || loadingMoreProp || !open || isSearchMode) return; const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting) { loadMore(); } }, { root: null, rootMargin: "100px", threshold: 0, } ); if (prefetchTriggerRef.current) { observer.observe(prefetchTriggerRef.current); } return () => observer.disconnect(); }, [loadMore, hasMoreProp, loadingMoreProp, open, isSearchMode]); 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") { 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) => { 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)} ); } if (item.type === "page_limit_exceeded") { return (
); } 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", }; }; if (!mounted) return null; const isLoading = isSearchMode ? isSearchLoading : loading; const inboxContent = ( <>
{isMobile && ( )}

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

{isMobile ? ( <> {t("filter") || "Filter"}

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

{activeTab === "status" && ( )}
{activeTab === "status" && statusSourceOptions.length > 0 && (

{t("sources") || "Sources"}

{statusSourceOptions.map((source) => ( ))}
)}
) : ( 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" && ( setActiveFilter("errors")} className="flex items-center justify-between" > {t("errors_only") || "Errors only"} {activeFilter === "errors" && } )} {activeTab === "status" && statusSourceOptions.length > 0 && ( <> {t("sources") || "Sources"}
setSelectedSource(null)} className="flex items-center justify-between" > {t("all_sources") || "All sources"} {selectedSource === null && } {statusSourceOptions.map((source) => ( setSelectedSource(source.key)} className="flex items-center justify-between" > {getConnectorIcon(source.type, "h-4 w-4")} {source.displayName} {selectedSource === source.key && } ))}
)}
)} {isMobile ? ( ) : ( {t("mark_all_read") || "Mark all as read"} )} {!isMobile && onDockedChange && ( {isDocked ? "Collapse panel" : "Expand panel"} )}
setSearchQuery(e.target.value)} className="pl-9 pr-8 h-9" /> {searchQuery && ( )}
{ const tab = value as InboxTab; setActiveTab(tab); if (tab !== "status" && activeFilter === "errors") { setActiveFilter("all"); } }} className="shrink-0 mx-4" > {t("comments") || "Comments"} {formatInboxCount(unreadCommentsCount)} {t("status") || "Status"} {formatInboxCount(unreadStatusCount)}
{isLoading ? (
{activeTab === "comments" ? [85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
)) : [75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
))}
) : filteredItems.length > 0 ? (
{filteredItems.map((item, index) => { const isMarkingAsRead = markingAsReadId === item.id; const isPrefetchTrigger = !isSearchMode && hasMoreProp && index === filteredItems.length - 5; return (
{isMobile ? ( ) : (

{item.title}

{convertRenderedToDisplay(item.message)}

)}
{formatTime(item.created_at)} {!item.read && }
); })} {!isSearchMode && filteredItems.length < 5 && hasMoreProp && (
)} {loadingMoreProp && (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}

)}
); if (isDocked && open && !isMobile) { return ( ); } return ( {inboxContent} ); }