mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-08 20:25:19 +02:00
feat(notifications): enhance inbox functionality with type filtering and independent pagination for mentions and status notifications
This commit is contained in:
parent
5690ac09ec
commit
79f7dfbbed
12 changed files with 600 additions and 532 deletions
|
|
@ -6,6 +6,7 @@ For older items (beyond the sync window), use the list endpoint.
|
|||
"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -20,6 +21,9 @@ router = APIRouter(prefix="/notifications", tags=["notifications"])
|
|||
# Must match frontend SYNC_WINDOW_DAYS in use-inbox.ts
|
||||
SYNC_WINDOW_DAYS = 14
|
||||
|
||||
# Valid notification types - must match frontend InboxItemTypeEnum
|
||||
NotificationType = Literal["connector_indexing", "document_processing", "new_mention"]
|
||||
|
||||
|
||||
class NotificationResponse(BaseModel):
|
||||
"""Response model for a single notification."""
|
||||
|
|
@ -73,6 +77,9 @@ class UnreadCountResponse(BaseModel):
|
|||
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||
async def get_unread_count(
|
||||
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),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> UnreadCountResponse:
|
||||
|
|
@ -103,6 +110,10 @@ async def get_unread_count(
|
|||
| (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_query = select(func.count(Notification.id)).where(*base_filter)
|
||||
total_result = await session.execute(total_query)
|
||||
|
|
@ -125,7 +136,7 @@ async def get_unread_count(
|
|||
@router.get("", response_model=NotificationListResponse)
|
||||
async def list_notifications(
|
||||
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"
|
||||
),
|
||||
before_date: str | None = Query(
|
||||
|
|
|
|||
|
|
@ -62,9 +62,7 @@ export const resetCurrentThreadAtom = atom(null, (_, set) => {
|
|||
});
|
||||
|
||||
/** Atom to read whether comments panel is collapsed */
|
||||
export const commentsCollapsedAtom = atom(
|
||||
(get) => get(currentThreadAtom).commentsCollapsed
|
||||
);
|
||||
export const commentsCollapsedAtom = atom((get) => get(currentThreadAtom).commentsCollapsed);
|
||||
|
||||
/** Atom to toggle the comments collapsed state */
|
||||
export const toggleCommentsCollapsedAtom = atom(null, (get, set) => {
|
||||
|
|
|
|||
|
|
@ -254,10 +254,7 @@ const defaultComponents = memoizeMarkdownComponents({
|
|||
table: ({ className, ...props }) => (
|
||||
<div className="aui-md-table-wrapper my-5 w-full overflow-x-auto">
|
||||
<table
|
||||
className={cn(
|
||||
"aui-md-table w-full min-w-max border-separate border-spacing-0",
|
||||
className
|
||||
)}
|
||||
className={cn("aui-md-table w-full min-w-max border-separate border-spacing-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,7 @@
|
|||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
clearTargetCommentIdAtom,
|
||||
targetCommentIdAtom,
|
||||
} from "@/atoms/chat/current-thread.atom";
|
||||
import { clearTargetCommentIdAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -82,10 +79,9 @@ function renderMentions(content: string): React.ReactNode {
|
|||
const mentionPattern = /@\{([^}]+)\}/g;
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = mentionPattern.exec(content)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
for (const match of content.matchAll(mentionPattern)) {
|
||||
if (match.index !== undefined && match.index > lastIndex) {
|
||||
parts.push(content.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
|
|
@ -96,7 +92,7 @@ function renderMentions(content: string): React.ReactNode {
|
|||
</span>
|
||||
);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
lastIndex = (match.index ?? 0) + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < content.length) {
|
||||
|
|
|
|||
|
|
@ -34,4 +34,3 @@ export function useSidebarContext(): SidebarContextValue {
|
|||
export function useSidebarContextSafe(): SidebarContextValue | null {
|
||||
return useContext(SidebarContext);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -104,19 +104,55 @@ export function LayoutDataProvider({
|
|||
// Search space dialog state
|
||||
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;
|
||||
|
||||
// Mentions: Only fetch "new_mention" type notifications
|
||||
const {
|
||||
inboxItems,
|
||||
unreadCount,
|
||||
loading: inboxLoading,
|
||||
loadingMore: inboxLoadingMore,
|
||||
hasMore: inboxHasMore,
|
||||
loadMore: inboxLoadMore,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
inboxItems: mentionItems,
|
||||
unreadCount: mentionUnreadCount,
|
||||
loading: mentionLoading,
|
||||
loadingMore: mentionLoadingMore,
|
||||
hasMore: mentionHasMore,
|
||||
loadMore: mentionLoadMore,
|
||||
markAsRead: markMentionAsRead,
|
||||
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);
|
||||
|
||||
// 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
|
||||
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
||||
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
|
||||
icon: Inbox,
|
||||
isActive: isInboxSidebarOpen,
|
||||
badge: unreadCount > 0 ? formatInboxCount(unreadCount) : undefined,
|
||||
badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined,
|
||||
},
|
||||
{
|
||||
title: "Documents",
|
||||
|
|
@ -206,7 +242,7 @@ export function LayoutDataProvider({
|
|||
isActive: pathname?.includes("/documents"),
|
||||
},
|
||||
],
|
||||
[searchSpaceId, pathname, isInboxSidebarOpen, unreadCount]
|
||||
[searchSpaceId, pathname, isInboxSidebarOpen, totalUnreadCount]
|
||||
);
|
||||
|
||||
// Handlers
|
||||
|
|
@ -465,12 +501,24 @@ export function LayoutDataProvider({
|
|||
inbox={{
|
||||
isOpen: isInboxSidebarOpen,
|
||||
onOpenChange: setIsInboxSidebarOpen,
|
||||
items: inboxItems,
|
||||
unreadCount,
|
||||
loading: inboxLoading,
|
||||
loadingMore: inboxLoadingMore,
|
||||
hasMore: inboxHasMore,
|
||||
loadMore: inboxLoadMore,
|
||||
// Separate data sources for each tab
|
||||
mentions: {
|
||||
items: mentionItems,
|
||||
unreadCount: mentionUnreadCount,
|
||||
loading: mentionLoading,
|
||||
loadingMore: mentionLoadingMore,
|
||||
hasMore: mentionHasMore,
|
||||
loadMore: mentionLoadMore,
|
||||
},
|
||||
status: {
|
||||
items: statusItems,
|
||||
unreadCount: statusUnreadCount,
|
||||
loading: statusLoading,
|
||||
loadingMore: statusLoadingMore,
|
||||
hasMore: statusHasMore,
|
||||
loadMore: statusLoadMore,
|
||||
},
|
||||
totalUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
isDocked: isInboxDocked,
|
||||
|
|
|
|||
|
|
@ -11,16 +11,26 @@ import { Header } from "../header";
|
|||
import { IconRail } from "../icon-rail";
|
||||
import { InboxSidebar, MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar";
|
||||
|
||||
// Inbox-related props
|
||||
interface InboxProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
// Tab-specific data source props
|
||||
interface TabDataSource {
|
||||
items: InboxItem[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
loadingMore?: boolean;
|
||||
hasMore?: boolean;
|
||||
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>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
/** Whether the inbox is docked (permanent) */
|
||||
|
|
@ -151,26 +161,23 @@ export function LayoutShell({
|
|||
setTheme={setTheme}
|
||||
/>
|
||||
|
||||
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||
{children}
|
||||
</main>
|
||||
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Mobile Inbox Sidebar - only render when open to avoid scroll blocking */}
|
||||
{inbox?.isOpen && (
|
||||
<InboxSidebar
|
||||
open={inbox.isOpen}
|
||||
onOpenChange={inbox.onOpenChange}
|
||||
inboxItems={inbox.items}
|
||||
unreadCount={inbox.unreadCount}
|
||||
loading={inbox.loading}
|
||||
loadingMore={inbox.loadingMore}
|
||||
hasMore={inbox.hasMore}
|
||||
loadMore={inbox.loadMore}
|
||||
markAsRead={inbox.markAsRead}
|
||||
markAllAsRead={inbox.markAllAsRead}
|
||||
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{/* Mobile Inbox Sidebar - only render when open to avoid scroll blocking */}
|
||||
{inbox?.isOpen && (
|
||||
<InboxSidebar
|
||||
open={inbox.isOpen}
|
||||
onOpenChange={inbox.onOpenChange}
|
||||
mentions={inbox.mentions}
|
||||
status={inbox.status}
|
||||
totalUnreadCount={inbox.totalUnreadCount}
|
||||
markAsRead={inbox.markAsRead}
|
||||
markAllAsRead={inbox.markAllAsRead}
|
||||
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarProvider>
|
||||
|
|
@ -181,7 +188,9 @@ export function LayoutShell({
|
|||
return (
|
||||
<SidebarProvider value={sidebarContextValue}>
|
||||
<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">
|
||||
<IconRail
|
||||
searchSpaces={searchSpaces}
|
||||
|
|
@ -226,12 +235,9 @@ export function LayoutShell({
|
|||
<InboxSidebar
|
||||
open={inbox.isOpen}
|
||||
onOpenChange={inbox.onOpenChange}
|
||||
inboxItems={inbox.items}
|
||||
unreadCount={inbox.unreadCount}
|
||||
loading={inbox.loading}
|
||||
loadingMore={inbox.loadingMore}
|
||||
hasMore={inbox.hasMore}
|
||||
loadMore={inbox.loadMore}
|
||||
mentions={inbox.mentions}
|
||||
status={inbox.status}
|
||||
totalUnreadCount={inbox.totalUnreadCount}
|
||||
markAsRead={inbox.markAsRead}
|
||||
markAllAsRead={inbox.markAllAsRead}
|
||||
isDocked={inbox.isDocked}
|
||||
|
|
@ -252,12 +258,9 @@ export function LayoutShell({
|
|||
<InboxSidebar
|
||||
open={inbox.isOpen}
|
||||
onOpenChange={inbox.onOpenChange}
|
||||
inboxItems={inbox.items}
|
||||
unreadCount={inbox.unreadCount}
|
||||
loading={inbox.loading}
|
||||
loadingMore={inbox.loadingMore}
|
||||
hasMore={inbox.hasMore}
|
||||
loadMore={inbox.loadMore}
|
||||
mentions={inbox.mentions}
|
||||
status={inbox.status}
|
||||
totalUnreadCount={inbox.totalUnreadCount}
|
||||
markAsRead={inbox.markAsRead}
|
||||
markAllAsRead={inbox.markAllAsRead}
|
||||
isDocked={false}
|
||||
|
|
|
|||
|
|
@ -44,12 +44,7 @@ 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 {
|
||||
type ConnectorIndexingMetadata,
|
||||
isConnectorIndexingMetadata,
|
||||
isNewMentionMetadata,
|
||||
type NewMentionMetadata,
|
||||
} from "@/contracts/types/inbox.types";
|
||||
import { isConnectorIndexingMetadata, isNewMentionMetadata } from "@/contracts/types/inbox.types";
|
||||
import type { InboxItem } from "@/hooks/use-inbox";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -136,15 +131,25 @@ function getConnectorTypeDisplayName(connectorType: string): string {
|
|||
type InboxTab = "mentions" | "status";
|
||||
type InboxFilter = "all" | "unread";
|
||||
|
||||
interface InboxSidebarProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
inboxItems: InboxItem[];
|
||||
// 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;
|
||||
|
|
@ -157,12 +162,9 @@ interface InboxSidebarProps {
|
|||
export function InboxSidebar({
|
||||
open,
|
||||
onOpenChange,
|
||||
inboxItems,
|
||||
unreadCount,
|
||||
loading,
|
||||
loadingMore = false,
|
||||
hasMore = false,
|
||||
loadMore,
|
||||
mentions,
|
||||
status,
|
||||
totalUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
onCloseMobileSidebar,
|
||||
|
|
@ -209,11 +211,11 @@ export function InboxSidebar({
|
|||
// 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;
|
||||
};
|
||||
|
|
@ -226,18 +228,18 @@ export function InboxSidebar({
|
|||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Split items by type
|
||||
const mentionItems = useMemo(
|
||||
() => inboxItems.filter((item) => item.type === "new_mention"),
|
||||
[inboxItems]
|
||||
);
|
||||
// Get current tab's data source - each tab has independent data and pagination
|
||||
const currentDataSource = activeTab === "mentions" ? mentions : status;
|
||||
const { loading, loadingMore = false, hasMore = false, loadMore } = currentDataSource;
|
||||
|
||||
// For status items, filter to only show status notification types
|
||||
// (the status data source may include all types from API)
|
||||
const statusItems = useMemo(
|
||||
() =>
|
||||
inboxItems.filter(
|
||||
status.items.filter(
|
||||
(item) => item.type === "connector_indexing" || item.type === "document_processing"
|
||||
),
|
||||
[inboxItems]
|
||||
[status.items]
|
||||
);
|
||||
|
||||
// Get unique connector types from status items for filtering
|
||||
|
|
@ -259,12 +261,12 @@ export function InboxSidebar({
|
|||
}));
|
||||
}, [statusItems]);
|
||||
|
||||
// Get items for current tab
|
||||
const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems;
|
||||
// Get items for current tab - mentions use their source directly, status uses filtered items
|
||||
const displayItems = activeTab === "mentions" ? mentions.items : statusItems;
|
||||
|
||||
// Filter items based on filter type, connector filter, and search query
|
||||
const filteredItems = useMemo(() => {
|
||||
let items = currentTabItems;
|
||||
let items = displayItems;
|
||||
|
||||
// Apply read/unread filter
|
||||
if (activeFilter === "unread") {
|
||||
|
|
@ -295,7 +297,7 @@ export function InboxSidebar({
|
|||
}
|
||||
|
||||
return items;
|
||||
}, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]);
|
||||
}, [displayItems, activeFilter, activeTab, selectedConnector, searchQuery]);
|
||||
|
||||
// Intersection Observer for infinite scroll with prefetching
|
||||
// Only active when not searching (search results are client-side filtered)
|
||||
|
|
@ -321,16 +323,11 @@ export function InboxSidebar({
|
|||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, hasMore, loadingMore, open, searchQuery, filteredItems.length]);
|
||||
}, [loadMore, hasMore, loadingMore, open, searchQuery]);
|
||||
|
||||
// Count unread items per tab
|
||||
const unreadMentionsCount = useMemo(() => {
|
||||
return mentionItems.filter((item) => !item.read).length;
|
||||
}, [mentionItems]);
|
||||
|
||||
const unreadStatusCount = useMemo(() => {
|
||||
return statusItems.filter((item) => !item.read).length;
|
||||
}, [statusItems]);
|
||||
// Use unread counts from data sources (more accurate than client-side counting)
|
||||
const unreadMentionsCount = mentions.unreadCount;
|
||||
const unreadStatusCount = status.unreadCount;
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
async (item: InboxItem) => {
|
||||
|
|
@ -481,209 +478,128 @@ export function InboxSidebar({
|
|||
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">
|
||||
<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 ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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>
|
||||
</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)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<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 ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => setFilterDrawerOpen(true)}
|
||||
>
|
||||
<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", activeTab === "status" ? "w-52" : "w-44")}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
|
||||
<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"}
|
||||
</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>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setSelectedConnector(null)}
|
||||
className="flex items-center justify-between"
|
||||
</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" />}
|
||||
</DropdownMenuItem>
|
||||
</button>
|
||||
{uniqueConnectorTypes.map((connector) => (
|
||||
<DropdownMenuItem
|
||||
<button
|
||||
key={connector.type}
|
||||
onClick={() => setSelectedConnector(connector.type)}
|
||||
className="flex items-center justify-between"
|
||||
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")}
|
||||
|
|
@ -692,240 +608,311 @@ export function InboxSidebar({
|
|||
{selectedConnector === connector.type && (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={unreadCount === 0}
|
||||
>
|
||||
<CheckCheck className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
|
||||
</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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">
|
||||
{t("mark_all_read") || "Mark all as read"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Close button - mobile only */}
|
||||
{isMobile && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={cn("z-80", 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>
|
||||
<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="sr-only">{t("close") || "Close"}</span>
|
||||
</Button>
|
||||
</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 className="flex items-center gap-2">
|
||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||
<span>{connector.displayName}</span>
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">
|
||||
{isDocked ? "Collapse panel" : "Expand panel"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{selectedConnector === connector.type && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</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 && (
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<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>
|
||||
{/* Close button - mobile only */}
|
||||
{isMobile && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
|
||||
onClick={handleClearSearch}
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
|
||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="sr-only">{t("close") || "Close"}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="z-80">
|
||||
{isDocked ? "Collapse panel" : "Expand panel"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as InboxTab)}
|
||||
className="shrink-0 mx-4"
|
||||
<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}
|
||||
>
|
||||
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||
<TabsTrigger
|
||||
value="mentions"
|
||||
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">
|
||||
<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>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
</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 if not searching)
|
||||
const isPrefetchTrigger =
|
||||
!searchQuery && hasMore && index === filteredItems.length - 5;
|
||||
<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="mentions"
|
||||
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">
|
||||
<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
|
||||
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"
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
</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 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>
|
||||
<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 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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Fallback trigger at the very end if less than 5 items and not searching */}
|
||||
{!searchQuery && filteredItems.length < 5 && hasMore && (
|
||||
<div ref={prefetchTriggerRef} className="h-1" />
|
||||
)}
|
||||
</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>
|
||||
) : searchQuery ? (
|
||||
<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 === "mentions" ? (
|
||||
<AtSign 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>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
{/* Fallback trigger at the very end if less than 5 items and not searching */}
|
||||
{!searchQuery && filteredItems.length < 5 && hasMore && (
|
||||
<div ref={prefetchTriggerRef} className="h-1" />
|
||||
)}
|
||||
</div>
|
||||
) : searchQuery ? (
|
||||
<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 === "mentions" ? (
|
||||
<AtSign 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>
|
||||
</>
|
||||
);
|
||||
|
|
@ -967,10 +954,7 @@ export function InboxSidebar({
|
|||
left: isMobile ? 0 : sidebarWidth,
|
||||
width: isMobile ? "100%" : 360,
|
||||
}}
|
||||
className={cn(
|
||||
"absolute z-10 overflow-hidden pointer-events-none",
|
||||
"inset-y-0"
|
||||
)}
|
||||
className={cn("absolute z-10 overflow-hidden pointer-events-none", "inset-y-0")}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ x: "-100%" }}
|
||||
|
|
|
|||
|
|
@ -197,7 +197,9 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
|
|||
<div className="min-w-0">
|
||||
<h3 className="truncate font-semibold text-foreground text-sm sm:text-base">{title}</h3>
|
||||
{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>
|
||||
|
||||
|
|
@ -243,7 +245,11 @@ export function Audio({ id, src, title, description, artwork, durationMs, classN
|
|||
{/* Volume control */}
|
||||
<div className="flex items-center gap-1 sm:gap-1.5">
|
||||
<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>
|
||||
{/* Custom volume bar - visually distinct from progress slider */}
|
||||
<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>
|
||||
|
||||
{/* 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" />
|
||||
Download
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -96,10 +96,14 @@ function PodcastGeneratingState({ title }: { title: string }) {
|
|||
<div className="absolute inset-1 animate-ping rounded-full bg-primary/20" />
|
||||
</div>
|
||||
<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">
|
||||
<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 className="mt-2 sm:mt-3">
|
||||
<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" />
|
||||
</div>
|
||||
<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.5 sm:mt-2 text-muted-foreground text-xs sm:text-sm">{error}</p>
|
||||
</div>
|
||||
|
|
@ -143,7 +149,9 @@ function AudioLoadingState({ title }: { title: string }) {
|
|||
<MicIcon className="size-6 sm:size-8 text-primary/50" />
|
||||
</div>
|
||||
<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">
|
||||
<Spinner size="sm" className="size-3 sm:size-4" />
|
||||
<span className="text-xs sm:text-sm">Loading audio...</span>
|
||||
|
|
|
|||
|
|
@ -318,9 +318,13 @@ export function useInbox(
|
|||
|
||||
try {
|
||||
// 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(
|
||||
searchSpaceId ?? undefined
|
||||
searchSpaceId ?? undefined,
|
||||
typeFilter ?? undefined
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
type GetNotificationsRequest,
|
||||
type GetNotificationsResponse,
|
||||
type GetUnreadCountResponse,
|
||||
type InboxItemTypeEnum,
|
||||
getNotificationsRequest,
|
||||
getNotificationsResponse,
|
||||
getUnreadCountResponse,
|
||||
|
|
@ -92,12 +93,20 @@ class NotificationsApiService {
|
|||
* Get unread notification count with split between total and recent
|
||||
* - total_unread: All unread notifications
|
||||
* - 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();
|
||||
if (searchSpaceId !== undefined) {
|
||||
params.append("search_space_id", String(searchSpaceId));
|
||||
}
|
||||
if (type) {
|
||||
params.append("type", type);
|
||||
}
|
||||
const queryString = params.toString();
|
||||
|
||||
return baseApiService.get(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue