SurfSense/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx

1122 lines
39 KiB
TypeScript
Raw Normal View History

"use client";
import { useQuery } from "@tanstack/react-query";
2026-01-28 22:15:43 -08:00
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 { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import {
isCommentReplyMetadata,
isConnectorIndexingMetadata,
isNewMentionMetadata,
isPageLimitExceededMetadata,
} from "@/contracts/types/inbox.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
2026-02-09 16:49:11 -08:00
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";
/**
* 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<string, string> = {
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<boolean>;
markAllAsRead: () => Promise<boolean>;
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<InboxTab>("comments");
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
const [selectedConnector, setSelectedConnector] = useState<string | null>(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<HTMLDivElement>) => {
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<number | null>(null);
// Prefetch trigger ref - placed on item near the end
const prefetchTriggerRef = useRef<HTMLDivElement>(null);
// Server-side search query (enabled only when user is typing a search)
// Determines which notification types to search based on active tab
2026-02-06 18:22:19 +05:30
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;
2026-02-06 18:22:19 +05:30
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<string>();
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
2026-02-06 18:22:19 +05:30
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 (
<Avatar className="h-8 w-8">
{metadata.author_avatar_url && (
<AvatarImage src={metadata.author_avatar_url} alt={metadata.author_name || "User"} />
)}
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(metadata.author_name, metadata.author_email)}
</AvatarFallback>
</Avatar>
);
}
return (
<Avatar className="h-8 w-8">
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(null, null)}
</AvatarFallback>
</Avatar>
);
}
// For page limit exceeded, show a warning icon with amber/orange color
if (item.type === "page_limit_exceeded") {
return (
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-amber-500/10">
<AlertTriangle className="h-4 w-4 text-amber-500" />
</div>
);
}
// For status items (connector/document), show status icons
// Safely access status from metadata
const metadata = item.metadata as Record<string, unknown>;
const status = typeof metadata?.status === "string" ? metadata.status : undefined;
switch (status) {
case "in_progress":
return (
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-muted">
<Spinner size="sm" className="text-foreground" />
</div>
);
case "completed":
return (
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-green-500/10">
<CheckCircle2 className="h-4 w-4 text-green-500" />
</div>
);
case "failed":
return (
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-red-500/10">
<AlertCircle className="h-4 w-4 text-red-500" />
</div>
);
default:
return (
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-muted">
<History className="h-4 w-4 text-muted-foreground" />
</div>
);
}
};
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;
// Shared content component for both docked and floating modes
const inboxContent = (
<>
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{/* Back button - mobile only */}
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => onOpenChange(false)}
>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("close") || "Close"}</span>
</Button>
)}
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
</div>
<div className="flex items-center gap-1">
{/* Mobile: Button that opens bottom drawer */}
{isMobile ? (
<>
2026-02-06 19:45:22 +05:30
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => setFilterDrawerOpen(true)}
>
<ListFilter className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("filter") || "Filter"}</span>
</Button>
<Drawer
open={filterDrawerOpen}
onOpenChange={setFilterDrawerOpen}
shouldScaleBackground={false}
>
<DrawerContent className="max-h-[70vh] z-80" overlayClassName="z-80">
<DrawerHandle />
<DrawerHeader className="px-4 pb-3 pt-2">
<DrawerTitle className="flex items-center gap-2 text-base font-semibold">
<ListFilter className="size-5" />
{t("filter") || "Filter"}
</DrawerTitle>
</DrawerHeader>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Filter section */}
<div className="space-y-2">
<p className="text-xs text-muted-foreground/80 font-medium px-1">
{t("filter") || "Filter"}
</p>
<div className="space-y-1">
<button
type="button"
onClick={() => {
setActiveFilter("all");
setFilterDrawerOpen(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
activeFilter === "all"
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)}
>
<span className="flex items-center gap-2">
<Inbox className="h-4 w-4" />
<span>{t("all") || "All"}</span>
</span>
{activeFilter === "all" && <Check className="h-4 w-4" />}
</button>
<button
type="button"
onClick={() => {
setActiveFilter("unread");
setFilterDrawerOpen(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
activeFilter === "unread"
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)}
>
<span className="flex items-center gap-2">
<BellDot className="h-4 w-4" />
<span>{t("unread") || "Unread"}</span>
</span>
{activeFilter === "unread" && <Check className="h-4 w-4" />}
</button>
</div>
</div>
{/* Connectors section - only for status tab */}
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground/80 font-medium px-1">
{t("connectors") || "Connectors"}
</p>
<div className="space-y-1">
<button
type="button"
onClick={() => {
setSelectedConnector(null);
setFilterDrawerOpen(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
selectedConnector === null
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)}
>
<span className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
<span>{t("all_connectors") || "All connectors"}</span>
</span>
{selectedConnector === null && <Check className="h-4 w-4" />}
</button>
{uniqueConnectorTypes.map((connector) => (
<button
key={connector.type}
type="button"
onClick={() => {
setSelectedConnector(connector.type);
setFilterDrawerOpen(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
selectedConnector === connector.type
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)}
>
<span className="flex items-center gap-2">
{getConnectorIcon(connector.type, "h-4 w-4")}
<span>{connector.displayName}</span>
</span>
{selectedConnector === connector.type && (
<Check className="h-4 w-4" />
)}
</button>
))}
</div>
</div>
)}
</div>
</DrawerContent>
</Drawer>
</>
) : (
/* Desktop: Dropdown menu */
<DropdownMenu
open={openDropdown === "filter"}
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<ListFilter className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("filter") || "Filter"}</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="end"
className={cn("z-80 select-none", activeTab === "status" ? "w-52" : "w-44")}
>
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
{t("filter") || "Filter"}
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => setActiveFilter("all")}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
<Inbox className="h-4 w-4" />
<span>{t("all") || "All"}</span>
</span>
{activeFilter === "all" && <Check className="h-4 w-4" />}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setActiveFilter("unread")}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
<BellDot className="h-4 w-4" />
<span>{t("unread") || "Unread"}</span>
</span>
{activeFilter === "unread" && <Check className="h-4 w-4" />}
</DropdownMenuItem>
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
{t("connectors") || "Connectors"}
</DropdownMenuLabel>
<div
className="relative max-h-[30vh] overflow-y-auto -mb-1"
onScroll={handleConnectorScroll}
style={{
maskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`,
WebkitMaskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`,
}}
>
<DropdownMenuItem
onClick={() => setSelectedConnector(null)}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
<span>{t("all_connectors") || "All connectors"}</span>
</span>
{selectedConnector === null && <Check className="h-4 w-4" />}
</DropdownMenuItem>
{uniqueConnectorTypes.map((connector) => (
<DropdownMenuItem
key={connector.type}
onClick={() => setSelectedConnector(connector.type)}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
{getConnectorIcon(connector.type, "h-4 w-4")}
<span>{connector.displayName}</span>
</span>
{selectedConnector === connector.type && <Check className="h-4 w-4" />}
</DropdownMenuItem>
))}
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
2026-02-06 19:45:22 +05:30
{isMobile ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={handleMarkAllAsRead}
disabled={totalUnreadCount === 0}
>
<CheckCheck className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={handleMarkAllAsRead}
disabled={totalUnreadCount === 0}
>
<CheckCheck className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">
{t("mark_all_read") || "Mark all as read"}
</TooltipContent>
</Tooltip>
)}
{/* Dock/Undock button - desktop only */}
{!isMobile && onDockedChange && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => {
if (isDocked) {
// Collapse: show comments immediately, then close inbox
setCommentsCollapsed(false);
onDockedChange(false);
onOpenChange(false);
} else {
// Expand: hide comments immediately
setCommentsCollapsed(true);
onDockedChange(true);
}
}}
>
{isDocked ? (
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">
{isDocked ? "Collapse panel" : "Expand panel"}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_inbox") || "Search inbox"}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 h-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
onClick={handleClearSearch}
>
<X className="h-3.5 w-3.5" />
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
</Button>
)}
</div>
</div>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as InboxTab)}
className="shrink-0 mx-4"
>
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
<TabsTrigger
value="comments"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<MessageSquare className="h-4 w-4" />
<span>{t("comments") || "Comments"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{formatInboxCount(unreadCommentsCount)}
</span>
</span>
</TabsTrigger>
<TabsTrigger
value="status"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<History className="h-4 w-4" />
<span>{t("status") || "Status"}</span>
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
{formatInboxCount(unreadStatusCount)}
</span>
</span>
</TabsTrigger>
</TabsList>
</Tabs>
2026-02-06 18:22:19 +05:30
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{(isSearchMode ? isSearchLoading : loading) ? (
<div className="space-y-2">
{activeTab === "comments"
? /* Comments skeleton: avatar + two-line text + time */
[85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
<div
key={`skeleton-comment-${i}`}
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
>
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
<div className="flex-1 min-w-0 space-y-2">
<Skeleton className="h-3 rounded" style={{ width: `${titleWidth}%` }} />
<Skeleton className="h-2.5 w-[70%] rounded" />
</div>
<Skeleton className="h-3 w-6 shrink-0 rounded" />
</div>
2026-02-06 18:22:19 +05:30
))
: /* Status skeleton: status icon circle + two-line text + time */
[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
<div
key={`skeleton-status-${i}`}
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
>
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
<div className="flex-1 min-w-0 space-y-2">
<Skeleton className="h-3 rounded" style={{ width: `${titleWidth}%` }} />
<Skeleton className="h-2.5 w-[60%] rounded" />
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Skeleton className="h-3 w-6 rounded" />
<Skeleton className="h-2 w-2 rounded-full" />
</div>
</div>
2026-02-06 18:22:19 +05:30
))}
</div>
) : filteredItems.length > 0 ? (
<div className="space-y-2">
{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 (
<div
key={item.id}
ref={isPrefetchTrigger ? prefetchTriggerRef : undefined}
className={cn(
"group flex items-center gap-3 rounded-lg px-3 py-3 text-sm h-[80px] overflow-hidden",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors cursor-pointer",
isMarkingAsRead && "opacity-50 pointer-events-none"
)}
>
2026-02-06 19:45:22 +05:30
{isMobile ? (
<button
type="button"
onClick={() => handleItemClick(item)}
disabled={isMarkingAsRead}
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
>
<div className="shrink-0">{getStatusIcon(item)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
<p
className={cn(
"text-xs font-medium line-clamp-2",
!item.read && "font-semibold"
)}
>
{item.title}
</p>
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
{convertRenderedToDisplay(item.message)}
</p>
</div>
</button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleItemClick(item)}
disabled={isMarkingAsRead}
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
>
<div className="shrink-0">{getStatusIcon(item)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
<p
className={cn(
"text-xs font-medium line-clamp-2",
!item.read && "font-semibold"
)}
>
{item.title}
</p>
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
{convertRenderedToDisplay(item.message)}
</p>
</div>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-[250px]">
<p className="font-medium">{item.title}</p>
<p className="text-muted-foreground mt-1">
{convertRenderedToDisplay(item.message)}
</p>
</TooltipContent>
</Tooltip>
)}
{/* Time and unread dot - fixed width to prevent content shift */}
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
<span className="text-[10px] text-muted-foreground">
{formatTime(item.created_at)}
</span>
{!item.read && <span className="h-2 w-2 rounded-full bg-blue-500 shrink-0" />}
</div>
</div>
);
})}
2026-02-06 18:22:19 +05:30
{/* Fallback trigger at the very end if less than 5 items and not searching */}
{!isSearchMode && filteredItems.length < 5 && hasMore && (
<div ref={prefetchTriggerRef} className="h-1" />
)}
{/* Loading more skeletons at the bottom during infinite scroll */}
{loadingMore &&
(activeTab === "comments"
? [80, 60, 90].map((titleWidth, i) => (
<div
key={`loading-more-comment-${i}`}
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
>
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
<div className="flex-1 min-w-0 space-y-2">
<Skeleton className="h-3 rounded" style={{ width: `${titleWidth}%` }} />
<Skeleton className="h-2.5 w-[70%] rounded" />
</div>
<Skeleton className="h-3 w-6 shrink-0 rounded" />
</div>
))
: [70, 85, 55].map((titleWidth, i) => (
<div
key={`loading-more-status-${i}`}
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
>
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
<div className="flex-1 min-w-0 space-y-2">
<Skeleton className="h-3 rounded" style={{ width: `${titleWidth}%` }} />
<Skeleton className="h-2.5 w-[60%] rounded" />
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Skeleton className="h-3 w-6 rounded" />
<Skeleton className="h-2 w-2 rounded-full" />
</div>
</div>
)))}
</div>
) : isSearchMode ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
{t("no_results_found") || "No results found"}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
{t("try_different_search") || "Try a different search term"}
</p>
</div>
) : (
<div className="text-center py-8">
{activeTab === "comments" ? (
<MessageSquare className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
) : (
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
)}
<p className="text-sm text-muted-foreground">{getEmptyStateMessage().title}</p>
<p className="text-xs text-muted-foreground/70 mt-1">{getEmptyStateMessage().hint}</p>
</div>
)}
</div>
</>
);
// DOCKED MODE: Render as a static flex child (no animation, no click-away)
if (isDocked && open && !isMobile) {
return (
<aside
className="h-full w-[360px] shrink-0 bg-background flex flex-col border-r"
aria-label={t("inbox") || "Inbox"}
>
{inboxContent}
</aside>
);
}
// FLOATING MODE: Render with animation and click-away layer
return (
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}>
{inboxContent}
</SidebarSlideOutPanel>
);
}