diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py
index deee748d8..6bc945643 100644
--- a/surfsense_backend/app/routes/notifications_routes.py
+++ b/surfsense_backend/app/routes/notifications_routes.py
@@ -1,12 +1,15 @@
"""
Notifications API routes.
-These endpoints allow marking notifications as read.
-Electric SQL automatically syncs the changes to all connected clients.
+These endpoints allow marking notifications as read and fetching older notifications.
+Electric SQL automatically syncs the changes to all connected clients for recent items.
+For older items (beyond the sync window), use the list endpoint.
"""
-from fastapi import APIRouter, Depends, HTTPException, status
+from datetime import UTC, datetime, timedelta
+
+from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
-from sqlalchemy import select, update
+from sqlalchemy import desc, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Notification, User, get_async_session
@@ -14,6 +17,36 @@ from app.users import current_active_user
router = APIRouter(prefix="/notifications", tags=["notifications"])
+# Must match frontend SYNC_WINDOW_DAYS in use-inbox.ts
+SYNC_WINDOW_DAYS = 14
+
+
+class NotificationResponse(BaseModel):
+ """Response model for a single notification."""
+
+ id: int
+ user_id: str
+ search_space_id: int | None
+ type: str
+ title: str
+ message: str
+ read: bool
+ metadata: dict
+ created_at: str
+ updated_at: str | None
+
+ class Config:
+ from_attributes = True
+
+
+class NotificationListResponse(BaseModel):
+ """Response for listing notifications with pagination."""
+
+ items: list[NotificationResponse]
+ total: int
+ has_more: bool
+ next_offset: int | None
+
class MarkReadResponse(BaseModel):
"""Response for mark as read operations."""
@@ -30,6 +63,169 @@ class MarkAllReadResponse(BaseModel):
updated_count: int
+class UnreadCountResponse(BaseModel):
+ """Response for unread count with split between recent and older items."""
+
+ total_unread: int
+ recent_unread: int # Within SYNC_WINDOW_DAYS
+
+
+@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"),
+ user: User = Depends(current_active_user),
+ session: AsyncSession = Depends(get_async_session),
+) -> UnreadCountResponse:
+ """
+ Get the total unread notification count for the current user.
+
+ Returns both:
+ - total_unread: All unread notifications (for accurate badge count)
+ - recent_unread: Unread notifications within the sync window (last 14 days)
+
+ This allows the frontend to calculate:
+ - older_unread = total_unread - recent_unread (static until reconciliation)
+ - Display count = older_unread + live_recent_count (from Electric SQL)
+ """
+ # Calculate cutoff date for sync window
+ cutoff_date = datetime.now(UTC) - timedelta(days=SYNC_WINDOW_DAYS)
+
+ # Base filter for user's unread notifications
+ base_filter = [
+ Notification.user_id == user.id,
+ Notification.read == False, # noqa: E712
+ ]
+
+ # Add search space filter if provided (include null for global notifications)
+ if search_space_id is not None:
+ base_filter.append(
+ (Notification.search_space_id == search_space_id)
+ | (Notification.search_space_id.is_(None))
+ )
+
+ # Total unread count (all time)
+ total_query = select(func.count(Notification.id)).where(*base_filter)
+ total_result = await session.execute(total_query)
+ total_unread = total_result.scalar() or 0
+
+ # Recent unread count (within sync window)
+ recent_query = select(func.count(Notification.id)).where(
+ *base_filter,
+ Notification.created_at > cutoff_date,
+ )
+ recent_result = await session.execute(recent_query)
+ recent_unread = recent_result.scalar() or 0
+
+ return UnreadCountResponse(
+ total_unread=total_unread,
+ recent_unread=recent_unread,
+ )
+
+
+@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(
+ None, alias="type", description="Filter by notification type"
+ ),
+ before_date: str | None = Query(
+ None, description="Get notifications before this ISO date (for pagination)"
+ ),
+ limit: int = Query(50, ge=1, le=100, description="Number of items to return"),
+ offset: int = Query(0, ge=0, description="Number of items to skip"),
+ user: User = Depends(current_active_user),
+ session: AsyncSession = Depends(get_async_session),
+) -> NotificationListResponse:
+ """
+ List notifications for the current user with pagination.
+
+ This endpoint is used as a fallback for older notifications that are
+ outside the Electric SQL sync window (2 weeks).
+
+ Use `before_date` to paginate through older notifications efficiently.
+ """
+ # Build base query
+ query = select(Notification).where(Notification.user_id == user.id)
+ count_query = select(func.count(Notification.id)).where(
+ Notification.user_id == user.id
+ )
+
+ # Filter by search space (include null search_space_id for global notifications)
+ if search_space_id is not None:
+ query = query.where(
+ (Notification.search_space_id == search_space_id)
+ | (Notification.search_space_id.is_(None))
+ )
+ count_query = count_query.where(
+ (Notification.search_space_id == search_space_id)
+ | (Notification.search_space_id.is_(None))
+ )
+
+ # Filter by type
+ if type_filter:
+ query = query.where(Notification.type == type_filter)
+ count_query = count_query.where(Notification.type == type_filter)
+
+ # Filter by date (for efficient pagination of older items)
+ if before_date:
+ try:
+ before_datetime = datetime.fromisoformat(before_date.replace("Z", "+00:00"))
+ query = query.where(Notification.created_at < before_datetime)
+ count_query = count_query.where(Notification.created_at < before_datetime)
+ except ValueError:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid date format. Use ISO format (e.g., 2024-01-15T00:00:00Z)",
+ ) from None
+
+ # Get total count
+ total_result = await session.execute(count_query)
+ total = total_result.scalar() or 0
+
+ # Apply ordering and pagination
+ query = (
+ query.order_by(desc(Notification.created_at)).offset(offset).limit(limit + 1)
+ )
+
+ # Execute query
+ result = await session.execute(query)
+ notifications = result.scalars().all()
+
+ # Check if there are more items
+ has_more = len(notifications) > limit
+ if has_more:
+ notifications = notifications[:limit]
+
+ # Convert to response format
+ items = []
+ for notification in notifications:
+ items.append(
+ NotificationResponse(
+ id=notification.id,
+ user_id=str(notification.user_id),
+ search_space_id=notification.search_space_id,
+ type=notification.type,
+ title=notification.title,
+ message=notification.message,
+ read=notification.read,
+ metadata=notification.notification_metadata or {},
+ created_at=notification.created_at.isoformat()
+ if notification.created_at
+ else "",
+ updated_at=notification.updated_at.isoformat()
+ if notification.updated_at
+ else None,
+ )
+ )
+
+ return NotificationListResponse(
+ items=items,
+ total=total,
+ has_more=has_more,
+ next_offset=offset + limit if has_more else None,
+ )
+
+
@router.patch("/{notification_id}/read", response_model=MarkReadResponse)
async def mark_notification_as_read(
notification_id: int,
diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
index c20436a5e..88c532cf2 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx
@@ -368,7 +368,7 @@ export default function NewChatPage() {
initializeThread();
}, [initializeThread]);
- // Handle scroll to comment from URL query params (e.g., from notification click)
+ // Handle scroll to comment from URL query params (e.g., from inbox item click)
const searchParams = useSearchParams();
const targetCommentId = searchParams.get("commentId");
diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
index 6701342de..b661e9222 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
@@ -3,29 +3,38 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import {
+ Bot,
Calendar,
Check,
Clock,
Copy,
Crown,
Edit2,
+ FileText,
Hash,
Link2,
LinkIcon,
Loader2,
+ Logs,
+ type LucideIcon,
+ MessageCircle,
+ MessageSquare,
+ Mic,
MoreHorizontal,
+ Plug,
Plus,
RefreshCw,
Search,
+ Settings,
Shield,
ShieldCheck,
Trash2,
- User,
UserMinus,
UserPlus,
Users,
} from "lucide-react";
import { motion } from "motion/react";
+import Image from "next/image";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -512,6 +521,25 @@ export default function TeamManagementPage() {
// ============ Members Tab ============
+// Helper function to get avatar initials
+function getAvatarInitials(member: Membership): string {
+ // Try display name first
+ if (member.user_display_name) {
+ const parts = member.user_display_name.trim().split(/\s+/);
+ if (parts.length >= 2) {
+ return (parts[0][0] + parts[1][0]).toUpperCase();
+ }
+ return member.user_display_name.slice(0, 2).toUpperCase();
+ }
+ // Try email
+ if (member.user_email) {
+ const emailName = member.user_email.split("@")[0];
+ return emailName.slice(0, 2).toUpperCase();
+ }
+ // Fallback
+ return "U";
+}
+
function MembersTab({
members,
roles,
@@ -560,7 +588,7 @@ function MembersTab({
setSearchQuery(e.target.value)}
className="pl-9"
@@ -573,10 +601,30 @@ function MembersTab({
- Member
- Role
- Joined
- Actions
+
+
+
+ Member
+
+
+
+
+
+ Role
+
+
+
+
+
+ Joined
+
+
+
+
+
+ Actions
+
+
@@ -601,19 +649,36 @@ function MembersTab({
-
-
-
+ {member.user_avatar_url ? (
+
+ ) : (
+
+
+ {getAvatarInitials(member)}
+
+
+ )}
{member.is_owner && (
-
- {member.user_email || "Unknown"}
+ {member.user_display_name || member.user_email || "Unknown"}
+ {member.user_display_name && member.user_email && (
+
+ {member.user_email}
+
+ )}
{member.is_owner && (
No role
{roles.map((role) => (
-
-
- {role.name}
-
+ {role.name}
))}
) : (
-
-
+
{member.role?.name || "No role"}
)}
-
-
+
{new Date(member.joined_at).toLocaleDateString()}
-
+
{canRemove && !member.is_owner && (
@@ -708,13 +765,137 @@ function MembersTab({
);
}
+// ============ Role Permissions Display ============
+
+const CATEGORY_CONFIG: Record = {
+ documents: { label: "Documents", icon: FileText, order: 1 },
+ chats: { label: "Chats", icon: MessageSquare, order: 2 },
+ comments: { label: "Comments", icon: MessageCircle, order: 3 },
+ llm_configs: { label: "LLM Configs", icon: Bot, order: 4 },
+ podcasts: { label: "Podcasts", icon: Mic, order: 5 },
+ connectors: { label: "Connectors", icon: Plug, order: 6 },
+ logs: { label: "Logs", icon: Logs, order: 7 },
+ members: { label: "Members", icon: Users, order: 8 },
+ roles: { label: "Roles", icon: Shield, order: 9 },
+ settings: { label: "Settings", icon: Settings, order: 10 },
+};
+
+const ACTION_LABELS: Record = {
+ create: "Create",
+ read: "Read",
+ update: "Update",
+ delete: "Delete",
+ invite: "Invite",
+ view: "View",
+ remove: "Remove",
+ manage_roles: "Manage Roles",
+};
+
+function RolePermissionsDisplay({ permissions }: { permissions: string[] }) {
+ if (permissions.includes("*")) {
+ return (
+
+
+
+
+
+
Full Access
+
All permissions granted
+
+
+ );
+ }
+
+ // Group permissions by category
+ const grouped: Record = {};
+ for (const perm of permissions) {
+ const [category, action] = perm.split(":");
+ if (!grouped[category]) grouped[category] = [];
+ grouped[category].push(action);
+ }
+
+ // Sort categories by predefined order
+ const sortedCategories = Object.keys(grouped).sort((a, b) => {
+ const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
+ const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
+ return orderA - orderB;
+ });
+
+ const categoryCount = sortedCategories.length;
+
+ return (
+
+
+
+
+
+
+
+
+
{permissions.length} Permissions
+
+ Across {categoryCount} {categoryCount === 1 ? "category" : "categories"}
+
+
+
+ View details
+
+
+
+
+
+
+ Role Permissions
+
+
+ {permissions.length} permissions across {categoryCount} categories
+
+
+
+
+ {sortedCategories.map((category) => {
+ const actions = grouped[category];
+ const config = CATEGORY_CONFIG[category] || { label: category, icon: FileText };
+ const IconComponent = config.icon;
+ return (
+
+
+
+ {config.label}
+
+
+ {actions.map((action) => (
+
+ {ACTION_LABELS[action] || action.replace(/_/g, " ")}
+
+ ))}
+
+
+ );
+ })}
+
+
+
+
+ );
+}
+
// ============ Roles Tab ============
function RolesTab({
roles,
- groupedPermissions,
+ groupedPermissions: _groupedPermissions,
loading,
- onUpdateRole,
+ onUpdateRole: _onUpdateRole,
onDeleteRole,
canUpdate,
canDelete,
@@ -852,32 +1033,7 @@ function RolesTab({
)}
-
-
- Permissions ({role.permissions.includes("*") ? "All" : role.permissions.length})
-
-
- {role.permissions.includes("*") ? (
-
- Full Access
-
- ) : (
- role.permissions.slice(0, 5).map((perm) => (
-
- {perm.replace(":", " ")}
-
- ))
- )}
- {!role.permissions.includes("*") && role.permissions.length > 5 && (
-
- +{role.permissions.length - 5} more
-
- )}
-
-
+
@@ -1500,7 +1656,11 @@ function CreateRoleDialog({
return (
-
+ toggleCategory(category)}
+ >
toggleCategory(category)}
@@ -1508,19 +1668,21 @@ function CreateRoleDialog({
{category} ({categorySelected}/{perms.length})
-
+
{perms.map((perm) => (
- togglePermission(perm.value)}
>
togglePermission(perm.value)}
/>
{perm.value.split(":")[1]}
-
+
))}
diff --git a/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx b/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx
index 0def32932..c72c77f65 100644
--- a/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx
+++ b/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx
@@ -69,7 +69,7 @@ export function CommentPanel({
style={!isMobile && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined}
>
{hasThreads && (
-
+
{threads.map((thread) => (
)}
-
+
+
+
+
+
+
+ Comments
+ {commentCount > 0 && (
+
+ {commentCount}
+
+ )}
+
+
+
+
+
+
+
+ );
+ }
+
+ // Use Sheet for medium screens (right side)
return (
- {/* Drag handle indicator - only for bottom sheet */}
- {isBottomSheet && (
-
- )}
-
+
Comments
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index 489fde3d7..5f4617b84 100644
--- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
@@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
-import { LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react";
+import { Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
@@ -19,6 +19,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
+import { useInbox } from "@/hooks/use-inbox";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
import { cleanupElectric } from "@/lib/electric/client";
@@ -29,6 +30,7 @@ import { CreateSearchSpaceDialog } from "../ui/dialogs";
import { LayoutShell } from "../ui/shell";
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
+import { InboxSidebar } from "../ui/sidebar/InboxSidebar";
interface LayoutDataProviderProps {
searchSpaceId: string;
@@ -59,8 +61,8 @@ export function LayoutDataProvider({
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
: null;
- // Fetch current search space
- const { data: searchSpace } = useQuery({
+ // Fetch current search space (for caching purposes)
+ useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
enabled: !!searchSpaceId,
@@ -77,9 +79,25 @@ export function LayoutDataProvider({
const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false);
const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false);
+ // Inbox sidebar state
+ const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
+
// Search space dialog state
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
+ // Inbox hook
+ const userId = user?.id ? String(user.id) : null;
+ const {
+ inboxItems,
+ unreadCount,
+ loading: inboxLoading,
+ loadingMore: inboxLoadingMore,
+ hasMore: inboxHasMore,
+ loadMore: inboxLoadMore,
+ markAsRead,
+ markAllAsRead,
+ } = useInbox(userId, Number(searchSpaceId) || null, null);
+
// Delete dialogs state
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
@@ -149,14 +167,21 @@ export function LayoutDataProvider({
icon: SquareLibrary,
isActive: pathname?.includes("/documents"),
},
+ // {
+ // title: "Logs",
+ // url: `/dashboard/${searchSpaceId}/logs`,
+ // icon: Logs,
+ // isActive: pathname?.includes("/logs"),
+ // },
{
- title: "Logs",
- url: `/dashboard/${searchSpaceId}/logs`,
- icon: Logs,
- isActive: pathname?.includes("/logs"),
+ title: "Inbox",
+ url: "#inbox", // Special URL to indicate this is handled differently
+ icon: Inbox,
+ isActive: isInboxSidebarOpen,
+ badge: unreadCount > 0 ? (unreadCount > 99 ? "99+" : unreadCount) : undefined,
},
],
- [searchSpaceId, pathname]
+ [searchSpaceId, pathname, isInboxSidebarOpen, unreadCount]
);
// Handlers
@@ -248,6 +273,11 @@ export function LayoutDataProvider({
const handleNavItemClick = useCallback(
(item: NavItem) => {
+ // Handle inbox specially - open sidebar instead of navigating
+ if (item.url === "#inbox") {
+ setIsInboxSidebarOpen(true);
+ return;
+ }
router.push(item.url);
},
[router]
@@ -517,6 +547,20 @@ export function LayoutDataProvider({
searchSpaceId={searchSpaceId}
/>
+ {/* Inbox Sidebar */}
+
+
{/* Create Search Space Dialog */}
- {/* Notifications */}
-
{/* Share button - only show on chat pages when thread exists */}
{hasThread && (
diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
index f5c64cc67..39f1b95bc 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
@@ -28,6 +28,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import {
@@ -237,20 +238,9 @@ export function AllPrivateChatsSidebar({
aria-label={t("chats") || "Private Chats"}
>
-
-
-
-
{t("chats") || "Private Chats"}
-
-
onOpenChange(false)}
- >
-
- Close
-
+
+
+
{t("chats") || "Private Chats"}
@@ -277,32 +267,38 @@ export function AllPrivateChatsSidebar({
{!isSearchMode && (
-
- setShowArchived(false)}
- className={cn(
- "flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
- !showArchived
- ? "border-b-2 border-primary text-primary"
- : "text-muted-foreground hover:text-foreground"
- )}
- >
- Active ({activeCount})
-
- setShowArchived(true)}
- className={cn(
- "flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
- showArchived
- ? "border-b-2 border-primary text-primary"
- : "text-muted-foreground hover:text-foreground"
- )}
- >
- Archived ({archivedCount})
-
-
+
setShowArchived(value === "archived")}
+ className="shrink-0 mx-4"
+ >
+
+
+
+
+ Active
+
+ {activeCount}
+
+
+
+
+
+
+ Archived
+
+ {archivedCount}
+
+
+
+
+
)}
@@ -371,7 +367,7 @@ export function AllPrivateChatsSidebar({
{isDeleting ? (
) : (
-
+
)}
{t("more_options") || "More options"}
diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
index f50cb028a..8dd593945 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
@@ -28,6 +28,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import {
@@ -237,20 +238,9 @@ export function AllSharedChatsSidebar({
aria-label={t("shared_chats") || "Shared Chats"}
>
-
-
-
-
{t("shared_chats") || "Shared Chats"}
-
-
onOpenChange(false)}
- >
-
- Close
-
+
+
+
{t("shared_chats") || "Shared Chats"}
@@ -277,32 +267,38 @@ export function AllSharedChatsSidebar({
{!isSearchMode && (
-
- setShowArchived(false)}
- className={cn(
- "flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
- !showArchived
- ? "border-b-2 border-primary text-primary"
- : "text-muted-foreground hover:text-foreground"
- )}
- >
- Active ({activeCount})
-
- setShowArchived(true)}
- className={cn(
- "flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
- showArchived
- ? "border-b-2 border-primary text-primary"
- : "text-muted-foreground hover:text-foreground"
- )}
- >
- Archived ({archivedCount})
-
-
+
setShowArchived(value === "archived")}
+ className="shrink-0 mx-4"
+ >
+
+
+
+
+ Active
+
+ {activeCount}
+
+
+
+
+
+
+ Archived
+
+ {archivedCount}
+
+
+
+
+
)}
@@ -371,7 +367,7 @@ export function AllSharedChatsSidebar({
{isDeleting ? (
) : (
-
+
)}
{t("more_options") || "More options"}
diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
index 7f5ede04c..5dd9c2cfa 100644
--- a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
@@ -39,11 +39,11 @@ export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItem
{/* Actions dropdown */}
-
+
-
+
{t("more_options")}
diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
new file mode 100644
index 000000000..166d77eca
--- /dev/null
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -0,0 +1,854 @@
+"use client";
+
+import {
+ AlertCircle,
+ AtSign,
+ BellDot,
+ Check,
+ CheckCheck,
+ CheckCircle2,
+ History,
+ Inbox,
+ LayoutGrid,
+ ListFilter,
+ Search,
+ X,
+} from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import {
+ Drawer,
+ DrawerContent,
+ DrawerHandle,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { 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 { InboxItem } from "@/hooks/use-inbox";
+import { useMediaQuery } from "@/hooks/use-media-query";
+import {
+ type ConnectorIndexingMetadata,
+ type NewMentionMetadata,
+ isConnectorIndexingMetadata,
+ isNewMentionMetadata,
+} from "@/contracts/types/inbox.types";
+import { cn } from "@/lib/utils";
+
+/**
+ * Get initials from name or email for avatar fallback
+ */
+function getInitials(name: string | null | undefined, email: string | null | undefined): string {
+ if (name) {
+ return name
+ .split(" ")
+ .map((n) => n[0])
+ .join("")
+ .toUpperCase()
+ .slice(0, 2);
+ }
+ if (email) {
+ const localPart = email.split("@")[0];
+ return localPart.slice(0, 2).toUpperCase();
+ }
+ return "U";
+}
+
+/**
+ * Get display name for connector type
+ */
+function getConnectorTypeDisplayName(connectorType: string): string {
+ const displayNames: Record = {
+ GITHUB_CONNECTOR: "GitHub",
+ GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
+ GOOGLE_GMAIL_CONNECTOR: "Gmail",
+ GOOGLE_DRIVE_CONNECTOR: "Google Drive",
+ LINEAR_CONNECTOR: "Linear",
+ NOTION_CONNECTOR: "Notion",
+ SLACK_CONNECTOR: "Slack",
+ TEAMS_CONNECTOR: "Microsoft Teams",
+ DISCORD_CONNECTOR: "Discord",
+ JIRA_CONNECTOR: "Jira",
+ CONFLUENCE_CONNECTOR: "Confluence",
+ BOOKSTACK_CONNECTOR: "BookStack",
+ CLICKUP_CONNECTOR: "ClickUp",
+ AIRTABLE_CONNECTOR: "Airtable",
+ LUMA_CONNECTOR: "Luma",
+ ELASTICSEARCH_CONNECTOR: "Elasticsearch",
+ WEBCRAWLER_CONNECTOR: "Web Crawler",
+ YOUTUBE_CONNECTOR: "YouTube",
+ CIRCLEBACK_CONNECTOR: "Circleback",
+ MCP_CONNECTOR: "MCP",
+ TAVILY_API: "Tavily",
+ SEARXNG_API: "SearXNG",
+ LINKUP_API: "Linkup",
+ BAIDU_SEARCH_API: "Baidu",
+ };
+
+ return (
+ displayNames[connectorType] ||
+ connectorType
+ .replace(/_/g, " ")
+ .replace(/CONNECTOR|API/gi, "")
+ .trim()
+ );
+}
+
+type InboxTab = "mentions" | "status";
+type InboxFilter = "all" | "unread";
+
+interface InboxSidebarProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ inboxItems: InboxItem[];
+ unreadCount: number;
+ loading: boolean;
+ loadingMore?: boolean;
+ hasMore?: boolean;
+ loadMore?: () => void;
+ markAsRead: (id: number) => Promise;
+ markAllAsRead: () => Promise;
+ onCloseMobileSidebar?: () => void;
+}
+
+export function InboxSidebar({
+ open,
+ onOpenChange,
+ inboxItems,
+ unreadCount,
+ loading,
+ loadingMore = false,
+ hasMore = false,
+ loadMore,
+ markAsRead,
+ markAllAsRead,
+ onCloseMobileSidebar,
+}: InboxSidebarProps) {
+ const t = useTranslations("sidebar");
+ const router = useRouter();
+ const isMobile = !useMediaQuery("(min-width: 640px)");
+
+ const [searchQuery, setSearchQuery] = useState("");
+ const [activeTab, setActiveTab] = useState("mentions");
+ const [activeFilter, setActiveFilter] = useState("all");
+ const [selectedConnector, setSelectedConnector] = useState(null);
+ const [mounted, setMounted] = useState(false);
+ // Dropdown state for filter menu (desktop only)
+ const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
+ // Drawer state for filter menu (mobile only)
+ const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
+ const [markingAsReadId, setMarkingAsReadId] = useState(null);
+
+ // Prefetch trigger ref - placed on item near the end
+ const prefetchTriggerRef = useRef(null);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && open) {
+ onOpenChange(false);
+ }
+ };
+ document.addEventListener("keydown", handleEscape);
+ return () => document.removeEventListener("keydown", handleEscape);
+ }, [open, onOpenChange]);
+
+ useEffect(() => {
+ if (open) {
+ document.body.style.overflow = "hidden";
+ } else {
+ document.body.style.overflow = "";
+ }
+ return () => {
+ document.body.style.overflow = "";
+ };
+ }, [open]);
+
+ // Reset connector filter when switching away from status tab
+ useEffect(() => {
+ if (activeTab !== "status") {
+ setSelectedConnector(null);
+ }
+ }, [activeTab]);
+
+ // Split items by type
+ const mentionItems = useMemo(
+ () => inboxItems.filter((item) => item.type === "new_mention"),
+ [inboxItems]
+ );
+
+ const statusItems = useMemo(
+ () =>
+ inboxItems.filter(
+ (item) => item.type === "connector_indexing" || item.type === "document_processing"
+ ),
+ [inboxItems]
+ );
+
+ // Get unique connector types from status items for filtering
+ const uniqueConnectorTypes = useMemo(() => {
+ const connectorTypes = new Set();
+
+ statusItems
+ .filter((item) => item.type === "connector_indexing")
+ .forEach((item) => {
+ // Use type guard for safe metadata access
+ if (isConnectorIndexingMetadata(item.metadata)) {
+ connectorTypes.add(item.metadata.connector_type);
+ }
+ });
+
+ return Array.from(connectorTypes).map((type) => ({
+ type,
+ displayName: getConnectorTypeDisplayName(type),
+ }));
+ }, [statusItems]);
+
+ // Get items for current tab
+ const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems;
+
+ // Filter items based on filter type, connector filter, and search query
+ const filteredItems = useMemo(() => {
+ let items = currentTabItems;
+
+ // Apply read/unread filter
+ if (activeFilter === "unread") {
+ items = items.filter((item) => !item.read);
+ }
+
+ // Apply connector filter (only for status tab)
+ if (activeTab === "status" && selectedConnector) {
+ items = items.filter((item) => {
+ if (item.type === "connector_indexing") {
+ // Use type guard for safe metadata access
+ if (isConnectorIndexingMetadata(item.metadata)) {
+ return item.metadata.connector_type === selectedConnector;
+ }
+ return false;
+ }
+ return false; // Hide document_processing when a specific connector is selected
+ });
+ }
+
+ // Apply search query
+ if (searchQuery.trim()) {
+ const query = searchQuery.toLowerCase();
+ items = items.filter(
+ (item) =>
+ item.title.toLowerCase().includes(query) || item.message.toLowerCase().includes(query)
+ );
+ }
+
+ return items;
+ }, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]);
+
+ // Intersection Observer for infinite scroll with prefetching
+ // Only active when not searching (search results are client-side filtered)
+ useEffect(() => {
+ if (!loadMore || !hasMore || loadingMore || !open || searchQuery.trim()) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ // When trigger element is visible, load more
+ if (entries[0]?.isIntersecting) {
+ loadMore();
+ }
+ },
+ {
+ root: null, // viewport
+ rootMargin: "100px", // Start loading 100px before visible
+ threshold: 0,
+ }
+ );
+
+ if (prefetchTriggerRef.current) {
+ observer.observe(prefetchTriggerRef.current);
+ }
+
+ return () => observer.disconnect();
+ }, [loadMore, hasMore, loadingMore, open, searchQuery, filteredItems.length]);
+
+ // 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]);
+
+ const handleItemClick = useCallback(
+ async (item: InboxItem) => {
+ if (!item.read) {
+ setMarkingAsReadId(item.id);
+ await markAsRead(item.id);
+ setMarkingAsReadId(null);
+ }
+
+ if (item.type === "new_mention") {
+ // Use type guard for safe metadata access
+ if (isNewMentionMetadata(item.metadata)) {
+ const searchSpaceId = item.search_space_id;
+ const threadId = item.metadata.thread_id;
+ const commentId = item.metadata.comment_id;
+
+ if (searchSpaceId && threadId) {
+ const url = commentId
+ ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
+ : `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
+ onOpenChange(false);
+ onCloseMobileSidebar?.();
+ router.push(url);
+ }
+ }
+ }
+ },
+ [markAsRead, router, onOpenChange, onCloseMobileSidebar]
+ );
+
+ const handleMarkAllAsRead = useCallback(async () => {
+ await markAllAsRead();
+ }, [markAllAsRead]);
+
+ const handleClearSearch = useCallback(() => {
+ setSearchQuery("");
+ }, []);
+
+ const formatTime = (dateString: string) => {
+ try {
+ const date = new Date(dateString);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffMins = Math.floor(diffMs / (1000 * 60));
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+ if (diffMins < 1) return "now";
+ if (diffMins < 60) return `${diffMins}m`;
+ if (diffHours < 24) return `${diffHours}h`;
+ if (diffDays < 7) return `${diffDays}d`;
+ return `${Math.floor(diffDays / 7)}w`;
+ } catch {
+ return "now";
+ }
+ };
+
+ const getStatusIcon = (item: InboxItem) => {
+ // For mentions, show the author's avatar with initials fallback
+ if (item.type === "new_mention") {
+ // Use type guard for safe metadata access
+ if (isNewMentionMetadata(item.metadata)) {
+ const authorName = item.metadata.author_name;
+ const avatarUrl = item.metadata.author_avatar_url;
+ const authorEmail = item.metadata.author_email;
+
+ return (
+
+ {avatarUrl && }
+
+ {getInitials(authorName, authorEmail)}
+
+
+ );
+ }
+ // Fallback for invalid metadata
+ return (
+
+
+ {getInitials(null, null)}
+
+
+ );
+ }
+
+ // For status items (connector/document), show status icons
+ // Safely access status from metadata
+ const metadata = item.metadata as Record;
+ const status = typeof metadata?.status === "string" ? metadata.status : undefined;
+
+ switch (status) {
+ case "in_progress":
+ return (
+
+
+
+ );
+ case "completed":
+ return (
+
+
+
+ );
+ case "failed":
+ return (
+
+ );
+ default:
+ return (
+
+
+
+ );
+ }
+ };
+
+ const getEmptyStateMessage = () => {
+ if (activeTab === "mentions") {
+ return {
+ title: t("no_mentions") || "No mentions",
+ hint: t("no_mentions_hint") || "You'll see mentions from others here",
+ };
+ }
+ return {
+ title: t("no_status_updates") || "No status updates",
+ hint: t("no_status_updates_hint") || "Document and connector updates will appear here",
+ };
+ };
+
+ if (!mounted) return null;
+
+ return createPortal(
+
+ {open && (
+ <>
+ onOpenChange(false)}
+ aria-hidden="true"
+ />
+
+
+
+
+
+
+
{t("inbox") || "Inbox"}
+
+
+ {/* Mobile: Button that opens bottom drawer */}
+ {isMobile ? (
+ <>
+
+
+ setFilterDrawerOpen(true)}
+ >
+
+ {t("filter") || "Filter"}
+
+
+ {t("filter") || "Filter"}
+
+
+
+
+
+
+
+ {t("filter") || "Filter"}
+
+
+
+ {/* Filter section */}
+
+
+ {t("filter") || "Filter"}
+
+
+ {
+ 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"
+ )}
+ >
+
+
+ {t("all") || "All"}
+
+ {activeFilter === "all" && }
+
+ {
+ 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"
+ )}
+ >
+
+
+ {t("unread") || "Unread"}
+
+ {activeFilter === "unread" && }
+
+
+
+ {/* Connectors section - only for status tab */}
+ {activeTab === "status" && uniqueConnectorTypes.length > 0 && (
+
+
+ {t("connectors") || "Connectors"}
+
+
+ {
+ 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"
+ )}
+ >
+
+
+ {t("all_connectors") || "All connectors"}
+
+ {selectedConnector === null && }
+
+ {uniqueConnectorTypes.map((connector) => (
+ {
+ 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"
+ )}
+ >
+
+ {getConnectorIcon(connector.type, "h-4 w-4")}
+ {connector.displayName}
+
+ {selectedConnector === connector.type && (
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+
+ >
+ ) : (
+ /* Desktop: Dropdown menu */
+
setOpenDropdown(isOpen ? "filter" : null)}
+ >
+
+
+
+
+
+ {t("filter") || "Filter"}
+
+
+
+ {t("filter") || "Filter"}
+
+
+
+ {t("filter") || "Filter"}
+
+ setActiveFilter("all")}
+ className="flex items-center justify-between"
+ >
+
+
+ {t("all") || "All"}
+
+ {activeFilter === "all" && }
+
+ setActiveFilter("unread")}
+ className="flex items-center justify-between"
+ >
+
+
+ {t("unread") || "Unread"}
+
+ {activeFilter === "unread" && }
+
+ {activeTab === "status" && uniqueConnectorTypes.length > 0 && (
+ <>
+
+ {t("connectors") || "Connectors"}
+
+ setSelectedConnector(null)}
+ className="flex items-center justify-between"
+ >
+
+
+ {t("all_connectors") || "All connectors"}
+
+ {selectedConnector === null && }
+
+ {uniqueConnectorTypes.map((connector) => (
+ setSelectedConnector(connector.type)}
+ className="flex items-center justify-between"
+ >
+
+ {getConnectorIcon(connector.type, "h-4 w-4")}
+ {connector.displayName}
+
+ {selectedConnector === connector.type && (
+
+ )}
+
+ ))}
+ >
+ )}
+
+
+ )}
+
+
+
+
+ {t("mark_all_read") || "Mark all as read"}
+
+
+
+ {t("mark_all_read") || "Mark all as read"}
+
+
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-9 pr-8 h-9"
+ />
+ {searchQuery && (
+
+
+ {t("clear_search") || "Clear search"}
+
+ )}
+
+
+
+ setActiveTab(value as InboxTab)}
+ className="shrink-0 mx-4"
+ >
+
+
+
+
+ {t("mentions") || "Mentions"}
+
+ {unreadMentionsCount}
+
+
+
+
+
+
+ {t("status") || "Status"}
+
+ {unreadStatusCount}
+
+
+
+
+
+
+
+ {loading ? (
+
+
+
+ ) : filteredItems.length > 0 ? (
+
+ {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 (
+
+
+
+ handleItemClick(item)}
+ disabled={isMarkingAsRead}
+ className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
+ >
+ {getStatusIcon(item)}
+
+
+ {item.title}
+
+
+ {convertRenderedToDisplay(item.message)}
+
+
+
+
+
+ {item.title}
+
+ {convertRenderedToDisplay(item.message)}
+
+
+
+
+ {/* Time and unread dot - fixed width to prevent content shift */}
+
+
+ {formatTime(item.created_at)}
+
+ {!item.read && (
+
+ )}
+
+
+ );
+ })}
+ {/* Fallback trigger at the very end if less than 5 items and not searching */}
+ {!searchQuery && filteredItems.length < 5 && hasMore && (
+
+ )}
+
+ ) : searchQuery ? (
+
+
+
+ {t("no_results_found") || "No results found"}
+
+
+ {t("try_different_search") || "Try a different search term"}
+
+
+ ) : (
+
+ {activeTab === "mentions" ? (
+
+ ) : (
+
+ )}
+
{getEmptyStateMessage().title}
+
+ {getEmptyStateMessage().hint}
+
+
+ )}
+
+
+ >
+ )}
+ ,
+ document.body
+ );
+}
diff --git a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx
index 7b694055b..d2d926de8 100644
--- a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx
@@ -30,7 +30,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
type="button"
onClick={() => onItemClick?.(item)}
className={cn(
- "flex h-10 w-10 items-center justify-center rounded-md transition-colors",
+ "relative flex h-10 w-10 items-center justify-center rounded-md transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
item.isActive && "bg-accent text-accent-foreground"
@@ -38,6 +38,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
{...joyrideAttr}
>
+ {item.badge && (
+
+ {item.badge}
+
+ )}
{item.title}
@@ -64,7 +69,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
>
{item.title}
- {item.badge && {item.badge} }
+ {item.badge && (
+
+ {item.badge}
+
+ )}
);
})}
diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx
index 52d681199..0ceafc113 100644
--- a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx
@@ -37,7 +37,7 @@ export function SidebarSection({
{/* Action button - visible on hover (always visible on mobile) */}
{action && (
-
+
{action}
)}
diff --git a/surfsense_web/components/layout/ui/sidebar/index.ts b/surfsense_web/components/layout/ui/sidebar/index.ts
index 282e4740b..d9c5edee5 100644
--- a/surfsense_web/components/layout/ui/sidebar/index.ts
+++ b/surfsense_web/components/layout/ui/sidebar/index.ts
@@ -1,6 +1,7 @@
export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
export { ChatListItem } from "./ChatListItem";
+export { InboxSidebar } from "./InboxSidebar";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
export { NavSection } from "./NavSection";
export { PageUsageDisplay } from "./PageUsageDisplay";
diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx
index 05a8a7306..fcace2572 100644
--- a/surfsense_web/components/new-chat/chat-share-button.tsx
+++ b/surfsense_web/components/new-chat/chat-share-button.tsx
@@ -2,7 +2,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
-import { Loader2, User, Users } from "lucide-react";
+import { User, Users } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
@@ -45,7 +45,6 @@ const visibilityOptions: {
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
- const [isUpdating, setIsUpdating] = useState(false);
// Use Jotai atom for visibility (single source of truth)
const currentThreadState = useAtomValue(currentThreadAtom);
@@ -62,7 +61,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
return;
}
- setIsUpdating(true);
// Update Jotai atom immediately for instant UI feedback
setThreadVisibility(newVisibility);
@@ -84,8 +82,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
// Revert Jotai state on error
setThreadVisibility(thread.visibility ?? "PRIVATE");
toast.error("Failed to update sharing settings");
- } finally {
- setIsUpdating(false);
}
},
[thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility]
@@ -128,16 +124,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
onCloseAutoFocus={(e) => e.preventDefault()}
>
- {/* Updating overlay */}
- {isUpdating && (
-
- )}
-
{visibilityOptions.map((option) => {
const isSelected = currentVisibility === option.value;
const Icon = option.icon;
@@ -147,7 +133,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
type="button"
key={option.value}
onClick={() => handleVisibilityChange(option.value)}
- disabled={isUpdating}
className={cn(
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
"hover:bg-accent/50 cursor-pointer",
diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx
index fba5e8cb1..af9378e34 100644
--- a/surfsense_web/components/new-chat/model-selector.tsx
+++ b/surfsense_web/components/new-chat/model-selector.tsx
@@ -72,7 +72,6 @@ interface ModelSelectorProps {
export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
- const [isSwitching, setIsSwitching] = useState(false);
// Fetch configs
const { data: userConfigs, isLoading: userConfigsLoading } = useAtomValue(newLLMConfigsAtom);
@@ -137,7 +136,6 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
return;
}
- setIsSwitching(true);
try {
await updatePreferences({
search_space_id: Number(searchSpaceId),
@@ -150,8 +148,6 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
} catch (error) {
console.error("Failed to switch model:", error);
toast.error("Failed to switch model");
- } finally {
- setIsSwitching(false);
}
},
[currentConfig, searchSpaceId, updatePreferences]
@@ -216,23 +212,12 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
shouldFilter={false}
className="rounded-lg relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
>
- {/* Switching overlay */}
- {isSwitching && (
-
-
-
- Switching model...
-
-
- )}
-
diff --git a/surfsense_web/components/notifications/NotificationButton.tsx b/surfsense_web/components/notifications/NotificationButton.tsx
deleted file mode 100644
index 020fea506..000000000
--- a/surfsense_web/components/notifications/NotificationButton.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-"use client";
-
-import { useAtomValue } from "jotai";
-import { Bell } from "lucide-react";
-import { useParams } from "next/navigation";
-import { useCallback, useEffect, useState } from "react";
-import { currentUserAtom } from "@/atoms/user/user-query.atoms";
-import { Button } from "@/components/ui/button";
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { useNotifications, type NotificationTypeEnum } from "@/hooks/use-notifications";
-import { cn } from "@/lib/utils";
-import { NotificationPopup } from "./NotificationPopup";
-
-const NOTIFICATION_FILTER_STORAGE_KEY = "surfsense_notification_filter";
-
-export function NotificationButton() {
- const [open, setOpen] = useState(false);
- const { data: user } = useAtomValue(currentUserAtom);
- const params = useParams();
-
- // Filter state - null means show all, otherwise filter by type
- const [activeFilter, setActiveFilter] = useState
(null);
-
- // Load filter from localStorage on mount
- useEffect(() => {
- try {
- const stored = localStorage.getItem(NOTIFICATION_FILTER_STORAGE_KEY);
- if (stored) {
- const parsed = JSON.parse(stored);
- if (
- parsed === null ||
- ["new_mention", "connector_indexing", "document_processing"].includes(parsed)
- ) {
- setActiveFilter(parsed);
- }
- }
- } catch {
- // Ignore localStorage errors
- }
- }, []);
-
- // Handle filter toggle - clicking same pill again shows all
- const handleFilterChange = useCallback((filter: NotificationTypeEnum | null) => {
- setActiveFilter((current) => {
- const newFilter = current === filter ? null : filter;
- try {
- localStorage.setItem(NOTIFICATION_FILTER_STORAGE_KEY, JSON.stringify(newFilter));
- } catch {
- // Ignore localStorage errors
- }
- return newFilter;
- });
- }, []);
-
- const userId = user?.id ? String(user.id) : null;
- // Get searchSpaceId from URL params - the component is rendered within /dashboard/[search_space_id]/
- const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
-
- const { notifications, unreadCount, loading, markAsRead, markAllAsRead } = useNotifications(
- userId,
- searchSpaceId,
- activeFilter
- );
-
- return (
-
-
-
-
-
-
- {unreadCount > 0 && (
- 9 && "px-1"
- )}
- >
- {unreadCount > 99 ? "99+" : unreadCount}
-
- )}
- Notifications
-
-
-
- Notifications
-
-
- setOpen(false)}
- activeFilter={activeFilter}
- onFilterChange={handleFilterChange}
- />
-
-
- );
-}
diff --git a/surfsense_web/components/notifications/NotificationPopup.tsx b/surfsense_web/components/notifications/NotificationPopup.tsx
deleted file mode 100644
index fbb756a00..000000000
--- a/surfsense_web/components/notifications/NotificationPopup.tsx
+++ /dev/null
@@ -1,246 +0,0 @@
-"use client";
-
-import { formatDistanceToNow } from "date-fns";
-import {
- AlertCircle,
- AtSign,
- Bell,
- Cable,
- CheckCheck,
- CheckCircle2,
- FileText,
- Loader2,
-} from "lucide-react";
-import { useRouter } from "next/navigation";
-import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { Button } from "@/components/ui/button";
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Separator } from "@/components/ui/separator";
-import type { Notification, NotificationTypeEnum } from "@/hooks/use-notifications";
-import { cn } from "@/lib/utils";
-
-/**
- * Filter configuration for notification types
- */
-const NOTIFICATION_FILTERS = {
- new_mention: { label: "Mentions", icon: AtSign },
- connector_indexing: { label: "Connectors", icon: Cable },
- document_processing: { label: "Documents", icon: FileText },
-} as const;
-
-/**
- * Get initials from name or email for avatar fallback
- */
-function getInitials(name: string | null | undefined, email: string | null | undefined): string {
- if (name) {
- return name
- .split(" ")
- .map((n) => n[0])
- .join("")
- .toUpperCase()
- .slice(0, 2);
- }
- if (email) {
- const localPart = email.split("@")[0];
- return localPart.slice(0, 2).toUpperCase();
- }
- return "U";
-}
-
-interface NotificationPopupProps {
- notifications: Notification[];
- unreadCount: number;
- loading: boolean;
- markAsRead: (id: number) => Promise;
- markAllAsRead: () => Promise;
- onClose?: () => void;
- activeFilter: NotificationTypeEnum | null;
- onFilterChange: (filter: NotificationTypeEnum | null) => void;
-}
-
-export function NotificationPopup({
- notifications,
- unreadCount,
- loading,
- markAsRead,
- markAllAsRead,
- onClose,
- activeFilter,
- onFilterChange,
-}: NotificationPopupProps) {
- const router = useRouter();
-
- const handleMarkAllAsRead = async () => {
- await markAllAsRead();
- };
-
- const handleNotificationClick = async (notification: Notification) => {
- if (!notification.read) {
- await markAsRead(notification.id);
- }
-
- if (notification.type === "new_mention") {
- const metadata = notification.metadata as {
- thread_id?: number;
- comment_id?: number;
- };
- const searchSpaceId = notification.search_space_id;
- const threadId = metadata?.thread_id;
- const commentId = metadata?.comment_id;
-
- if (searchSpaceId && threadId) {
- const url = commentId
- ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
- : `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
- onClose?.();
- router.push(url);
- }
- }
- };
-
- const formatTime = (dateString: string) => {
- try {
- return formatDistanceToNow(new Date(dateString), { addSuffix: true });
- } catch {
- return "Recently";
- }
- };
-
- const getStatusIcon = (notification: Notification) => {
- // For mentions, show the author's avatar with initials fallback
- if (notification.type === "new_mention") {
- const metadata = notification.metadata as {
- author_name?: string;
- author_avatar_url?: string | null;
- author_email?: string;
- };
- const authorName = metadata?.author_name;
- const avatarUrl = metadata?.author_avatar_url;
- const authorEmail = metadata?.author_email;
-
- return (
-
- {avatarUrl && }
-
- {getInitials(authorName, authorEmail)}
-
-
- );
- }
-
- // For other notification types, show status icons
- const status = notification.metadata?.status as string | undefined;
-
- switch (status) {
- case "in_progress":
- return ;
- case "completed":
- return ;
- case "failed":
- return ;
- default:
- return ;
- }
- };
-
- return (
-
- {/* Header */}
-
-
-
Notifications
-
- {unreadCount > 0 && (
-
-
- Mark all read
-
- )}
-
-
- {/* Filter Pills */}
-
- {(
- Object.entries(NOTIFICATION_FILTERS) as [
- NotificationTypeEnum,
- (typeof NOTIFICATION_FILTERS)[keyof typeof NOTIFICATION_FILTERS],
- ][]
- ).map(([key, { label, icon: Icon }]) => {
- const isActive = activeFilter === key;
- return (
- onFilterChange(key)}
- className={cn(
- "inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-medium transition-colors whitespace-nowrap",
- "border focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
- isActive
- ? "bg-primary text-primary-foreground border-primary"
- : "bg-transparent text-muted-foreground border-border hover:bg-accent hover:text-accent-foreground"
- )}
- >
-
- {label}
-
- );
- })}
-
-
- {/* Notifications List */}
-
- {loading ? (
-
-
-
- ) : notifications.length === 0 ? (
-
- ) : (
-
- {notifications.map((notification, index) => (
-
-
handleNotificationClick(notification)}
- className={cn(
- "w-full px-4 py-3 text-left hover:bg-accent transition-colors",
- !notification.read && "bg-accent/50"
- )}
- >
-
-
{getStatusIcon(notification)}
-
-
-
- {notification.title}
-
-
-
- {convertRenderedToDisplay(notification.message)}
-
-
-
- {formatTime(notification.created_at)}
-
-
-
-
-
- {index < notifications.length - 1 &&
}
-
- ))}
-
- )}
-
-
- );
-}
diff --git a/surfsense_web/components/shared/llm-config-form.tsx b/surfsense_web/components/shared/llm-config-form.tsx
index e2e45194b..5ffff1ab7 100644
--- a/surfsense_web/components/shared/llm-config-form.tsx
+++ b/surfsense_web/components/shared/llm-config-form.tsx
@@ -551,7 +551,9 @@ export function LLMConfigForm({
render={({ field }) => (
-
Enable Citations
+
+ Enable Citations
+
Include [citation:id] references to source documents
diff --git a/surfsense_web/components/ui/drawer.tsx b/surfsense_web/components/ui/drawer.tsx
new file mode 100644
index 000000000..015d6ac07
--- /dev/null
+++ b/surfsense_web/components/ui/drawer.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import * as React from "react";
+import { Drawer as DrawerPrimitive } from "vaul";
+
+import { cn } from "@/lib/utils";
+
+function Drawer({
+ shouldScaleBackground = true,
+ ...props
+}: React.ComponentProps
) {
+ return ;
+}
+Drawer.displayName = "Drawer";
+
+const DrawerTrigger = DrawerPrimitive.Trigger;
+
+const DrawerPortal = DrawerPrimitive.Portal;
+
+const DrawerClose = DrawerPrimitive.Close;
+
+function DrawerOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
+
+function DrawerContent({
+ className,
+ children,
+ overlayClassName,
+ ...props
+}: React.ComponentProps & {
+ overlayClassName?: string;
+}) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
+DrawerContent.displayName = "DrawerContent";
+
+function DrawerHeader({ className, ...props }: React.HTMLAttributes) {
+ return
;
+}
+DrawerHeader.displayName = "DrawerHeader";
+
+function DrawerFooter({ className, ...props }: React.HTMLAttributes) {
+ return
;
+}
+DrawerFooter.displayName = "DrawerFooter";
+
+function DrawerTitle({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
+
+function DrawerDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
+
+function DrawerHandle({ className, ...props }: React.HTMLAttributes) {
+ return (
+
+ );
+}
+DrawerHandle.displayName = "DrawerHandle";
+
+export {
+ Drawer,
+ DrawerPortal,
+ DrawerOverlay,
+ DrawerTrigger,
+ DrawerClose,
+ DrawerContent,
+ DrawerHeader,
+ DrawerFooter,
+ DrawerTitle,
+ DrawerDescription,
+ DrawerHandle,
+};
diff --git a/surfsense_web/components/ui/sheet.tsx b/surfsense_web/components/ui/sheet.tsx
index accd4f782..650e85403 100644
--- a/surfsense_web/components/ui/sheet.tsx
+++ b/surfsense_web/components/ui/sheet.tsx
@@ -42,13 +42,15 @@ function SheetContent({
className,
children,
side = "right",
+ overlayClassName,
...props
}: React.ComponentProps & {
side?: "top" | "right" | "bottom" | "left";
+ overlayClassName?: string;
}) {
return (
-
+
+ );
+}
diff --git a/surfsense_web/content/docs/how-to/electric-sql.mdx b/surfsense_web/content/docs/how-to/electric-sql.mdx
index 54244c19b..288745850 100644
--- a/surfsense_web/content/docs/how-to/electric-sql.mdx
+++ b/surfsense_web/content/docs/how-to/electric-sql.mdx
@@ -5,11 +5,11 @@ description: Setting up Electric SQL for real-time data synchronization in SurfS
# Electric SQL
-[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for notifications, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL.
+[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for inbox items, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL.
## What Does Electric SQL Do?
-When you index documents or receive notifications, Electric SQL pushes updates to your browser in real-time. The data flows like this:
+When you index documents or receive inbox updates, Electric SQL pushes updates to your browser in real-time. The data flows like this:
1. Backend writes data to PostgreSQL
2. Electric SQL detects changes and streams them to the frontend
@@ -18,7 +18,7 @@ When you index documents or receive notifications, Electric SQL pushes updates t
This means:
-- **Notifications appear instantly** - No need to refresh the page
+- **Inbox updates appear instantly** - No need to refresh the page
- **Document indexing progress updates live** - Watch your documents get processed
- **Connector status syncs automatically** - See when connectors finish syncing
- **Offline support** - PGlite caches data locally, so previously loaded data remains accessible
diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts
new file mode 100644
index 000000000..0983bbc55
--- /dev/null
+++ b/surfsense_web/contracts/types/inbox.types.ts
@@ -0,0 +1,281 @@
+import { z } from "zod";
+import { searchSourceConnectorTypeEnum } from "./connector.types";
+import { documentTypeEnum } from "./document.types";
+
+/**
+ * Inbox item type enum - matches backend notification types
+ */
+export const inboxItemTypeEnum = z.enum([
+ "connector_indexing",
+ "document_processing",
+ "new_mention",
+]);
+
+/**
+ * Inbox item status enum - used in metadata
+ */
+export const inboxItemStatusEnum = z.enum(["in_progress", "completed", "failed"]);
+
+/**
+ * Document processing stage enum
+ */
+export const documentProcessingStageEnum = z.enum([
+ "queued",
+ "parsing",
+ "chunking",
+ "embedding",
+ "storing",
+ "completed",
+ "failed",
+]);
+
+/**
+ * Base metadata schema shared across inbox item types
+ */
+export const baseInboxItemMetadata = z.object({
+ operation_id: z.string().optional(),
+ status: inboxItemStatusEnum.optional(),
+ started_at: z.string().optional(),
+ completed_at: z.string().optional(),
+});
+
+/**
+ * Connector indexing metadata schema
+ */
+export const connectorIndexingMetadata = baseInboxItemMetadata.extend({
+ connector_id: z.number(),
+ connector_name: z.string(),
+ connector_type: searchSourceConnectorTypeEnum,
+ start_date: z.string().nullable().optional(),
+ end_date: z.string().nullable().optional(),
+ indexed_count: z.number(),
+ total_count: z.number().optional(),
+ progress_percent: z.number().optional(),
+ error_message: z.string().nullable().optional(),
+ // Google Drive specific fields
+ folder_count: z.number().optional(),
+ file_count: z.number().optional(),
+ folder_names: z.array(z.string()).optional(),
+ file_names: z.array(z.string()).optional(),
+});
+
+/**
+ * Document processing metadata schema
+ */
+export const documentProcessingMetadata = baseInboxItemMetadata.extend({
+ document_type: documentTypeEnum,
+ document_name: z.string(),
+ processing_stage: documentProcessingStageEnum,
+ file_size: z.number().optional(),
+ chunks_count: z.number().optional(),
+ document_id: z.number().optional(),
+ error_message: z.string().nullable().optional(),
+});
+
+/**
+ * New mention metadata schema
+ */
+export const newMentionMetadata = z.object({
+ mention_id: z.number(),
+ comment_id: z.number(),
+ message_id: z.number(),
+ thread_id: z.number(),
+ thread_title: z.string(),
+ author_id: z.string(),
+ author_name: z.string(),
+ author_avatar_url: z.string().nullable().optional(),
+ author_email: z.string().optional(),
+ content_preview: z.string(),
+});
+
+/**
+ * Union of all inbox item metadata types
+ * Use this when the inbox item type is unknown
+ */
+export const inboxItemMetadata = z.union([
+ connectorIndexingMetadata,
+ documentProcessingMetadata,
+ newMentionMetadata,
+ baseInboxItemMetadata,
+]);
+
+/**
+ * Main inbox item schema
+ */
+export const inboxItem = z.object({
+ id: z.number(),
+ user_id: z.string(),
+ search_space_id: z.number().nullable(),
+ type: inboxItemTypeEnum,
+ title: z.string(),
+ message: z.string(),
+ read: z.boolean(),
+ metadata: z.record(z.string(), z.unknown()),
+ created_at: z.string(),
+ updated_at: z.string().nullable(),
+});
+
+/**
+ * Typed inbox item schemas for specific types
+ */
+export const connectorIndexingInboxItem = inboxItem.extend({
+ type: z.literal("connector_indexing"),
+ metadata: connectorIndexingMetadata,
+});
+
+export const documentProcessingInboxItem = inboxItem.extend({
+ type: z.literal("document_processing"),
+ metadata: documentProcessingMetadata,
+});
+
+export const newMentionInboxItem = inboxItem.extend({
+ type: z.literal("new_mention"),
+ metadata: newMentionMetadata,
+});
+
+// =============================================================================
+// API Request/Response Schemas
+// =============================================================================
+
+/**
+ * Request schema for getting notifications
+ */
+export const getNotificationsRequest = z.object({
+ queryParams: z.object({
+ search_space_id: z.number().optional(),
+ type: inboxItemTypeEnum.optional(),
+ before_date: z.string().optional(),
+ limit: z.number().min(1).max(100).optional(),
+ offset: z.number().min(0).optional(),
+ }),
+});
+
+/**
+ * Response schema for listing notifications
+ */
+export const getNotificationsResponse = z.object({
+ items: z.array(inboxItem),
+ total: z.number(),
+ has_more: z.boolean(),
+ next_offset: z.number().nullable(),
+});
+
+/**
+ * Request schema for marking a single notification as read
+ */
+export const markNotificationReadRequest = z.object({
+ notificationId: z.number(),
+});
+
+/**
+ * Response schema for mark as read operations
+ */
+export const markNotificationReadResponse = z.object({
+ success: z.boolean(),
+ message: z.string(),
+});
+
+/**
+ * Response schema for mark all as read operation
+ */
+export const markAllNotificationsReadResponse = z.object({
+ success: z.boolean(),
+ message: z.string(),
+ updated_count: z.number(),
+});
+
+/**
+ * Request schema for getting unread count
+ */
+export const getUnreadCountRequest = z.object({
+ search_space_id: z.number().optional(),
+});
+
+/**
+ * Response schema for unread count
+ * Returns both total and recent counts for split tracking
+ */
+export const getUnreadCountResponse = z.object({
+ total_unread: z.number(),
+ recent_unread: z.number(), // Within SYNC_WINDOW_DAYS (14 days)
+});
+
+// =============================================================================
+// Type Guards for Metadata
+// =============================================================================
+
+/**
+ * Type guard for ConnectorIndexingMetadata
+ */
+export function isConnectorIndexingMetadata(
+ metadata: unknown
+): metadata is ConnectorIndexingMetadata {
+ return connectorIndexingMetadata.safeParse(metadata).success;
+}
+
+/**
+ * Type guard for DocumentProcessingMetadata
+ */
+export function isDocumentProcessingMetadata(
+ metadata: unknown
+): metadata is DocumentProcessingMetadata {
+ return documentProcessingMetadata.safeParse(metadata).success;
+}
+
+/**
+ * Type guard for NewMentionMetadata
+ */
+export function isNewMentionMetadata(metadata: unknown): metadata is NewMentionMetadata {
+ return newMentionMetadata.safeParse(metadata).success;
+}
+
+/**
+ * Safe metadata parser - returns typed metadata or null
+ */
+export function parseInboxItemMetadata(
+ type: InboxItemTypeEnum,
+ metadata: unknown
+): ConnectorIndexingMetadata | DocumentProcessingMetadata | NewMentionMetadata | null {
+ switch (type) {
+ case "connector_indexing": {
+ const result = connectorIndexingMetadata.safeParse(metadata);
+ return result.success ? result.data : null;
+ }
+ case "document_processing": {
+ const result = documentProcessingMetadata.safeParse(metadata);
+ return result.success ? result.data : null;
+ }
+ case "new_mention": {
+ const result = newMentionMetadata.safeParse(metadata);
+ return result.success ? result.data : null;
+ }
+ default:
+ return null;
+ }
+}
+
+// =============================================================================
+// Inferred types
+// =============================================================================
+
+export type InboxItemTypeEnum = z.infer;
+export type InboxItemStatusEnum = z.infer;
+export type DocumentProcessingStageEnum = z.infer;
+export type BaseInboxItemMetadata = z.infer;
+export type ConnectorIndexingMetadata = z.infer;
+export type DocumentProcessingMetadata = z.infer;
+export type NewMentionMetadata = z.infer;
+export type InboxItemMetadata = z.infer;
+export type InboxItem = z.infer;
+export type ConnectorIndexingInboxItem = z.infer;
+export type DocumentProcessingInboxItem = z.infer;
+export type NewMentionInboxItem = z.infer;
+
+// API Request/Response types
+export type GetNotificationsRequest = z.infer;
+export type GetNotificationsResponse = z.infer;
+export type MarkNotificationReadRequest = z.infer;
+export type MarkNotificationReadResponse = z.infer;
+export type MarkAllNotificationsReadResponse = z.infer;
+export type GetUnreadCountRequest = z.infer;
+export type GetUnreadCountResponse = z.infer;
diff --git a/surfsense_web/contracts/types/notification.types.ts b/surfsense_web/contracts/types/notification.types.ts
deleted file mode 100644
index b2b39d26e..000000000
--- a/surfsense_web/contracts/types/notification.types.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-import { z } from "zod";
-import { searchSourceConnectorTypeEnum } from "./connector.types";
-import { documentTypeEnum } from "./document.types";
-
-/**
- * Notification type enum - matches backend notification types
- */
-export const notificationTypeEnum = z.enum([
- "connector_indexing",
- "document_processing",
- "new_mention",
-]);
-
-/**
- * Notification status enum - used in metadata
- */
-export const notificationStatusEnum = z.enum(["in_progress", "completed", "failed"]);
-
-/**
- * Document processing stage enum
- */
-export const documentProcessingStageEnum = z.enum([
- "queued",
- "parsing",
- "chunking",
- "embedding",
- "storing",
- "completed",
- "failed",
-]);
-
-/**
- * Base metadata schema shared across notification types
- */
-export const baseNotificationMetadata = z.object({
- operation_id: z.string().optional(),
- status: notificationStatusEnum.optional(),
- started_at: z.string().optional(),
- completed_at: z.string().optional(),
-});
-
-/**
- * Connector indexing metadata schema
- */
-export const connectorIndexingMetadata = baseNotificationMetadata.extend({
- connector_id: z.number(),
- connector_name: z.string(),
- connector_type: searchSourceConnectorTypeEnum,
- start_date: z.string().nullable().optional(),
- end_date: z.string().nullable().optional(),
- indexed_count: z.number(),
- total_count: z.number().optional(),
- progress_percent: z.number().optional(),
- error_message: z.string().nullable().optional(),
- // Google Drive specific fields
- folder_count: z.number().optional(),
- file_count: z.number().optional(),
- folder_names: z.array(z.string()).optional(),
- file_names: z.array(z.string()).optional(),
-});
-
-/**
- * Document processing metadata schema
- */
-export const documentProcessingMetadata = baseNotificationMetadata.extend({
- document_type: documentTypeEnum,
- document_name: z.string(),
- processing_stage: documentProcessingStageEnum,
- file_size: z.number().optional(),
- chunks_count: z.number().optional(),
- document_id: z.number().optional(),
- error_message: z.string().nullable().optional(),
-});
-
-/**
- * New mention metadata schema
- */
-export const newMentionMetadata = z.object({
- mention_id: z.number(),
- comment_id: z.number(),
- message_id: z.number(),
- thread_id: z.number(),
- thread_title: z.string(),
- author_id: z.string(),
- author_name: z.string(),
- author_avatar_url: z.string().nullable().optional(),
- author_email: z.string().optional(),
- content_preview: z.string(),
-});
-
-/**
- * Union of all notification metadata types
- * Use this when the notification type is unknown
- */
-export const notificationMetadata = z.union([
- connectorIndexingMetadata,
- documentProcessingMetadata,
- newMentionMetadata,
- baseNotificationMetadata,
-]);
-
-/**
- * Main notification schema
- */
-export const notification = z.object({
- id: z.number(),
- user_id: z.string(),
- search_space_id: z.number().nullable(),
- type: notificationTypeEnum,
- title: z.string(),
- message: z.string(),
- read: z.boolean(),
- metadata: z.record(z.string(), z.unknown()),
- created_at: z.string(),
- updated_at: z.string().nullable(),
-});
-
-/**
- * Typed notification schemas for specific notification types
- */
-export const connectorIndexingNotification = notification.extend({
- type: z.literal("connector_indexing"),
- metadata: connectorIndexingMetadata,
-});
-
-export const documentProcessingNotification = notification.extend({
- type: z.literal("document_processing"),
- metadata: documentProcessingMetadata,
-});
-
-export const newMentionNotification = notification.extend({
- type: z.literal("new_mention"),
- metadata: newMentionMetadata,
-});
-
-// Inferred types
-export type NotificationTypeEnum = z.infer;
-export type NotificationStatusEnum = z.infer;
-export type DocumentProcessingStageEnum = z.infer;
-export type BaseNotificationMetadata = z.infer;
-export type ConnectorIndexingMetadata = z.infer;
-export type DocumentProcessingMetadata = z.infer;
-export type NewMentionMetadata = z.infer;
-export type NotificationMetadata = z.infer;
-export type Notification = z.infer;
-export type ConnectorIndexingNotification = z.infer;
-export type DocumentProcessingNotification = z.infer;
-export type NewMentionNotification = z.infer;
diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts
new file mode 100644
index 000000000..4c26ddcb9
--- /dev/null
+++ b/surfsense_web/hooks/use-inbox.ts
@@ -0,0 +1,523 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
+import { notificationsApiService } from "@/lib/apis/notifications-api.service";
+import type { SyncHandle } from "@/lib/electric/client";
+import { useElectricClient } from "@/lib/electric/context";
+
+export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
+
+const PAGE_SIZE = 50;
+const SYNC_WINDOW_DAYS = 14;
+
+/**
+ * Check if an item is older than the sync window
+ */
+function isOlderThanSyncWindow(createdAt: string): boolean {
+ const cutoffDate = new Date();
+ cutoffDate.setDate(cutoffDate.getDate() - SYNC_WINDOW_DAYS);
+ return new Date(createdAt) < cutoffDate;
+}
+
+/**
+ * Deduplicate by ID and sort by created_at descending.
+ * This is the SINGLE source of truth for deduplication - prevents race conditions.
+ */
+function deduplicateAndSort(items: InboxItem[]): InboxItem[] {
+ const seen = new Map();
+ for (const item of items) {
+ if (!seen.has(item.id)) {
+ seen.set(item.id, item);
+ }
+ }
+ return Array.from(seen.values()).sort(
+ (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
+ );
+}
+
+/**
+ * Calculate the cutoff date for sync window
+ */
+function getSyncCutoffDate(): string {
+ const cutoff = new Date();
+ cutoff.setDate(cutoff.getDate() - SYNC_WINDOW_DAYS);
+ return cutoff.toISOString();
+}
+
+/**
+ * Convert a date value to ISO string format
+ */
+function toISOString(date: string | Date | null | undefined): string | null {
+ if (!date) return null;
+ if (date instanceof Date) return date.toISOString();
+ if (typeof date === "string") {
+ if (date.includes("T")) return date;
+ try {
+ return new Date(date).toISOString();
+ } catch {
+ return date;
+ }
+ }
+ return null;
+}
+
+/**
+ * Hook for managing inbox items with Electric SQL real-time sync + API fallback
+ *
+ * Architecture (Simplified & Race-Condition Free):
+ * - Electric SQL: Syncs recent items (within SYNC_WINDOW_DAYS) for real-time updates
+ * - Live Query: Provides reactive first page from PGLite
+ * - API: Handles all pagination (more reliable than mixing with Electric)
+ *
+ * Key Design Decisions:
+ * 1. No mutable refs for cursor - cursor computed from current state
+ * 2. Single deduplicateAndSort function - prevents inconsistencies
+ * 3. Filter-based preservation in live query - prevents data loss
+ * 4. Auto-fetch from API when Electric returns 0 items
+ *
+ * @param userId - The user ID to fetch inbox items for
+ * @param searchSpaceId - The search space ID to filter inbox items
+ * @param typeFilter - Optional inbox item type to filter by
+ */
+export function useInbox(
+ userId: string | null,
+ searchSpaceId: number | null,
+ typeFilter: InboxItemTypeEnum | null = null
+) {
+ const electricClient = useElectricClient();
+
+ const [inboxItems, setInboxItems] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [hasMore, setHasMore] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Split unread count tracking for accurate counts with 14-day sync window
+ // olderUnreadCount = unread items OLDER than sync window (from server, static until reconciliation)
+ // recentUnreadCount = unread items within sync window (from live query, real-time)
+ const [olderUnreadCount, setOlderUnreadCount] = useState(0);
+ const [recentUnreadCount, setRecentUnreadCount] = useState(0);
+
+ const syncHandleRef = useRef(null);
+ const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
+ const userSyncKeyRef = useRef(null);
+ const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
+
+ // Total unread = older (static from server) + recent (live from Electric)
+ const totalUnreadCount = olderUnreadCount + recentUnreadCount;
+
+ // EFFECT 1: Electric SQL sync for real-time updates
+ useEffect(() => {
+ if (!userId || !electricClient) {
+ setLoading(!electricClient);
+ return;
+ }
+
+ const client = electricClient;
+ let mounted = true;
+
+ async function startSync() {
+ try {
+ const cutoffDate = getSyncCutoffDate();
+ const userSyncKey = `inbox_${userId}_${cutoffDate}`;
+
+ // Skip if already syncing with this key
+ if (userSyncKeyRef.current === userSyncKey) return;
+
+ // Clean up previous sync
+ if (syncHandleRef.current) {
+ syncHandleRef.current.unsubscribe();
+ syncHandleRef.current = null;
+ }
+
+ console.log("[useInbox] Starting sync for:", userId);
+ userSyncKeyRef.current = userSyncKey;
+
+ const handle = await client.syncShape({
+ table: "notifications",
+ where: `user_id = '${userId}' AND created_at > '${cutoffDate}'`,
+ primaryKey: ["id"],
+ });
+
+ // Wait for initial sync with timeout
+ if (!handle.isUpToDate && handle.initialSyncPromise) {
+ await Promise.race([
+ handle.initialSyncPromise,
+ new Promise((resolve) => setTimeout(resolve, 3000)),
+ ]);
+ }
+
+ if (!mounted) {
+ handle.unsubscribe();
+ return;
+ }
+
+ syncHandleRef.current = handle;
+ setLoading(false);
+ setError(null);
+ } catch (err) {
+ if (!mounted) return;
+ console.error("[useInbox] Sync failed:", err);
+ setError(err instanceof Error ? err : new Error("Sync failed"));
+ setLoading(false);
+ }
+ }
+
+ startSync();
+
+ return () => {
+ mounted = false;
+ userSyncKeyRef.current = null;
+ if (syncHandleRef.current) {
+ syncHandleRef.current.unsubscribe();
+ syncHandleRef.current = null;
+ }
+ };
+ }, [userId, electricClient]);
+
+ // Reset when filters change
+ useEffect(() => {
+ setHasMore(true);
+ setInboxItems([]);
+ // Reset count states - will be refetched by the unread count effect
+ setOlderUnreadCount(0);
+ setRecentUnreadCount(0);
+ }, [userId, searchSpaceId, typeFilter]);
+
+ // EFFECT 2: Live query for real-time updates + auto-fetch from API if empty
+ useEffect(() => {
+ if (!userId || !electricClient) return;
+
+ const client = electricClient;
+ let mounted = true;
+
+ async function setupLiveQuery() {
+ // Clean up previous live query
+ if (liveQueryRef.current) {
+ liveQueryRef.current.unsubscribe();
+ liveQueryRef.current = null;
+ }
+
+ try {
+ const cutoff = getSyncCutoffDate();
+
+ const query = `SELECT * FROM notifications
+ WHERE user_id = $1
+ AND (search_space_id = $2 OR search_space_id IS NULL)
+ AND created_at > '${cutoff}'
+ ${typeFilter ? "AND type = $3" : ""}
+ ORDER BY created_at DESC
+ LIMIT ${PAGE_SIZE}`;
+
+ const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
+
+ const db = client.db as any;
+
+ // Initial fetch from PGLite - no validation needed, schema is enforced by Electric SQL sync
+ const result = await client.db.query(query, params);
+
+ if (mounted && result.rows) {
+ const items = deduplicateAndSort(result.rows);
+ setInboxItems(items);
+
+ // AUTO-FETCH: If Electric returned 0 items, check API for older items
+ // This handles the edge case where user has no recent notifications
+ // but has older ones outside the sync window
+ if (items.length === 0) {
+ console.log(
+ "[useInbox] Electric returned 0 items, checking API for older notifications"
+ );
+ try {
+ // Use the API service with proper Zod validation for API responses
+ const data = await notificationsApiService.getNotifications({
+ queryParams: {
+ search_space_id: searchSpaceId ?? undefined,
+ type: typeFilter ?? undefined,
+ limit: PAGE_SIZE,
+ },
+ });
+
+ if (mounted) {
+ if (data.items.length > 0) {
+ setInboxItems(data.items);
+ }
+ setHasMore(data.has_more);
+ }
+ } catch (err) {
+ console.error("[useInbox] API fallback failed:", err);
+ }
+ }
+ }
+
+ // Set up live query for real-time updates
+ if (db.live?.query) {
+ const liveQuery = await db.live.query(query, params);
+
+ if (!mounted) {
+ liveQuery.unsubscribe?.();
+ return;
+ }
+
+ if (liveQuery.subscribe) {
+ // Live query data comes from PGlite - no validation needed
+ liveQuery.subscribe((result: { rows: InboxItem[] }) => {
+ if (mounted && result.rows) {
+ const liveItems = result.rows;
+
+ setInboxItems((prev) => {
+ const liveItemIds = new Set(liveItems.map((item) => item.id));
+
+ // FIXED: Keep ALL items not in live result (not just slice)
+ // This prevents data loss when new notifications push items
+ // out of the LIMIT window
+ const itemsToKeep = prev.filter((item) => !liveItemIds.has(item.id));
+
+ return deduplicateAndSort([...liveItems, ...itemsToKeep]);
+ });
+ }
+ });
+ }
+
+ if (liveQuery.unsubscribe) {
+ liveQueryRef.current = liveQuery;
+ }
+ }
+ } catch (err) {
+ console.error("[useInbox] Live query error:", err);
+ }
+ }
+
+ setupLiveQuery();
+
+ return () => {
+ mounted = false;
+ if (liveQueryRef.current) {
+ liveQueryRef.current.unsubscribe();
+ liveQueryRef.current = null;
+ }
+ };
+ }, [userId, searchSpaceId, typeFilter, electricClient]);
+
+ // EFFECT 3: Dedicated unread count sync with split tracking
+ // - Fetches server count on mount (accurate total)
+ // - Sets up live query for recent count (real-time updates)
+ // - Handles items older than sync window separately
+ useEffect(() => {
+ if (!userId || !electricClient) return;
+
+ const client = electricClient;
+ let mounted = true;
+
+ async function setupUnreadCountSync() {
+ // Cleanup previous live query
+ if (unreadCountLiveQueryRef.current) {
+ unreadCountLiveQueryRef.current.unsubscribe();
+ unreadCountLiveQueryRef.current = null;
+ }
+
+ try {
+ // STEP 1: Fetch server counts (total and recent) - guaranteed accurate
+ console.log("[useInbox] Fetching unread count from server");
+ const serverCounts = await notificationsApiService.getUnreadCount(
+ searchSpaceId ?? undefined
+ );
+
+ if (mounted) {
+ // Calculate older count = total - recent
+ const olderCount = serverCounts.total_unread - serverCounts.recent_unread;
+ setOlderUnreadCount(olderCount);
+ setRecentUnreadCount(serverCounts.recent_unread);
+ console.log(
+ `[useInbox] Server counts: total=${serverCounts.total_unread}, recent=${serverCounts.recent_unread}, older=${olderCount}`
+ );
+ }
+
+ // STEP 2: Set up PGLite live query for RECENT unread count only
+ // This provides real-time updates for notifications within sync window
+ const db = client.db as any;
+ const cutoff = getSyncCutoffDate();
+
+ // Count query - NO LIMIT, counts all unread in synced window
+ const countQuery = `
+ SELECT COUNT(*) as count FROM notifications
+ WHERE user_id = $1
+ AND (search_space_id = $2 OR search_space_id IS NULL)
+ AND created_at > '${cutoff}'
+ AND read = false
+ ${typeFilter ? "AND type = $3" : ""}
+ `;
+ const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
+
+ if (db.live?.query) {
+ const liveQuery = await db.live.query(countQuery, params);
+
+ if (!mounted) {
+ liveQuery.unsubscribe?.();
+ return;
+ }
+
+ if (liveQuery.subscribe) {
+ liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => {
+ if (mounted && result.rows?.[0]) {
+ const liveCount = Number(result.rows[0].count) || 0;
+ // Update recent count from live query
+ // This fires in real-time when Electric syncs new/updated notifications
+ setRecentUnreadCount(liveCount);
+ }
+ });
+ }
+
+ if (liveQuery.unsubscribe) {
+ unreadCountLiveQueryRef.current = liveQuery;
+ }
+ }
+ } catch (err) {
+ console.error("[useInbox] Unread count sync error:", err);
+ // On error, counts will remain at 0 or previous values
+ // The items-based count will be the fallback
+ }
+ }
+
+ setupUnreadCountSync();
+
+ return () => {
+ mounted = false;
+ if (unreadCountLiveQueryRef.current) {
+ unreadCountLiveQueryRef.current.unsubscribe();
+ unreadCountLiveQueryRef.current = null;
+ }
+ };
+ }, [userId, searchSpaceId, typeFilter, electricClient]);
+
+ // loadMore - Pure cursor-based pagination, no race conditions
+ // Cursor is computed from current state, not stored in refs
+ const loadMore = useCallback(async () => {
+ // Removed inboxItems.length === 0 check to allow loading older items
+ // when Electric returns 0 items
+ if (!userId || loadingMore || !hasMore) return;
+
+ setLoadingMore(true);
+
+ try {
+ // Cursor is computed from current state - no stale refs possible
+ const oldestItem = inboxItems.length > 0 ? inboxItems[inboxItems.length - 1] : null;
+ const beforeDate = oldestItem ? toISOString(oldestItem.created_at) : null;
+
+ console.log("[useInbox] Loading more, before:", beforeDate ?? "none (initial)");
+
+ // Use the API service with proper Zod validation
+ const data = await notificationsApiService.getNotifications({
+ queryParams: {
+ search_space_id: searchSpaceId ?? undefined,
+ type: typeFilter ?? undefined,
+ before_date: beforeDate ?? undefined,
+ limit: PAGE_SIZE,
+ },
+ });
+
+ if (data.items.length > 0) {
+ // Functional update ensures we always merge with latest state
+ // Items are already validated by the API service
+ setInboxItems((prev) => deduplicateAndSort([...prev, ...data.items]));
+ }
+
+ // Use API's has_more flag
+ setHasMore(data.has_more);
+ } catch (err) {
+ console.error("[useInbox] Load more failed:", err);
+ } finally {
+ setLoadingMore(false);
+ }
+ }, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]);
+
+ // Mark inbox item as read with optimistic update
+ // Handles both recent items (live query updates count) and older items (manual count decrement)
+ const markAsRead = useCallback(
+ async (itemId: number) => {
+ // Find the item to check if it's older than sync window
+ const item = inboxItems.find((i) => i.id === itemId);
+ const isOlderItem = item && !item.read && isOlderThanSyncWindow(item.created_at);
+
+ // Optimistic update: mark as read immediately for instant UI feedback
+ setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: true } : i)));
+
+ // If older item, manually decrement older count
+ // (live query won't see items outside sync window)
+ if (isOlderItem) {
+ setOlderUnreadCount((prev) => Math.max(0, prev - 1));
+ }
+
+ try {
+ // Use the API service with proper Zod validation
+ const result = await notificationsApiService.markAsRead({ notificationId: itemId });
+
+ if (!result.success) {
+ // Rollback on error
+ setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
+ if (isOlderItem) {
+ setOlderUnreadCount((prev) => prev + 1);
+ }
+ }
+ // If successful, Electric SQL will sync the change and live query will update
+ // This ensures eventual consistency even if optimistic update was wrong
+ return result.success;
+ } catch (err) {
+ console.error("Failed to mark as read:", err);
+ // Rollback on error
+ setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
+ if (isOlderItem) {
+ setOlderUnreadCount((prev) => prev + 1);
+ }
+ return false;
+ }
+ },
+ [inboxItems]
+ );
+
+ // Mark all inbox items as read with optimistic update
+ // Resets both older and recent counts to 0
+ const markAllAsRead = useCallback(async () => {
+ // Store previous counts for potential rollback
+ const prevOlderCount = olderUnreadCount;
+ const prevRecentCount = recentUnreadCount;
+
+ // Optimistic update: mark all as read immediately for instant UI feedback
+ setInboxItems((prev) => prev.map((item) => ({ ...item, read: true })));
+ setOlderUnreadCount(0);
+ setRecentUnreadCount(0);
+
+ try {
+ // Use the API service with proper Zod validation
+ const result = await notificationsApiService.markAllAsRead();
+
+ if (!result.success) {
+ console.error("Failed to mark all as read");
+ // Rollback counts on error
+ setOlderUnreadCount(prevOlderCount);
+ setRecentUnreadCount(prevRecentCount);
+ }
+ // Electric SQL will sync and live query will ensure consistency
+ return result.success;
+ } catch (err) {
+ console.error("Failed to mark all as read:", err);
+ // Rollback counts on error
+ setOlderUnreadCount(prevOlderCount);
+ setRecentUnreadCount(prevRecentCount);
+ return false;
+ }
+ }, [olderUnreadCount, recentUnreadCount]);
+
+ return {
+ inboxItems,
+ unreadCount: totalUnreadCount,
+ markAsRead,
+ markAllAsRead,
+ loading,
+ loadingMore,
+ hasMore,
+ loadMore,
+ isUsingApiFallback: true, // Always use API for pagination
+ error,
+ };
+}
diff --git a/surfsense_web/hooks/use-notifications.ts b/surfsense_web/hooks/use-notifications.ts
deleted file mode 100644
index eca00a935..000000000
--- a/surfsense_web/hooks/use-notifications.ts
+++ /dev/null
@@ -1,334 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useRef, useState } from "react";
-import type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
-import { authenticatedFetch } from "@/lib/auth-utils";
-import type { SyncHandle } from "@/lib/electric/client";
-import { useElectricClient } from "@/lib/electric/context";
-
-export type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
-
-/**
- * Hook for managing notifications with Electric SQL real-time sync
- *
- * Uses the Electric client from context (provided by ElectricProvider)
- * instead of initializing its own - prevents race conditions and memory leaks
- *
- * Architecture:
- * - User-level sync: Syncs ALL notifications for a user (runs once per user)
- * - Search-space-level query: Filters notifications by searchSpaceId (updates on search space change)
- *
- * This separation ensures smooth transitions when switching search spaces (no flash).
- *
- * @param userId - The user ID to fetch notifications for
- * @param searchSpaceId - The search space ID to filter notifications (null shows global notifications only)
- * @param typeFilter - Optional notification type to filter by (null shows all types)
- */
-export function useNotifications(
- userId: string | null,
- searchSpaceId: number | null,
- typeFilter: NotificationTypeEnum | null = null
-) {
- // Get Electric client from context - ElectricProvider handles initialization
- const electricClient = useElectricClient();
-
- const [notifications, setNotifications] = useState([]);
- const [totalUnreadCount, setTotalUnreadCount] = useState(0);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const syncHandleRef = useRef(null);
- const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
- const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
-
- // Track user-level sync key to prevent duplicate sync subscriptions
- const userSyncKeyRef = useRef(null);
-
- // EFFECT 1: User-level sync - runs once per user, syncs ALL notifications
- useEffect(() => {
- if (!userId || !electricClient) {
- setLoading(!electricClient);
- return;
- }
-
- const userSyncKey = `notifications_${userId}`;
- if (userSyncKeyRef.current === userSyncKey) {
- // Already syncing for this user
- return;
- }
-
- let mounted = true;
- userSyncKeyRef.current = userSyncKey;
-
- async function startUserSync() {
- try {
- console.log("[useNotifications] Starting user-level sync for:", userId);
-
- // Sync ALL notifications for this user (cached via syncShape caching)
- const handle = await electricClient.syncShape({
- table: "notifications",
- where: `user_id = '${userId}'`,
- primaryKey: ["id"],
- });
-
- console.log("[useNotifications] User sync started:", {
- isUpToDate: handle.isUpToDate,
- });
-
- // Wait for initial sync with timeout
- if (!handle.isUpToDate && handle.initialSyncPromise) {
- try {
- await Promise.race([
- handle.initialSyncPromise,
- new Promise((resolve) => setTimeout(resolve, 2000)),
- ]);
- } catch (syncErr) {
- console.error("[useNotifications] Initial sync failed:", syncErr);
- }
- }
-
- if (!mounted) {
- handle.unsubscribe();
- return;
- }
-
- syncHandleRef.current = handle;
- setLoading(false);
- setError(null);
- } catch (err) {
- if (!mounted) return;
- console.error("[useNotifications] Failed to start user sync:", err);
- setError(err instanceof Error ? err : new Error("Failed to sync notifications"));
- setLoading(false);
- }
- }
-
- startUserSync();
-
- return () => {
- mounted = false;
- userSyncKeyRef.current = null;
-
- if (syncHandleRef.current) {
- syncHandleRef.current.unsubscribe();
- syncHandleRef.current = null;
- }
- };
- }, [userId, electricClient]);
-
- // EFFECT 2: Search-space-level query - updates when searchSpaceId or typeFilter changes
- // This runs independently of sync, allowing smooth transitions between search spaces
- useEffect(() => {
- if (!userId || !electricClient) {
- return;
- }
-
- let mounted = true;
-
- async function updateQuery() {
- // Clean up previous live query (but DON'T clear notifications - keep showing old until new arrive)
- if (liveQueryRef.current) {
- liveQueryRef.current.unsubscribe();
- liveQueryRef.current = null;
- }
-
- try {
- console.log(
- "[useNotifications] Updating query for searchSpace:",
- searchSpaceId,
- "typeFilter:",
- typeFilter
- );
-
- // Build query with optional type filter
- const baseQuery = `SELECT * FROM notifications
- WHERE user_id = $1
- AND (search_space_id = $2 OR search_space_id IS NULL)`;
- const typeClause = typeFilter ? ` AND type = $3` : "";
- const orderClause = ` ORDER BY created_at DESC`;
- const fullQuery = baseQuery + typeClause + orderClause;
- const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
-
- // Fetch notifications for current search space immediately
- const result = await electricClient.db.query(fullQuery, params);
-
- if (mounted) {
- setNotifications(result.rows || []);
- }
-
- // Set up live query for real-time updates
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const db = electricClient.db as any;
-
- if (db.live?.query && typeof db.live.query === "function") {
- const liveQuery = await db.live.query(fullQuery, params);
-
- if (!mounted) {
- liveQuery.unsubscribe?.();
- return;
- }
-
- // Set initial results from live query
- if (liveQuery.initialResults?.rows) {
- setNotifications(liveQuery.initialResults.rows);
- } else if (liveQuery.rows) {
- setNotifications(liveQuery.rows);
- }
-
- // Subscribe to changes
- if (typeof liveQuery.subscribe === "function") {
- liveQuery.subscribe((result: { rows: Notification[] }) => {
- if (mounted && result.rows) {
- setNotifications(result.rows);
- }
- });
- }
-
- if (typeof liveQuery.unsubscribe === "function") {
- liveQueryRef.current = liveQuery;
- }
- }
- } catch (err) {
- console.error("[useNotifications] Failed to update query:", err);
- }
- }
-
- updateQuery();
-
- return () => {
- mounted = false;
- if (liveQueryRef.current) {
- liveQueryRef.current.unsubscribe();
- liveQueryRef.current = null;
- }
- };
- }, [userId, searchSpaceId, typeFilter, electricClient]);
-
- // EFFECT 3: Total unread count - independent of type filter
- // This ensures the badge count stays consistent regardless of active filter
- useEffect(() => {
- if (!userId || !electricClient) {
- return;
- }
-
- let mounted = true;
-
- async function updateUnreadCount() {
- // Clean up previous live query
- if (unreadCountLiveQueryRef.current) {
- unreadCountLiveQueryRef.current.unsubscribe();
- unreadCountLiveQueryRef.current = null;
- }
-
- try {
- const countQuery = `SELECT COUNT(*) as count FROM notifications
- WHERE user_id = $1
- AND (search_space_id = $2 OR search_space_id IS NULL)
- AND read = false`;
-
- // Fetch initial count
- const result = await electricClient.db.query<{ count: number }>(countQuery, [
- userId,
- searchSpaceId,
- ]);
-
- if (mounted && result.rows?.[0]) {
- setTotalUnreadCount(Number(result.rows[0].count) || 0);
- }
-
- // Set up live query for real-time updates
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const db = electricClient.db as any;
-
- if (db.live?.query && typeof db.live.query === "function") {
- const liveQuery = await db.live.query(countQuery, [userId, searchSpaceId]);
-
- if (!mounted) {
- liveQuery.unsubscribe?.();
- return;
- }
-
- // Set initial results from live query
- if (liveQuery.initialResults?.rows?.[0]) {
- setTotalUnreadCount(Number(liveQuery.initialResults.rows[0].count) || 0);
- } else if (liveQuery.rows?.[0]) {
- setTotalUnreadCount(Number(liveQuery.rows[0].count) || 0);
- }
-
- // Subscribe to changes
- if (typeof liveQuery.subscribe === "function") {
- liveQuery.subscribe((result: { rows: { count: number }[] }) => {
- if (mounted && result.rows?.[0]) {
- setTotalUnreadCount(Number(result.rows[0].count) || 0);
- }
- });
- }
-
- if (typeof liveQuery.unsubscribe === "function") {
- unreadCountLiveQueryRef.current = liveQuery;
- }
- }
- } catch (err) {
- console.error("[useNotifications] Failed to update unread count:", err);
- }
- }
-
- updateUnreadCount();
-
- return () => {
- mounted = false;
- if (unreadCountLiveQueryRef.current) {
- unreadCountLiveQueryRef.current.unsubscribe();
- unreadCountLiveQueryRef.current = null;
- }
- };
- }, [userId, searchSpaceId, electricClient]);
-
- // Mark notification as read via backend API
- const markAsRead = useCallback(async (notificationId: number) => {
- try {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${notificationId}/read`,
- { method: "PATCH" }
- );
-
- if (!response.ok) {
- const error = await response.json().catch(() => ({ detail: "Failed to mark as read" }));
- throw new Error(error.detail || "Failed to mark notification as read");
- }
-
- return true;
- } catch (err) {
- console.error("Failed to mark notification as read:", err);
- return false;
- }
- }, []);
-
- // Mark all notifications as read via backend API
- const markAllAsRead = useCallback(async () => {
- try {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`,
- { method: "PATCH" }
- );
-
- if (!response.ok) {
- const error = await response.json().catch(() => ({ detail: "Failed to mark all as read" }));
- throw new Error(error.detail || "Failed to mark all notifications as read");
- }
-
- return true;
- } catch (err) {
- console.error("Failed to mark all notifications as read:", err);
- return false;
- }
- }, []);
-
- return {
- notifications,
- unreadCount: totalUnreadCount,
- markAsRead,
- markAllAsRead,
- loading,
- error,
- };
-}
diff --git a/surfsense_web/lib/apis/notifications-api.service.ts b/surfsense_web/lib/apis/notifications-api.service.ts
new file mode 100644
index 000000000..927aee747
--- /dev/null
+++ b/surfsense_web/lib/apis/notifications-api.service.ts
@@ -0,0 +1,110 @@
+import {
+ type GetNotificationsRequest,
+ type GetNotificationsResponse,
+ type GetUnreadCountResponse,
+ type MarkAllNotificationsReadResponse,
+ type MarkNotificationReadRequest,
+ type MarkNotificationReadResponse,
+ getNotificationsRequest,
+ getNotificationsResponse,
+ getUnreadCountResponse,
+ markAllNotificationsReadResponse,
+ markNotificationReadRequest,
+ markNotificationReadResponse,
+} from "@/contracts/types/inbox.types";
+import { ValidationError } from "../error";
+import { baseApiService } from "./base-api.service";
+
+class NotificationsApiService {
+ /**
+ * Get notifications with pagination
+ */
+ getNotifications = async (
+ request: GetNotificationsRequest
+ ): Promise => {
+ const parsedRequest = getNotificationsRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+ const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ const { queryParams } = parsedRequest.data;
+
+ // Build query string from params
+ const params = new URLSearchParams();
+
+ if (queryParams.search_space_id !== undefined) {
+ params.append("search_space_id", String(queryParams.search_space_id));
+ }
+ if (queryParams.type) {
+ params.append("type", queryParams.type);
+ }
+ if (queryParams.before_date) {
+ params.append("before_date", queryParams.before_date);
+ }
+ if (queryParams.limit !== undefined) {
+ params.append("limit", String(queryParams.limit));
+ }
+ if (queryParams.offset !== undefined) {
+ params.append("offset", String(queryParams.offset));
+ }
+
+ const queryString = params.toString();
+
+ return baseApiService.get(
+ `/api/v1/notifications${queryString ? `?${queryString}` : ""}`,
+ getNotificationsResponse
+ );
+ };
+
+ /**
+ * Mark a single notification as read
+ */
+ markAsRead = async (
+ request: MarkNotificationReadRequest
+ ): Promise => {
+ const parsedRequest = markNotificationReadRequest.safeParse(request);
+
+ if (!parsedRequest.success) {
+ console.error("Invalid request:", parsedRequest.error);
+ const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ const { notificationId } = parsedRequest.data;
+
+ return baseApiService.patch(
+ `/api/v1/notifications/${notificationId}/read`,
+ markNotificationReadResponse
+ );
+ };
+
+ /**
+ * Mark all notifications as read
+ */
+ markAllAsRead = async (): Promise => {
+ return baseApiService.patch("/api/v1/notifications/read-all", markAllNotificationsReadResponse);
+ };
+
+ /**
+ * Get unread notification count with split between total and recent
+ * - total_unread: All unread notifications
+ * - recent_unread: Unread within sync window (last 14 days)
+ */
+ getUnreadCount = async (searchSpaceId?: number): Promise => {
+ const params = new URLSearchParams();
+ if (searchSpaceId !== undefined) {
+ params.append("search_space_id", String(searchSpaceId));
+ }
+ const queryString = params.toString();
+
+ return baseApiService.get(
+ `/api/v1/notifications/unread-count${queryString ? `?${queryString}` : ""}`,
+ getUnreadCountResponse
+ );
+ };
+}
+
+export const notificationsApiService = new NotificationsApiService();
diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts
index 514185d23..6a9d87b88 100644
--- a/surfsense_web/lib/electric/client.ts
+++ b/surfsense_web/lib/electric/client.ts
@@ -53,8 +53,9 @@ const activeSyncHandles = new Map();
const pendingSyncs = new Map>();
// Version for sync state - increment this to force fresh sync when Electric config changes
-// Set to v2 for user-specific database architecture
-const SYNC_VERSION = 2;
+// v2: user-specific database architecture
+// v3: consistent cutoff date for sync+queries, visibility refresh support
+const SYNC_VERSION = 3;
// Database name prefix for identifying SurfSense databases
const DB_PREFIX = "surfsense-";
diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json
index cda522b61..93fc8c1b4 100644
--- a/surfsense_web/messages/en.json
+++ b/surfsense_web/messages/en.json
@@ -692,7 +692,23 @@
"light": "Light",
"dark": "Dark",
"system": "System",
- "logout": "Logout"
+ "logout": "Logout",
+ "inbox": "Inbox",
+ "search_inbox": "Search inbox",
+ "mark_all_read": "Mark all as read",
+ "mark_as_read": "Mark as read",
+ "mentions": "Mentions",
+ "status": "Status",
+ "no_results_found": "No results found",
+ "no_mentions": "No mentions",
+ "no_mentions_hint": "You'll see mentions from others here",
+ "no_status_updates": "No status updates",
+ "no_status_updates_hint": "Document and connector updates will appear here",
+ "filter": "Filter",
+ "all": "All",
+ "unread": "Unread",
+ "connectors": "Connectors",
+ "all_connectors": "All connectors"
},
"errors": {
"something_went_wrong": "Something went wrong",
diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json
index 7f2f49cfc..47d06cb20 100644
--- a/surfsense_web/messages/zh.json
+++ b/surfsense_web/messages/zh.json
@@ -677,7 +677,23 @@
"light": "浅色",
"dark": "深色",
"system": "系统",
- "logout": "退出登录"
+ "logout": "退出登录",
+ "inbox": "收件箱",
+ "search_inbox": "搜索收件箱...",
+ "mark_all_read": "全部标记为已读",
+ "mark_as_read": "标记为已读",
+ "mentions": "提及",
+ "status": "状态",
+ "no_results_found": "未找到结果",
+ "no_mentions": "没有提及",
+ "no_mentions_hint": "您会在这里看到他人的提及",
+ "no_status_updates": "没有状态更新",
+ "no_status_updates_hint": "文档和连接器更新将显示在这里",
+ "filter": "筛选",
+ "all": "全部",
+ "unread": "未读",
+ "connectors": "连接器",
+ "all_connectors": "所有连接器"
},
"errors": {
"something_went_wrong": "出错了",
diff --git a/surfsense_web/package.json b/surfsense_web/package.json
index b8e628b33..3c035c13d 100644
--- a/surfsense_web/package.json
+++ b/surfsense_web/package.json
@@ -105,6 +105,7 @@
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"unist-util-visit": "^5.0.0",
+ "vaul": "^1.1.2",
"zod": "^4.2.1",
"zustand": "^5.0.9"
},
diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml
index 5e910d87a..f7928abea 100644
--- a/surfsense_web/pnpm-lock.yaml
+++ b/surfsense_web/pnpm-lock.yaml
@@ -260,6 +260,9 @@ importers:
unist-util-visit:
specifier: ^5.0.0
version: 5.0.0
+ vaul:
+ specifier: ^1.1.2
+ version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
zod:
specifier: ^4.2.1
version: 4.2.1
@@ -6384,6 +6387,12 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
+ vaul@1.1.2:
+ resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==}
+ peerDependencies:
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
+
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
@@ -13451,6 +13460,15 @@ snapshots:
uuid@8.3.2: {}
+ vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+ dependencies:
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ transitivePeerDependencies:
+ - '@types/react'
+ - '@types/react-dom'
+
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3