feat(notifications): enhance inbox functionality with type filtering and independent pagination for mentions and status notifications

This commit is contained in:
Anish Sarkar 2026-01-28 02:14:36 +05:30
parent 5690ac09ec
commit 79f7dfbbed
12 changed files with 600 additions and 532 deletions

View file

@ -6,6 +6,7 @@ For older items (beyond the sync window), use the list endpoint.
""" """
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel from pydantic import BaseModel
@ -20,6 +21,9 @@ router = APIRouter(prefix="/notifications", tags=["notifications"])
# Must match frontend SYNC_WINDOW_DAYS in use-inbox.ts # Must match frontend SYNC_WINDOW_DAYS in use-inbox.ts
SYNC_WINDOW_DAYS = 14 SYNC_WINDOW_DAYS = 14
# Valid notification types - must match frontend InboxItemTypeEnum
NotificationType = Literal["connector_indexing", "document_processing", "new_mention"]
class NotificationResponse(BaseModel): class NotificationResponse(BaseModel):
"""Response model for a single notification.""" """Response model for a single notification."""
@ -73,6 +77,9 @@ class UnreadCountResponse(BaseModel):
@router.get("/unread-count", response_model=UnreadCountResponse) @router.get("/unread-count", response_model=UnreadCountResponse)
async def get_unread_count( async def get_unread_count(
search_space_id: int | None = Query(None, description="Filter by search space ID"), search_space_id: int | None = Query(None, description="Filter by search space ID"),
type_filter: NotificationType | None = Query(
None, alias="type", description="Filter by notification type"
),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
) -> UnreadCountResponse: ) -> UnreadCountResponse:
@ -103,6 +110,10 @@ async def get_unread_count(
| (Notification.search_space_id.is_(None)) | (Notification.search_space_id.is_(None))
) )
# Filter by notification type if provided
if type_filter:
base_filter.append(Notification.type == type_filter)
# Total unread count (all time) # Total unread count (all time)
total_query = select(func.count(Notification.id)).where(*base_filter) total_query = select(func.count(Notification.id)).where(*base_filter)
total_result = await session.execute(total_query) total_result = await session.execute(total_query)
@ -125,7 +136,7 @@ async def get_unread_count(
@router.get("", response_model=NotificationListResponse) @router.get("", response_model=NotificationListResponse)
async def list_notifications( async def list_notifications(
search_space_id: int | None = Query(None, description="Filter by search space ID"), search_space_id: int | None = Query(None, description="Filter by search space ID"),
type_filter: str | None = Query( type_filter: NotificationType | None = Query(
None, alias="type", description="Filter by notification type" None, alias="type", description="Filter by notification type"
), ),
before_date: str | None = Query( before_date: str | None = Query(

View file

@ -62,9 +62,7 @@ export const resetCurrentThreadAtom = atom(null, (_, set) => {
}); });
/** Atom to read whether comments panel is collapsed */ /** Atom to read whether comments panel is collapsed */
export const commentsCollapsedAtom = atom( export const commentsCollapsedAtom = atom((get) => get(currentThreadAtom).commentsCollapsed);
(get) => get(currentThreadAtom).commentsCollapsed
);
/** Atom to toggle the comments collapsed state */ /** Atom to toggle the comments collapsed state */
export const toggleCommentsCollapsedAtom = atom(null, (get, set) => { export const toggleCommentsCollapsedAtom = atom(null, (get, set) => {

View file

@ -254,10 +254,7 @@ const defaultComponents = memoizeMarkdownComponents({
table: ({ className, ...props }) => ( table: ({ className, ...props }) => (
<div className="aui-md-table-wrapper my-5 w-full overflow-x-auto"> <div className="aui-md-table-wrapper my-5 w-full overflow-x-auto">
<table <table
className={cn( className={cn("aui-md-table w-full min-w-max border-separate border-spacing-0", className)}
"aui-md-table w-full min-w-max border-separate border-spacing-0",
className
)}
{...props} {...props}
/> />
</div> </div>

View file

@ -3,10 +3,7 @@
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { MessageSquare } from "lucide-react"; import { MessageSquare } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import { clearTargetCommentIdAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
clearTargetCommentIdAtom,
targetCommentIdAtom,
} from "@/atoms/chat/current-thread.atom";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -82,10 +79,9 @@ function renderMentions(content: string): React.ReactNode {
const mentionPattern = /@\{([^}]+)\}/g; const mentionPattern = /@\{([^}]+)\}/g;
const parts: React.ReactNode[] = []; const parts: React.ReactNode[] = [];
let lastIndex = 0; let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = mentionPattern.exec(content)) !== null) { for (const match of content.matchAll(mentionPattern)) {
if (match.index > lastIndex) { if (match.index !== undefined && match.index > lastIndex) {
parts.push(content.slice(lastIndex, match.index)); parts.push(content.slice(lastIndex, match.index));
} }
@ -96,7 +92,7 @@ function renderMentions(content: string): React.ReactNode {
</span> </span>
); );
lastIndex = match.index + match[0].length; lastIndex = (match.index ?? 0) + match[0].length;
} }
if (lastIndex < content.length) { if (lastIndex < content.length) {

View file

@ -34,4 +34,3 @@ export function useSidebarContext(): SidebarContextValue {
export function useSidebarContextSafe(): SidebarContextValue | null { export function useSidebarContextSafe(): SidebarContextValue | null {
return useContext(SidebarContext); return useContext(SidebarContext);
} }

View file

@ -104,19 +104,55 @@ export function LayoutDataProvider({
// Search space dialog state // Search space dialog state
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
// Inbox hook // Inbox hooks - separate data sources for mentions and status tabs
// This ensures each tab has independent pagination and data loading
const userId = user?.id ? String(user.id) : null; const userId = user?.id ? String(user.id) : null;
// Mentions: Only fetch "new_mention" type notifications
const { const {
inboxItems, inboxItems: mentionItems,
unreadCount, unreadCount: mentionUnreadCount,
loading: inboxLoading, loading: mentionLoading,
loadingMore: inboxLoadingMore, loadingMore: mentionLoadingMore,
hasMore: inboxHasMore, hasMore: mentionHasMore,
loadMore: inboxLoadMore, loadMore: mentionLoadMore,
markAsRead, markAsRead: markMentionAsRead,
markAllAsRead, markAllAsRead: markAllMentionsAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, "new_mention");
// Status: Fetch all types (will be filtered client-side to status types)
// We pass null to get all, then InboxSidebar filters to status types
const {
inboxItems: statusItems,
unreadCount: statusUnreadCount,
loading: statusLoading,
loadingMore: statusLoadingMore,
hasMore: statusHasMore,
loadMore: statusLoadMore,
markAsRead: markStatusAsRead,
markAllAsRead: markAllStatusAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, null); } = useInbox(userId, Number(searchSpaceId) || null, null);
// Combined unread count for nav badge (mentions take priority for visibility)
const totalUnreadCount = mentionUnreadCount + statusUnreadCount;
// Unified mark as read that delegates to the correct hook
const markAsRead = useCallback(
async (id: number) => {
// Try both - one will succeed based on which list has the item
const mentionResult = await markMentionAsRead(id);
if (mentionResult) return true;
return markStatusAsRead(id);
},
[markMentionAsRead, markStatusAsRead]
);
// Mark all as read for both types
const markAllAsRead = useCallback(async () => {
await Promise.all([markAllMentionsAsRead(), markAllStatusAsRead()]);
return true;
}, [markAllMentionsAsRead, markAllStatusAsRead]);
// Delete dialogs state // Delete dialogs state
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false); const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
@ -197,7 +233,7 @@ export function LayoutDataProvider({
url: "#inbox", // Special URL to indicate this is handled differently url: "#inbox", // Special URL to indicate this is handled differently
icon: Inbox, icon: Inbox,
isActive: isInboxSidebarOpen, isActive: isInboxSidebarOpen,
badge: unreadCount > 0 ? formatInboxCount(unreadCount) : undefined, badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
}, },
{ {
title: "Documents", title: "Documents",
@ -206,7 +242,7 @@ export function LayoutDataProvider({
isActive: pathname?.includes("/documents"), isActive: pathname?.includes("/documents"),
}, },
], ],
[searchSpaceId, pathname, isInboxSidebarOpen, unreadCount] [searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount]
); );
// Handlers // Handlers
@ -465,12 +501,24 @@ export function LayoutDataProvider({
inbox={{ inbox={{
isOpen: isInboxSidebarOpen, isOpen: isInboxSidebarOpen,
onOpenChange: setIsInboxSidebarOpen, onOpenChange: setIsInboxSidebarOpen,
items: inboxItems, // Separate data sources for each tab
unreadCount, mentions: {
loading: inboxLoading, items: mentionItems,
loadingMore: inboxLoadingMore, unreadCount: mentionUnreadCount,
hasMore: inboxHasMore, loading: mentionLoading,
loadMore: inboxLoadMore, loadingMore: mentionLoadingMore,
hasMore: mentionHasMore,
loadMore: mentionLoadMore,
},
status: {
items: statusItems,
unreadCount: statusUnreadCount,
loading: statusLoading,
loadingMore: statusLoadingMore,
hasMore: statusHasMore,
loadMore: statusLoadMore,
},
totalUnreadCount,
markAsRead, markAsRead,
markAllAsRead, markAllAsRead,
isDocked: isInboxDocked, isDocked: isInboxDocked,

View file

@ -11,16 +11,26 @@ import { Header } from "../header";
import { IconRail } from "../icon-rail"; import { IconRail } from "../icon-rail";
import { InboxSidebar, MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar"; import { InboxSidebar, MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar";
// Inbox-related props // Tab-specific data source props
interface InboxProps { interface TabDataSource {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
items: InboxItem[]; items: InboxItem[];
unreadCount: number; unreadCount: number;
loading: boolean; loading: boolean;
loadingMore?: boolean; loadingMore?: boolean;
hasMore?: boolean; hasMore?: boolean;
loadMore?: () => void; loadMore?: () => void;
}
// Inbox-related props with separate data sources per tab
interface InboxProps {
isOpen: 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 nav badge */
totalUnreadCount: number;
markAsRead: (id: number) => Promise<boolean>; markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>; markAllAsRead: () => Promise<boolean>;
/** Whether the inbox is docked (permanent) */ /** Whether the inbox is docked (permanent) */
@ -151,26 +161,23 @@ export function LayoutShell({
setTheme={setTheme} setTheme={setTheme}
/> />
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}> <main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children} {children}
</main> </main>
{/* Mobile Inbox Sidebar - only render when open to avoid scroll blocking */} {/* Mobile Inbox Sidebar - only render when open to avoid scroll blocking */}
{inbox?.isOpen && ( {inbox?.isOpen && (
<InboxSidebar <InboxSidebar
open={inbox.isOpen} open={inbox.isOpen}
onOpenChange={inbox.onOpenChange} onOpenChange={inbox.onOpenChange}
inboxItems={inbox.items} mentions={inbox.mentions}
unreadCount={inbox.unreadCount} status={inbox.status}
loading={inbox.loading} totalUnreadCount={inbox.totalUnreadCount}
loadingMore={inbox.loadingMore} markAsRead={inbox.markAsRead}
hasMore={inbox.hasMore} markAllAsRead={inbox.markAllAsRead}
loadMore={inbox.loadMore} onCloseMobileSidebar={() => setMobileMenuOpen(false)}
markAsRead={inbox.markAsRead} />
markAllAsRead={inbox.markAllAsRead} )}
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
/>
)}
</div> </div>
</TooltipProvider> </TooltipProvider>
</SidebarProvider> </SidebarProvider>
@ -181,7 +188,9 @@ export function LayoutShell({
return ( return (
<SidebarProvider value={sidebarContextValue}> <SidebarProvider value={sidebarContextValue}>
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<div className={cn("flex h-screen w-full gap-2 p-2 overflow-hidden bg-muted/40", className)}> <div
className={cn("flex h-screen w-full gap-2 p-2 overflow-hidden bg-muted/40", className)}
>
<div className="hidden md:flex overflow-hidden"> <div className="hidden md:flex overflow-hidden">
<IconRail <IconRail
searchSpaces={searchSpaces} searchSpaces={searchSpaces}
@ -226,12 +235,9 @@ export function LayoutShell({
<InboxSidebar <InboxSidebar
open={inbox.isOpen} open={inbox.isOpen}
onOpenChange={inbox.onOpenChange} onOpenChange={inbox.onOpenChange}
inboxItems={inbox.items} mentions={inbox.mentions}
unreadCount={inbox.unreadCount} status={inbox.status}
loading={inbox.loading} totalUnreadCount={inbox.totalUnreadCount}
loadingMore={inbox.loadingMore}
hasMore={inbox.hasMore}
loadMore={inbox.loadMore}
markAsRead={inbox.markAsRead} markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead} markAllAsRead={inbox.markAllAsRead}
isDocked={inbox.isDocked} isDocked={inbox.isDocked}
@ -252,12 +258,9 @@ export function LayoutShell({
<InboxSidebar <InboxSidebar
open={inbox.isOpen} open={inbox.isOpen}
onOpenChange={inbox.onOpenChange} onOpenChange={inbox.onOpenChange}
inboxItems={inbox.items} mentions={inbox.mentions}
unreadCount={inbox.unreadCount} status={inbox.status}
loading={inbox.loading} totalUnreadCount={inbox.totalUnreadCount}
loadingMore={inbox.loadingMore}
hasMore={inbox.hasMore}
loadMore={inbox.loadMore}
markAsRead={inbox.markAsRead} markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead} markAllAsRead={inbox.markAllAsRead}
isDocked={false} isDocked={false}

View file

@ -44,12 +44,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { import { isConnectorIndexingMetadata, isNewMentionMetadata } from "@/contracts/types/inbox.types";
type ConnectorIndexingMetadata,
isConnectorIndexingMetadata,
isNewMentionMetadata,
type NewMentionMetadata,
} from "@/contracts/types/inbox.types";
import type { InboxItem } from "@/hooks/use-inbox"; import type { InboxItem } from "@/hooks/use-inbox";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -136,15 +131,25 @@ function getConnectorTypeDisplayName(connectorType: string): string {
type InboxTab = "mentions" | "status"; type InboxTab = "mentions" | "status";
type InboxFilter = "all" | "unread"; type InboxFilter = "all" | "unread";
interface InboxSidebarProps { // Tab-specific data source with independent pagination
open: boolean; interface TabDataSource {
onOpenChange: (open: boolean) => void; items: InboxItem[];
inboxItems: InboxItem[];
unreadCount: number; unreadCount: number;
loading: boolean; loading: boolean;
loadingMore?: boolean; loadingMore?: boolean;
hasMore?: boolean; hasMore?: boolean;
loadMore?: () => void; 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>; markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>; markAllAsRead: () => Promise<boolean>;
onCloseMobileSidebar?: () => void; onCloseMobileSidebar?: () => void;
@ -157,12 +162,9 @@ interface InboxSidebarProps {
export function InboxSidebar({ export function InboxSidebar({
open, open,
onOpenChange, onOpenChange,
inboxItems, mentions,
unreadCount, status,
loading, totalUnreadCount,
loadingMore = false,
hasMore = false,
loadMore,
markAsRead, markAsRead,
markAllAsRead, markAllAsRead,
onCloseMobileSidebar, onCloseMobileSidebar,
@ -226,18 +228,18 @@ export function InboxSidebar({
} }
}, [activeTab]); }, [activeTab]);
// Split items by type // Get current tab's data source - each tab has independent data and pagination
const mentionItems = useMemo( const currentDataSource = activeTab === "mentions" ? mentions : status;
() => inboxItems.filter((item) => item.type === "new_mention"), const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource;
[inboxItems]
);
// For status items, filter to only show status notification types
// (the status data source may include all types from API)
const statusItems = useMemo( const statusItems = useMemo(
() => () =>
inboxItems.filter( status.items.filter(
(item) => item.type === "connector_indexing" || item.type === "document_processing" (item) => item.type === "connector_indexing" || item.type === "document_processing"
), ),
[inboxItems] [status.items]
); );
// Get unique connector types from status items for filtering // Get unique connector types from status items for filtering
@ -259,12 +261,12 @@ export function InboxSidebar({
})); }));
}, [statusItems]); }, [statusItems]);
// Get items for current tab // Get items for current tab - mentions use their source directly, status uses filtered items
const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems; const displayItems = activeTab === "mentions" ? mentions.items : statusItems;
// Filter items based on filter type, connector filter, and search query // Filter items based on filter type, connector filter, and search query
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
let items = currentTabItems; let items = displayItems;
// Apply read/unread filter // Apply read/unread filter
if (activeFilter === "unread") { if (activeFilter === "unread") {
@ -295,7 +297,7 @@ export function InboxSidebar({
} }
return items; return items;
}, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]); }, [displayItems, activeFilter, activeTab, selectedConnector, searchQuery]);
// Intersection Observer for infinite scroll with prefetching // Intersection Observer for infinite scroll with prefetching
// Only active when not searching (search results are client-side filtered) // Only active when not searching (search results are client-side filtered)
@ -321,16 +323,11 @@ export function InboxSidebar({
} }
return () => observer.disconnect(); return () => observer.disconnect();
}, [loadMore, hasMore, loadingMore, open, searchQuery, filteredItems.length]); }, [loadMore, hasMore, loadingMore, open, searchQuery]);
// Count unread items per tab // Use unread counts from data sources (more accurate than client-side counting)
const unreadMentionsCount = useMemo(() => { const unreadMentionsCount = mentions.unreadCount;
return mentionItems.filter((item) => !item.read).length; const unreadStatusCount = status.unreadCount;
}, [mentionItems]);
const unreadStatusCount = useMemo(() => {
return statusItems.filter((item) => !item.read).length;
}, [statusItems]);
const handleItemClick = useCallback( const handleItemClick = useCallback(
async (item: InboxItem) => { async (item: InboxItem) => {
@ -481,209 +478,128 @@ export function InboxSidebar({
const inboxContent = ( const inboxContent = (
<> <>
<div className="shrink-0 p-4 pb-2 space-y-3"> <div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2> <h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* Mobile: Button that opens bottom drawer */} {/* Mobile: Button that opens bottom drawer */}
{isMobile ? ( {isMobile ? (
<> <>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 rounded-full" className="h-8 w-8 rounded-full"
onClick={() => setFilterDrawerOpen(true)} onClick={() => setFilterDrawerOpen(true)}
>
<ListFilter className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("filter") || "Filter"}</span>
</Button>
</TooltipTrigger>
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
</Tooltip>
<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> <ListFilter className="h-4 w-4 text-muted-foreground" />
<TooltipTrigger asChild> <span className="sr-only">{t("filter") || "Filter"}</span>
<DropdownMenuTrigger asChild> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full"> </TooltipTrigger>
<ListFilter className="h-4 w-4 text-muted-foreground" /> <TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
<span className="sr-only">{t("filter") || "Filter"}</span> </Tooltip>
</Button> <Drawer
</DropdownMenuTrigger> open={filterDrawerOpen}
</TooltipTrigger> onOpenChange={setFilterDrawerOpen}
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent> shouldScaleBackground={false}
</Tooltip> >
<DropdownMenuContent <DrawerContent className="max-h-[70vh] z-80" overlayClassName="z-80">
align="end" <DrawerHandle />
className={cn("z-80", activeTab === "status" ? "w-52" : "w-44")} <DrawerHeader className="px-4 pb-3 pt-2">
> <DrawerTitle className="flex items-center gap-2 text-base font-semibold">
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal"> <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"} {t("filter") || "Filter"}
</DropdownMenuLabel> </p>
<DropdownMenuItem <div className="space-y-1">
onClick={() => setActiveFilter("all")} <button
className="flex items-center justify-between" type="button"
> onClick={() => {
<span className="flex items-center gap-2"> setActiveFilter("all");
<Inbox className="h-4 w-4" /> setFilterDrawerOpen(false);
<span>{t("all") || "All"}</span> }}
</span> className={cn(
{activeFilter === "all" && <Check className="h-4 w-4" />} "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
</DropdownMenuItem> activeFilter === "all"
<DropdownMenuItem ? "bg-primary/10 text-primary"
onClick={() => setActiveFilter("unread")} : "hover:bg-muted"
className="flex items-center justify-between" )}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<BellDot className="h-4 w-4" /> <Inbox className="h-4 w-4" />
<span>{t("unread") || "Unread"}</span> <span>{t("all") || "All"}</span>
</span> </span>
{activeFilter === "unread" && <Check className="h-4 w-4" />} {activeFilter === "all" && <Check className="h-4 w-4" />}
</DropdownMenuItem> </button>
{activeTab === "status" && uniqueConnectorTypes.length > 0 && ( <button
<> type="button"
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2"> onClick={() => {
{t("connectors") || "Connectors"} setActiveFilter("unread");
</DropdownMenuLabel> setFilterDrawerOpen(false);
<DropdownMenuItem }}
onClick={() => setSelectedConnector(null)} className={cn(
className="flex items-center justify-between" "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"> <span className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4" /> <LayoutGrid className="h-4 w-4" />
<span>{t("all_connectors") || "All connectors"}</span> <span>{t("all_connectors") || "All connectors"}</span>
</span> </span>
{selectedConnector === null && <Check className="h-4 w-4" />} {selectedConnector === null && <Check className="h-4 w-4" />}
</DropdownMenuItem> </button>
{uniqueConnectorTypes.map((connector) => ( {uniqueConnectorTypes.map((connector) => (
<DropdownMenuItem <button
key={connector.type} key={connector.type}
onClick={() => setSelectedConnector(connector.type)} type="button"
className="flex items-center justify-between" 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"> <span className="flex items-center gap-2">
{getConnectorIcon(connector.type, "h-4 w-4")} {getConnectorIcon(connector.type, "h-4 w-4")}
@ -692,240 +608,311 @@ export function InboxSidebar({
{selectedConnector === connector.type && ( {selectedConnector === connector.type && (
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
)} )}
</DropdownMenuItem> </button>
))} ))}
</> </div>
)} </div>
</DropdownMenuContent> )}
</DropdownMenu> </div>
)} </DrawerContent>
<Tooltip> </Drawer>
<TooltipTrigger asChild> </>
<Button ) : (
variant="ghost" /* Desktop: Dropdown menu */
size="icon" <DropdownMenu
className="h-8 w-8 rounded-full" open={openDropdown === "filter"}
onClick={handleMarkAllAsRead} onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
disabled={unreadCount === 0} >
> <Tooltip>
<CheckCheck className="h-4 w-4 text-muted-foreground" /> <TooltipTrigger asChild>
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span> <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> </Button>
</TooltipTrigger> </DropdownMenuTrigger>
<TooltipContent className="z-80"> </TooltipTrigger>
{t("mark_all_read") || "Mark all as read"} <TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
</TooltipContent> </Tooltip>
</Tooltip> <DropdownMenuContent
{/* Close button - mobile only */} align="end"
{isMobile && ( className={cn("z-80", activeTab === "status" ? "w-52" : "w-44")}
<Tooltip> >
<TooltipTrigger asChild> <DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
<Button {t("filter") || "Filter"}
variant="ghost" </DropdownMenuLabel>
size="icon" <DropdownMenuItem
className="h-8 w-8 rounded-full" onClick={() => setActiveFilter("all")}
onClick={() => onOpenChange(false)} 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>
<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"
> >
<ChevronLeft className="h-4 w-4 text-muted-foreground" /> <span className="flex items-center gap-2">
<span className="sr-only">{t("close") || "Close"}</span> {getConnectorIcon(connector.type, "h-4 w-4")}
</Button> <span>{connector.displayName}</span>
</TooltipTrigger>
<TooltipContent className="z-80">
{t("close") || "Close"}
</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> </span>
</Button> {selectedConnector === connector.type && <Check className="h-4 w-4" />}
</TooltipTrigger> </DropdownMenuItem>
<TooltipContent className="z-80"> ))}
{isDocked ? "Collapse panel" : "Expand panel"} </>
</TooltipContent>
</Tooltip>
)} )}
</div> </DropdownMenuContent>
</div> </DropdownMenu>
)}
<div className="relative"> <Tooltip>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <TooltipTrigger asChild>
<Input <Button
type="text" variant="ghost"
placeholder={t("search_inbox") || "Search inbox"} size="icon"
value={searchQuery} className="h-8 w-8 rounded-full"
onChange={(e) => setSearchQuery(e.target.value)} onClick={handleMarkAllAsRead}
className="pl-9 pr-8 h-9" disabled={totalUnreadCount === 0}
/> >
{searchQuery && ( <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>
{/* Close button - mobile only */}
{isMobile && (
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6" className="h-8 w-8 rounded-full"
onClick={handleClearSearch} onClick={() => onOpenChange(false)}
> >
<X className="h-3.5 w-3.5" /> <ChevronLeft className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("clear_search") || "Clear search"}</span> <span className="sr-only">{t("close") || "Close"}</span>
</Button> </Button>
)} </TooltipTrigger>
</div> <TooltipContent className="z-80">{t("close") || "Close"}</TooltipContent>
</div> </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>
<Tabs <div className="relative">
value={activeTab} <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
onValueChange={(value) => setActiveTab(value as InboxTab)} <Input
className="shrink-0 mx-4" 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}
> >
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b"> <X className="h-3.5 w-3.5" />
<TabsTrigger <span className="sr-only">{t("clear_search") || "Clear search"}</span>
value="mentions" </Button>
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" )}
> </div>
<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"> </div>
<AtSign className="h-4 w-4" />
<span>{t("mentions") || "Mentions"}</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(unreadMentionsCount)}
</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>
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2"> <Tabs
{loading ? ( value={activeTab}
<div className="flex items-center justify-center py-8"> onValueChange={(value) => setActiveTab(value as InboxTab)}
<Spinner size="md" className="text-muted-foreground" /> className="shrink-0 mx-4"
</div> >
) : filteredItems.length > 0 ? ( <TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
<div className="space-y-2"> <TabsTrigger
{filteredItems.map((item, index) => { value="mentions"
const isMarkingAsRead = markingAsReadId === item.id; 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"
// Place prefetch trigger on 5th item from end (only if not searching) >
const isPrefetchTrigger = <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">
!searchQuery && hasMore && index === filteredItems.length - 5; <AtSign className="h-4 w-4" />
<span>{t("mentions") || "Mentions"}</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(unreadMentionsCount)}
</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>
return ( <div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
<div {loading ? (
key={item.id} <div className="flex items-center justify-center py-8">
ref={isPrefetchTrigger ? prefetchTriggerRef : undefined} <Spinner size="md" className="text-muted-foreground" />
className={cn( </div>
"group flex items-center gap-3 rounded-lg px-3 py-3 text-sm h-[80px] overflow-hidden", ) : filteredItems.length > 0 ? (
"hover:bg-accent hover:text-accent-foreground", <div className="space-y-2">
"transition-colors cursor-pointer", {filteredItems.map((item, index) => {
isMarkingAsRead && "opacity-50 pointer-events-none" 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 (
<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"
)}
>
<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"
> >
<Tooltip> <div className="shrink-0">{getStatusIcon(item)}</div>
<TooltipTrigger asChild> <div className="flex-1 min-w-0 overflow-hidden">
<button <p
type="button" className={cn(
onClick={() => handleItemClick(item)} "text-xs font-medium line-clamp-2",
disabled={isMarkingAsRead} !item.read && "font-semibold"
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden" )}
> >
<div className="shrink-0">{getStatusIcon(item)}</div> {item.title}
<div className="flex-1 min-w-0 overflow-hidden"> </p>
<p <p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
className={cn( {convertRenderedToDisplay(item.message)}
"text-xs font-medium line-clamp-2", </p>
!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>
</div> </button>
); </TooltipTrigger>
})} <TooltipContent side="bottom" align="start" className="max-w-[250px]">
{/* Fallback trigger at the very end if less than 5 items and not searching */} <p className="font-medium">{item.title}</p>
{!searchQuery && filteredItems.length < 5 && hasMore && ( <p className="text-muted-foreground mt-1">
<div ref={prefetchTriggerRef} className="h-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> </div>
) : searchQuery ? ( );
<div className="text-center py-8"> })}
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" /> {/* Fallback trigger at the very end if less than 5 items and not searching */}
<p className="text-sm text-muted-foreground"> {!searchQuery && filteredItems.length < 5 && hasMore && (
{t("no_results_found") || "No results found"} <div ref={prefetchTriggerRef} className="h-1" />
</p> )}
<p className="text-xs text-muted-foreground/70 mt-1"> </div>
{t("try_different_search") || "Try a different search term"} ) : searchQuery ? (
</p> <div className="text-center py-8">
</div> <Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
) : ( <p className="text-sm text-muted-foreground">
<div className="text-center py-8"> {t("no_results_found") || "No results found"}
{activeTab === "mentions" ? ( </p>
<AtSign className="h-12 w-12 mx-auto text-muted-foreground mb-3" /> <p className="text-xs text-muted-foreground/70 mt-1">
) : ( {t("try_different_search") || "Try a different search term"}
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" /> </p>
)} </div>
<p className="text-sm text-muted-foreground">{getEmptyStateMessage().title}</p> ) : (
<p className="text-xs text-muted-foreground/70 mt-1"> <div className="text-center py-8">
{getEmptyStateMessage().hint} {activeTab === "mentions" ? (
</p> <AtSign className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
</div> ) : (
)} <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> </div>
</> </>
); );
@ -967,10 +954,7 @@ export function InboxSidebar({
left: isMobile ? 0 : sidebarWidth, left: isMobile ? 0 : sidebarWidth,
width: isMobile ? "100%" : 360, width: isMobile ? "100%" : 360,
}} }}
className={cn( className={cn("absolute z-10 overflow-hidden pointer-events-none", "inset-y-0")}
"absolute z-10 overflow-hidden pointer-events-none",
"inset-y-0"
)}
> >
<motion.div <motion.div
initial={{ x: "-100%" }} initial={{ x: "-100%" }}

View file

@ -197,7 +197,9 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
<div className="min-w-0"> <div className="min-w-0">
<h3 className="truncate font-semibold text-foreground text-sm sm:text-base">{title}</h3> <h3 className="truncate font-semibold text-foreground text-sm sm:text-base">{title}</h3>
{description && ( {description && (
<p className="mt-0.5 line-clamp-1 text-muted-foreground text-xs sm:text-sm">{description}</p> <p className="mt-0.5 line-clamp-1 text-muted-foreground text-xs sm:text-sm">
{description}
</p>
)} )}
</div> </div>
@ -243,7 +245,11 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
{/* Volume control */} {/* Volume control */}
<div className="flex items-center gap-1 sm:gap-1.5"> <div className="flex items-center gap-1 sm:gap-1.5">
<Button variant="ghost" size="icon" onClick={toggleMute} className="size-7 sm:size-8"> <Button variant="ghost" size="icon" onClick={toggleMute} className="size-7 sm:size-8">
{isMuted ? <VolumeXIcon className="size-3.5 sm:size-4" /> : <Volume2Icon className="size-3.5 sm:size-4" />} {isMuted ? (
<VolumeXIcon className="size-3.5 sm:size-4" />
) : (
<Volume2Icon className="size-3.5 sm:size-4" />
)}
</Button> </Button>
{/* Custom volume bar - visually distinct from progress slider */} {/* Custom volume bar - visually distinct from progress slider */}
<div className="relative flex h-6 w-12 sm:w-16 items-center"> <div className="relative flex h-6 w-12 sm:w-16 items-center">
@ -268,7 +274,12 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
</div> </div>
{/* Download button */} {/* Download button */}
<Button variant="outline" size="sm" onClick={handleDownload} className="gap-1.5 sm:gap-2 h-7 sm:h-8 px-2.5 sm:px-3 text-xs sm:text-sm"> <Button
variant="outline"
size="sm"
onClick={handleDownload}
className="gap-1.5 sm:gap-2 h-7 sm:h-8 px-2.5 sm:px-3 text-xs sm:text-sm"
>
<DownloadIcon className="size-3 sm:size-4" /> <DownloadIcon className="size-3 sm:size-4" />
Download Download
</Button> </Button>

View file

@ -96,10 +96,14 @@ function PodcastGeneratingState({ title }: { title: string }) {
<div className="absolute inset-1 animate-ping rounded-full bg-primary/20" /> <div className="absolute inset-1 animate-ping rounded-full bg-primary/20" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground text-sm sm:text-lg leading-tight">{title}</h3> <h3 className="font-semibold text-foreground text-sm sm:text-lg leading-tight">
{title}
</h3>
<div className="mt-1.5 sm:mt-2 flex items-center gap-1.5 sm:gap-2 text-muted-foreground"> <div className="mt-1.5 sm:mt-2 flex items-center gap-1.5 sm:gap-2 text-muted-foreground">
<Spinner size="sm" className="size-3 sm:size-4" /> <Spinner size="sm" className="size-3 sm:size-4" />
<span className="text-xs sm:text-sm">Generating podcast. This may take a few minutes.</span> <span className="text-xs sm:text-sm">
Generating podcast. This may take a few minutes.
</span>
</div> </div>
<div className="mt-2 sm:mt-3"> <div className="mt-2 sm:mt-3">
<div className="h-1 sm:h-1.5 w-full overflow-hidden rounded-full bg-primary/10"> <div className="h-1 sm:h-1.5 w-full overflow-hidden rounded-full bg-primary/10">
@ -123,7 +127,9 @@ function PodcastErrorState({ title, error }: { title: string; error: string }) {
<AlertCircleIcon className="size-6 sm:size-8 text-destructive" /> <AlertCircleIcon className="size-6 sm:size-8 text-destructive" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight">{title}</h3> <h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight">
{title}
</h3>
<p className="mt-1 text-destructive text-xs sm:text-sm">Failed to generate podcast</p> <p className="mt-1 text-destructive text-xs sm:text-sm">Failed to generate podcast</p>
<p className="mt-1.5 sm:mt-2 text-muted-foreground text-xs sm:text-sm">{error}</p> <p className="mt-1.5 sm:mt-2 text-muted-foreground text-xs sm:text-sm">{error}</p>
</div> </div>
@ -143,7 +149,9 @@ function AudioLoadingState({ title }: { title: string }) {
<MicIcon className="size-6 sm:size-8 text-primary/50" /> <MicIcon className="size-6 sm:size-8 text-primary/50" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight">{title}</h3> <h3 className="font-semibold text-foreground text-sm sm:text-base leading-tight">
{title}
</h3>
<div className="mt-1.5 sm:mt-2 flex items-center gap-1.5 sm:gap-2 text-muted-foreground"> <div className="mt-1.5 sm:mt-2 flex items-center gap-1.5 sm:gap-2 text-muted-foreground">
<Spinner size="sm" className="size-3 sm:size-4" /> <Spinner size="sm" className="size-3 sm:size-4" />
<span className="text-xs sm:text-sm">Loading audio...</span> <span className="text-xs sm:text-sm">Loading audio...</span>

View file

@ -318,9 +318,13 @@ export function useInbox(
try { try {
// STEP 1: Fetch server counts (total and recent) - guaranteed accurate // STEP 1: Fetch server counts (total and recent) - guaranteed accurate
console.log("[useInbox] Fetching unread count from server"); console.log(
"[useInbox] Fetching unread count from server",
typeFilter ? `for type: ${typeFilter}` : "for all types"
);
const serverCounts = await notificationsApiService.getUnreadCount( const serverCounts = await notificationsApiService.getUnreadCount(
searchSpaceId ?? undefined searchSpaceId ?? undefined,
typeFilter ?? undefined
); );
if (mounted) { if (mounted) {

View file

@ -2,6 +2,7 @@ import {
type GetNotificationsRequest, type GetNotificationsRequest,
type GetNotificationsResponse, type GetNotificationsResponse,
type GetUnreadCountResponse, type GetUnreadCountResponse,
type InboxItemTypeEnum,
getNotificationsRequest, getNotificationsRequest,
getNotificationsResponse, getNotificationsResponse,
getUnreadCountResponse, getUnreadCountResponse,
@ -92,12 +93,20 @@ class NotificationsApiService {
* Get unread notification count with split between total and recent * Get unread notification count with split between total and recent
* - total_unread: All unread notifications * - total_unread: All unread notifications
* - recent_unread: Unread within sync window (last 14 days) * - recent_unread: Unread within sync window (last 14 days)
* @param searchSpaceId - Optional search space ID to filter by
* @param type - Optional notification type to filter by (type-safe enum)
*/ */
getUnreadCount = async (searchSpaceId?: number): Promise<GetUnreadCountResponse> => { getUnreadCount = async (
searchSpaceId?: number,
type?: InboxItemTypeEnum
): Promise<GetUnreadCountResponse> => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (searchSpaceId !== undefined) { if (searchSpaceId !== undefined) {
params.append("search_space_id", String(searchSpaceId)); params.append("search_space_id", String(searchSpaceId));
} }
if (type) {
params.append("type", type);
}
const queryString = params.toString(); const queryString = params.toString();
return baseApiService.get( return baseApiService.get(