diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx deleted file mode 100644 index 7cf909112..000000000 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ /dev/null @@ -1,1082 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { useAtom } from "jotai"; -import { - AlertCircle, - AlertTriangle, - BellDot, - Check, - CheckCheck, - CheckCircle2, - ChevronLeft, - History, - Inbox, - LayoutGrid, - ListFilter, - MessageCircleReply, - Search, - X, -} from "lucide-react"; -import { useParams, useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; -import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs"; -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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import { - isCommentReplyMetadata, - isConnectorIndexingMetadata, - isDocumentProcessingMetadata, - isInsufficientCreditsMetadata, - isNewMentionMetadata, -} 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 { convertRenderedToDisplay } from "@/lib/comments/utils"; -import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; -import { getWorkspaceIdNumber } from "@/lib/route-params"; -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", - ONEDRIVE_CONNECTOR: "OneDrive", - DROPBOX_CONNECTOR: "Dropbox", - 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"; - -interface TabDataSource { - items: InboxItem[]; - unreadCount: number; - loading: boolean; - loadingMore: boolean; - hasMore: boolean; - loadMore: () => void; - markAsRead: (id: number) => Promise; - markAllAsRead: () => Promise; -} - -export interface InboxSidebarContentProps { - onOpenChange: (open: boolean) => void; - comments: TabDataSource; - status: TabDataSource; - totalUnreadCount: number; - onCloseMobileSidebar?: () => void; -} - -interface InboxSidebarProps extends InboxSidebarContentProps { - open: boolean; -} - -export function InboxSidebarContent({ - onOpenChange, - comments, - status, - totalUnreadCount, - onCloseMobileSidebar, -}: InboxSidebarContentProps) { - const t = useTranslations("sidebar"); - const router = useRouter(); - const params = useParams(); - const isMobile = !useMediaQuery("(min-width: 640px)"); - const workspaceId = getWorkspaceIdNumber(params) ?? null; - - 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 connectorRafRef = useRef(null); - const handleConnectorScroll = useCallback((e: React.UIEvent) => { - const el = e.currentTarget; - if (connectorRafRef.current) return; - connectorRafRef.current = requestAnimationFrame(() => { - const atTop = el.scrollTop <= 2; - const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; - setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); - connectorRafRef.current = null; - }); - }, []); - useEffect( - () => () => { - if (connectorRafRef.current) cancelAnimationFrame(connectorRafRef.current); - }, - [] - ); - 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(workspaceId, debouncedSearch.trim(), activeTab), - queryFn: () => - notificationsApiService.getNotifications({ - queryParams: { - workspace_id: workspaceId ?? undefined, - type: searchTypeFilter, - search: debouncedSearch.trim(), - limit: 50, - }, - }), - staleTime: 30 * 1000, - enabled: isSearchMode, - }); - - useEffect(() => { - setMounted(true); - }, []); - - useEffect(() => { - if (!isMobile) return; - const originalOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - return () => { - document.body.style.overflow = originalOverflow; - }; - }, [isMobile]); - - useEffect(() => { - if (activeTab !== "status") { - setSelectedSource(null); - } - }, [activeTab]); - - // Active tab's data source — fully independent loading, pagination, and counts - const activeSource = activeTab === "comments" ? comments : status; - - // Fetch source types for the status tab filter - const { data: sourceTypesData } = useQuery({ - queryKey: cacheKeys.notifications.sourceTypes(workspaceId), - queryFn: () => notificationsApiService.getSourceTypes(workspaceId ?? undefined), - staleTime: 60 * 1000, - enabled: 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 === "insufficient_credits") return true; - const meta = item.metadata as Record | undefined; - return typeof meta?.status === "string" && meta.status === "failed"; - } - return true; - }, - [activeFilter] - ); - - // Defer non-urgent list updates so the search input stays responsive. - // The deferred snapshot lags one render behind the live value intentionally. - const deferredTabItems = useDeferredValue(activeSource.items); - const deferredSearchItems = useDeferredValue(searchResponse?.items ?? []); - - // Two data paths: search mode (API) or default (per-tab data source) - const filteredItems = useMemo(() => { - const tabItems: InboxItem[] = isSearchMode ? deferredSearchItems : deferredTabItems; - - let result = tabItems; - if (activeFilter !== "all") { - result = result.filter(matchesActiveFilter); - } - if (activeTab === "status" && selectedSource) { - result = result.filter(matchesSourceFilter); - } - - return result; - }, [ - isSearchMode, - deferredSearchItems, - deferredTabItems, - activeTab, - activeFilter, - selectedSource, - matchesActiveFilter, - matchesSourceFilter, - ]); - - // Infinite scroll — uses active tab's pagination - useEffect(() => { - if (!activeSource.hasMore || activeSource.loadingMore || isSearchMode) return; - - const observer = new IntersectionObserver( - (entries) => { - if (entries[0]?.isIntersecting) { - activeSource.loadMore(); - } - }, - { - root: null, - rootMargin: "100px", - threshold: 0, - } - ); - - if (prefetchTriggerRef.current) { - observer.observe(prefetchTriggerRef.current); - } - - return () => observer.disconnect(); - }, [activeSource.hasMore, activeSource.loadingMore, activeSource.loadMore, isSearchMode]); - - const handleItemClick = useCallback( - async (item: InboxItem) => { - if (!item.read) { - setMarkingAsReadId(item.id); - await activeSource.markAsRead(item.id); - setMarkingAsReadId(null); - } - - if (item.type === "new_mention") { - if (isNewMentionMetadata(item.metadata)) { - const workspaceId = item.workspace_id; - const threadId = item.metadata.thread_id; - const commentId = item.metadata.comment_id; - - if (workspaceId && threadId) { - if (commentId) { - setTargetCommentId(commentId); - } - const url = commentId - ? `/dashboard/${workspaceId}/new-chat/${threadId}?commentId=${commentId}` - : `/dashboard/${workspaceId}/new-chat/${threadId}`; - onOpenChange(false); - onCloseMobileSidebar?.(); - router.push(url); - } - } - } else if (item.type === "comment_reply") { - if (isCommentReplyMetadata(item.metadata)) { - const workspaceId = item.workspace_id; - const threadId = item.metadata.thread_id; - const replyId = item.metadata.reply_id; - - if (workspaceId && threadId) { - if (replyId) { - setTargetCommentId(replyId); - } - const url = replyId - ? `/dashboard/${workspaceId}/new-chat/${threadId}?commentId=${replyId}` - : `/dashboard/${workspaceId}/new-chat/${threadId}`; - onOpenChange(false); - onCloseMobileSidebar?.(); - router.push(url); - } - } - } else if (item.type === "insufficient_credits") { - if (isInsufficientCreditsMetadata(item.metadata)) { - const actionUrl = item.metadata.action_url; - if (actionUrl) { - onOpenChange(false); - onCloseMobileSidebar?.(); - router.push(actionUrl); - } - } - } - }, - [activeSource.markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId] - ); - - const handleMarkAllAsRead = useCallback(async () => { - await Promise.all([comments.markAllAsRead(), status.markAllAsRead()]); - }, [comments.markAllAsRead, status.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 === "insufficient_credits") { - 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 : activeSource.loading; - - return ( - <> -
-
-
- {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 && } - - ))} -
- - )} -
-
- )} - - - - - - {t("mark_all_read") || "Mark all as read"} - - -
-
- -
- - setSearchQuery(e.target.value)} - className="h-8 border-0 bg-muted pl-8 pr-7 text-sm shadow-none" - /> - {searchQuery && ( - - )} -
-
- - { - const tab = value as InboxTab; - setActiveTab(tab); - if (tab !== "status" && activeFilter === "errors") { - setActiveFilter("all"); - } - }} - className="shrink-0 mx-3 mt-1.5" - > - - - - - {t("comments") || "Comments"} - - {formatInboxCount(comments.unreadCount)} - - - - - - - {t("status") || "Status"} - - {formatInboxCount(status.unreadCount)} - - - - - - -
- {isLoading ? ( -
- {activeTab === "comments" - ? [85, 60, 90, 70, 50, 75].map((titleWidth) => ( -
- -
- - -
- -
- )) - : [75, 90, 55, 80, 65, 85].map((titleWidth) => ( -
- -
- - -
-
- - -
-
- ))} -
- ) : filteredItems.length > 0 ? ( -
- {filteredItems.map((item, index) => { - const isMarkingAsRead = markingAsReadId === item.id; - const isPrefetchTrigger = - !isSearchMode && activeSource.hasMore && index === filteredItems.length - 5; - - return ( -
- {activeTab === "status" ? ( - - - - - -

{item.title}

-

- {convertRenderedToDisplay(item.message)} -

-
-
- ) : ( - - )} - -
- - {formatTime(item.created_at)} - - {!item.read && } -
-
- ); - })} - {!isSearchMode && filteredItems.length < 5 && activeSource.hasMore && ( -
- )} - {activeSource.loadingMore && - (activeTab === "comments" - ? [80, 60, 90].map((titleWidth) => ( -
- -
- - -
- -
- )) - : [70, 85, 55].map((titleWidth) => ( -
- -
- - -
-
- - -
-
- )))} -
- ) : isSearchMode ? ( -
- -

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

-

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

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

{getEmptyStateMessage().title}

-

- {getEmptyStateMessage().hint} -

-
- )} -
- - ); -} - -export function InboxSidebar({ - open, - onOpenChange, - comments, - status, - totalUnreadCount, - onCloseMobileSidebar, -}: InboxSidebarProps) { - const t = useTranslations("sidebar"); - - return ( - - - - ); -} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx deleted file mode 100644 index 52b2cf998..000000000 --- a/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx +++ /dev/null @@ -1,115 +0,0 @@ -"use client"; - -import { useSetAtom } from "jotai"; -import { AnimatePresence, motion } from "motion/react"; -import { useCallback, useEffect } from "react"; -import { useIsMobile } from "@/hooks/use-mobile"; -import { slideoutOpenedTickAtom } from "@/lib/layout-events"; - -interface SidebarSlideOutPanelProps { - open: boolean; - onOpenChange: (open: boolean) => void; - ariaLabel: string; - width?: number; - children: React.ReactNode; -} - -/** - * Reusable slide-out panel that extends from the sidebar. - * - * Desktop: absolutely positioned at the sidebar's right edge, overlaying the main - * content with a blur backdrop. Does not push/shrink the main content. - * - * Mobile: full-width absolute overlay (unchanged). - */ -export function SidebarSlideOutPanel({ - open, - onOpenChange, - ariaLabel, - width = 360, - children, -}: SidebarSlideOutPanelProps) { - const isMobile = useIsMobile(); - const bumpSlideoutOpenedTick = useSetAtom(slideoutOpenedTickAtom); - - useEffect(() => { - if (open) { - bumpSlideoutOpenedTick((tick) => tick + 1); - } - }, [open, bumpSlideoutOpenedTick]); - - const handleEscape = useCallback( - (e: KeyboardEvent) => { - if (e.key === "Escape") onOpenChange(false); - }, - [onOpenChange] - ); - - useEffect(() => { - if (!open) return; - document.addEventListener("keydown", handleEscape); - return () => document.removeEventListener("keydown", handleEscape); - }, [open, handleEscape]); - - if (isMobile) { - return ( - - {open && ( -
- - {children} - -
- )} -
- ); - } - - return ( - - {open && ( - <> - {/* Blur backdrop covering the main content area (right of sidebar) */} - onOpenChange(false)} - aria-hidden="true" - /> - - {/* Panel extending from sidebar's right edge, flush with the wrapper border */} - -
- {children} -
-
- - )} -
- ); -}