From 01307f36dddd9c0693e8afe2cae6aeb8bc86f7bc Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 21 Jan 2026 15:14:14 +0530
Subject: [PATCH 01/20] refactor: simplify state management in ChatShareButton
and ModelSelector
---
.../components/new-chat/chat-share-button.tsx | 17 +----------------
.../components/new-chat/model-selector.tsx | 15 ---------------
2 files changed, 1 insertion(+), 31 deletions(-)
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...
-
-
- )}
-
From 4d87c38b5f2d0c16dc62980a48014c47189666fb Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 21 Jan 2026 16:12:24 +0530
Subject: [PATCH 02/20] impr: improve tabbed navigation for active and archived
chats in sidebars
---
.../ui/sidebar/AllPrivateChatsSidebar.tsx | 55 ++++++++++---------
.../ui/sidebar/AllSharedChatsSidebar.tsx | 55 ++++++++++---------
2 files changed, 58 insertions(+), 52 deletions(-)
diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
index f5c64cc67..fb1a6ed0d 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 {
@@ -277,32 +278,34 @@ 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}
+
+
+
+
)}
diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
index f50cb028a..f400e6fc8 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 {
@@ -277,32 +278,34 @@ 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}
+
+
+
+
)}
From 4653eb5f646a4ac9209b8b6c8b22f83f75d600e0 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 21 Jan 2026 16:25:38 +0530
Subject: [PATCH 03/20] refactor: update role management UI components for
improved interaction
- Renamed props in RolesTab for clarity.
- Replaced label elements with button elements in CreateRoleDialog for better accessibility and interaction.
- Enhanced toggle functionality for category and permission selection.
---
.../dashboard/[search_space_id]/team/page.tsx | 18 ++++++++++++------
1 file changed, 12 insertions(+), 6 deletions(-)
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..132906d9f 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
@@ -712,9 +712,9 @@ function MembersTab({
function RolesTab({
roles,
- groupedPermissions,
+ groupedPermissions: _groupedPermissions,
loading,
- onUpdateRole,
+ onUpdateRole: _onUpdateRole,
onDeleteRole,
canUpdate,
canDelete,
@@ -1500,7 +1500,11 @@ function CreateRoleDialog({
return (
-
+ toggleCategory(category)}
+ >
toggleCategory(category)}
@@ -1508,19 +1512,21 @@ function CreateRoleDialog({
{category} ({categorySelected}/{perms.length})
-
+
{perms.map((perm) => (
- togglePermission(perm.value)}
>
togglePermission(perm.value)}
/>
{perm.value.split(":")[1]}
-
+
))}
From 8eec94843464e56d49750f086e46c9bb46dae018 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 21 Jan 2026 17:13:30 +0530
Subject: [PATCH 04/20] feat: enhance team management UI with avatar initials
and role permissions display
- Added a helper function to generate avatar initials for team members without an avatar.
- Improved the MembersTab component by displaying user avatars or initials.
- Introduced a new RolePermissionsDisplay component to present role permissions in a categorized manner.
- Updated table headers in MembersTab for better clarity and added icons for visual enhancement.
---
.../dashboard/[search_space_id]/team/page.tsx | 261 ++++++++++++++----
1 file changed, 210 insertions(+), 51 deletions(-)
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 132906d9f..a0bc6be03 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"}
-
+
+ {member.role?.name || "No role"}
+
)}
-
-
+
{new Date(member.joined_at).toLocaleDateString()}
-
+
{canRemove && !member.is_owner && (
@@ -708,6 +768,130 @@ 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({
@@ -852,32 +1036,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
-
- )}
-
-
+
From 93aa1dcf3c00efe4d4c49ebfdfd599f99ec1206b Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 21 Jan 2026 19:43:20 +0530
Subject: [PATCH 05/20] feat: implement inbox sidebar for enhanced user
notifications
- Introduced a new InboxSidebar component to manage and display inbox items.
- Integrated real-time syncing of inbox items using Electric SQL for instant updates.
- Added functionality to mark items as read, archive/unarchive, and filter inbox content.
- Updated existing components to accommodate the new inbox feature, including layout adjustments and state management.
- Enhanced user experience with improved navigation and interaction for inbox items.
---
.../new-chat/[[...chat_id]]/page.tsx | 2 +-
.../layout/providers/LayoutDataProvider.tsx | 53 +-
.../components/layout/ui/header/Header.tsx | 3 -
.../layout/ui/sidebar/InboxSidebar.tsx | 662 ++++++++++++++++++
.../layout/ui/sidebar/NavSection.tsx | 13 +-
.../components/layout/ui/sidebar/index.ts | 1 +
.../notifications/NotificationButton.tsx | 103 ---
.../notifications/NotificationPopup.tsx | 246 -------
.../content/docs/how-to/electric-sql.mdx | 6 +-
.../{notification.types.ts => inbox.types.ts} | 57 +-
.../{use-notifications.ts => use-inbox.ts} | 125 ++--
surfsense_web/messages/en.json | 17 +-
surfsense_web/messages/zh.json | 13 +-
13 files changed, 860 insertions(+), 441 deletions(-)
create mode 100644 surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
delete mode 100644 surfsense_web/components/notifications/NotificationButton.tsx
delete mode 100644 surfsense_web/components/notifications/NotificationPopup.tsx
rename surfsense_web/contracts/types/{notification.types.ts => inbox.types.ts} (62%)
rename surfsense_web/hooks/{use-notifications.ts => use-inbox.ts} (65%)
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 f23851f47..c59d50e08 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
@@ -367,7 +367,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/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index 489fde3d7..53f33f27b 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, Logs, 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,20 @@ 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, markAsRead, markAllAsRead, archiveItem } = 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 +162,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 +268,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 +542,18 @@ 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/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
new file mode 100644
index 000000000..4171ac267
--- /dev/null
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -0,0 +1,662 @@
+"use client";
+
+import {
+ AlertCircle,
+ Archive,
+ AtSign,
+ BellDot,
+ Check,
+ CheckCheck,
+ CheckCircle2,
+ History,
+ Inbox,
+ ListFilter,
+ Loader2,
+ MoreHorizontal,
+ RotateCcw,
+ 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, 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 {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ 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 type { InboxItem } from "@/hooks/use-inbox";
+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";
+}
+
+type InboxTab = "mentions" | "status";
+type InboxFilter = "all" | "unread" | "archived";
+
+interface InboxSidebarProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ inboxItems: InboxItem[];
+ unreadCount: number;
+ loading: boolean;
+ markAsRead: (id: number) => Promise;
+ markAllAsRead: () => Promise;
+ archiveItem: (id: number, archived: boolean) => Promise;
+ onCloseMobileSidebar?: () => void;
+}
+
+export function InboxSidebar({
+ open,
+ onOpenChange,
+ inboxItems,
+ unreadCount,
+ loading,
+ markAsRead,
+ markAllAsRead,
+ archiveItem,
+ onCloseMobileSidebar,
+}: InboxSidebarProps) {
+ const t = useTranslations("sidebar");
+ const router = useRouter();
+
+ const [searchQuery, setSearchQuery] = useState("");
+ const [activeTab, setActiveTab] = useState("mentions");
+ const [activeFilter, setActiveFilter] = useState("all");
+ const [mounted, setMounted] = useState(false);
+ // Unified dropdown state: "filter" | "options" | number (item id) | null
+ const [openDropdown, setOpenDropdown] = useState<"filter" | "options" | number | null>(null);
+ const [markingAsReadId, setMarkingAsReadId] = useState(null);
+ const [archivingItemId, setArchivingItemId] = useState(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]);
+
+ // 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 items for current tab
+ const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems;
+
+ // Filter items based on filter type and search query
+ const filteredItems = useMemo(() => {
+ let items = currentTabItems;
+
+ // Apply filter
+ if (activeFilter === "all") {
+ // "Unread & read" shows all non-archived items
+ items = items.filter((item) => !(item as InboxItem & { archived?: boolean }).archived);
+ } else if (activeFilter === "unread") {
+ // "Unread" shows only unread non-archived items
+ items = items.filter((item) => !item.read && !(item as InboxItem & { archived?: boolean }).archived);
+ } else if (activeFilter === "archived") {
+ // "Archived" shows only archived items
+ items = items.filter((item) => (item as InboxItem & { archived?: boolean }).archived);
+ }
+
+ // 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, searchQuery]);
+
+ // Count unread items per tab
+ const unreadMentionsCount = useMemo(
+ () => mentionItems.filter((item) => !item.read).length,
+ [mentionItems]
+ );
+
+ const unreadStatusCount = useMemo(
+ () => 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") {
+ const metadata = item.metadata as {
+ thread_id?: number;
+ comment_id?: number;
+ };
+ const searchSpaceId = item.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}`;
+ onOpenChange(false);
+ onCloseMobileSidebar?.();
+ router.push(url);
+ }
+ }
+ },
+ [markAsRead, router, onOpenChange, onCloseMobileSidebar]
+ );
+
+ const handleMarkAsRead = useCallback(
+ async (itemId: number) => {
+ setMarkingAsReadId(itemId);
+ await markAsRead(itemId);
+ setMarkingAsReadId(null);
+ },
+ [markAsRead]
+ );
+
+ const handleMarkAllAsRead = useCallback(async () => {
+ await markAllAsRead();
+ }, [markAllAsRead]);
+
+ const handleToggleArchive = useCallback(
+ async (itemId: number, currentlyArchived: boolean) => {
+ setArchivingItemId(itemId);
+ await archiveItem(itemId, !currentlyArchived);
+ setArchivingItemId(null);
+ },
+ [archiveItem]
+ );
+
+ 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") {
+ const metadata = item.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 status items (connector/document), show status icons
+ const status = item.metadata?.status as string | 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"}
+
+
+
setOpenDropdown(isOpen ? "filter" : null)}
+ >
+
+
+
+ {t("filter") || "Filter"}
+
+
+
+
+ {t("filter") || "Filter"}
+
+ setActiveFilter("all")}
+ className="flex items-center justify-between"
+ >
+
+
+ {t("unread_and_read") || "Unread & read"}
+
+ {activeFilter === "all" && }
+
+ setActiveFilter("unread")}
+ className="flex items-center justify-between"
+ >
+
+
+ {t("unread") || "Unread"}
+
+ {activeFilter === "unread" && }
+
+ setActiveFilter("archived")}
+ className="flex items-center justify-between"
+ >
+
+
+ {t("archived") || "Archived"}
+
+ {activeFilter === "archived" && }
+
+
+
+
setOpenDropdown(isOpen ? "options" : null)}
+ >
+
+
+
+ {t("more_options") || "More options"}
+
+
+
+
+
+ {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 > 0 && (
+
+ {unreadMentionsCount}
+
+ )}
+
+
+
+
+
+ {t("status") || "Status"}
+ {unreadStatusCount > 0 && (
+
+ {unreadStatusCount}
+
+ )}
+
+
+
+
+
+
+ {loading ? (
+
+
+
+ ) : filteredItems.length > 0 ? (
+
+ {filteredItems.map((item) => {
+ const isMarkingAsRead = markingAsReadId === item.id;
+ const isArchiving = archivingItemId === item.id;
+ const isBusy = isMarkingAsRead || isArchiving;
+ const isArchived = (item as InboxItem & { archived?: boolean }).archived;
+
+ return (
+
+
+
+ handleItemClick(item)}
+ disabled={isBusy}
+ className="flex items-start gap-3 flex-1 min-w-0 text-left overflow-hidden self-start"
+ >
+ {getStatusIcon(item)}
+
+
+ {item.title}
+
+
+ {convertRenderedToDisplay(item.message)}
+
+
+
+
+
+ {item.title}
+
+ {convertRenderedToDisplay(item.message)}
+
+
+
+
+ {/* Time/dot and 3-dot button container - swap on hover */}
+
+ {/* Time and unread dot - visible by default, hidden on hover or when dropdown is open */}
+
+
+ {formatTime(item.created_at)}
+
+ {!item.read && (
+
+ )}
+
+
+ {/* 3-dot menu - hidden by default, visible on hover or when dropdown is open */}
+
+ setOpenDropdown(isOpen ? item.id : null)
+ }
+ >
+
+
+ {isBusy ? (
+
+ ) : (
+
+ )}
+
+ {t("more_options") || "More options"}
+
+
+
+
+ {!item.read && (
+ <>
+ handleMarkAsRead(item.id)}
+ disabled={isBusy}
+ >
+
+ {t("mark_as_read") || "Mark as read"}
+
+
+ >
+ )}
+ handleToggleArchive(item.id, !!isArchived)}
+ disabled={isArchiving}
+ >
+ {isArchived ? (
+ <>
+
+ {t("unarchive") || "Restore"}
+ >
+ ) : (
+ <>
+
+ {t("archive") || "Archive"}
+ >
+ )}
+
+
+
+
+
+ );
+ })}
+
+ ) : 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/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/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/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/notification.types.ts b/surfsense_web/contracts/types/inbox.types.ts
similarity index 62%
rename from surfsense_web/contracts/types/notification.types.ts
rename to surfsense_web/contracts/types/inbox.types.ts
index b2b39d26e..515ba5864 100644
--- a/surfsense_web/contracts/types/notification.types.ts
+++ b/surfsense_web/contracts/types/inbox.types.ts
@@ -3,18 +3,18 @@ import { searchSourceConnectorTypeEnum } from "./connector.types";
import { documentTypeEnum } from "./document.types";
/**
- * Notification type enum - matches backend notification types
+ * Inbox item type enum - matches backend notification types
*/
-export const notificationTypeEnum = z.enum([
+export const inboxItemTypeEnum = z.enum([
"connector_indexing",
"document_processing",
"new_mention",
]);
/**
- * Notification status enum - used in metadata
+ * Inbox item status enum - used in metadata
*/
-export const notificationStatusEnum = z.enum(["in_progress", "completed", "failed"]);
+export const inboxItemStatusEnum = z.enum(["in_progress", "completed", "failed"]);
/**
* Document processing stage enum
@@ -30,11 +30,11 @@ export const documentProcessingStageEnum = z.enum([
]);
/**
- * Base metadata schema shared across notification types
+ * Base metadata schema shared across inbox item types
*/
-export const baseNotificationMetadata = z.object({
+export const baseInboxItemMetadata = z.object({
operation_id: z.string().optional(),
- status: notificationStatusEnum.optional(),
+ status: inboxItemStatusEnum.optional(),
started_at: z.string().optional(),
completed_at: z.string().optional(),
});
@@ -42,7 +42,7 @@ export const baseNotificationMetadata = z.object({
/**
* Connector indexing metadata schema
*/
-export const connectorIndexingMetadata = baseNotificationMetadata.extend({
+export const connectorIndexingMetadata = baseInboxItemMetadata.extend({
connector_id: z.number(),
connector_name: z.string(),
connector_type: searchSourceConnectorTypeEnum,
@@ -62,7 +62,7 @@ export const connectorIndexingMetadata = baseNotificationMetadata.extend({
/**
* Document processing metadata schema
*/
-export const documentProcessingMetadata = baseNotificationMetadata.extend({
+export const documentProcessingMetadata = baseInboxItemMetadata.extend({
document_type: documentTypeEnum,
document_name: z.string(),
processing_stage: documentProcessingStageEnum,
@@ -89,24 +89,24 @@ export const newMentionMetadata = z.object({
});
/**
- * Union of all notification metadata types
- * Use this when the notification type is unknown
+ * Union of all inbox item metadata types
+ * Use this when the inbox item type is unknown
*/
-export const notificationMetadata = z.union([
+export const inboxItemMetadata = z.union([
connectorIndexingMetadata,
documentProcessingMetadata,
newMentionMetadata,
- baseNotificationMetadata,
+ baseInboxItemMetadata,
]);
/**
- * Main notification schema
+ * Main inbox item schema
*/
-export const notification = z.object({
+export const inboxItem = z.object({
id: z.number(),
user_id: z.string(),
search_space_id: z.number().nullable(),
- type: notificationTypeEnum,
+ type: inboxItemTypeEnum,
title: z.string(),
message: z.string(),
read: z.boolean(),
@@ -116,33 +116,34 @@ export const notification = z.object({
});
/**
- * Typed notification schemas for specific notification types
+ * Typed inbox item schemas for specific types
*/
-export const connectorIndexingNotification = notification.extend({
+export const connectorIndexingInboxItem = inboxItem.extend({
type: z.literal("connector_indexing"),
metadata: connectorIndexingMetadata,
});
-export const documentProcessingNotification = notification.extend({
+export const documentProcessingInboxItem = inboxItem.extend({
type: z.literal("document_processing"),
metadata: documentProcessingMetadata,
});
-export const newMentionNotification = notification.extend({
+export const newMentionInboxItem = inboxItem.extend({
type: z.literal("new_mention"),
metadata: newMentionMetadata,
});
// Inferred types
-export type NotificationTypeEnum = z.infer;
-export type NotificationStatusEnum = z.infer;
+export type InboxItemTypeEnum = z.infer;
+export type InboxItemStatusEnum = z.infer;
export type DocumentProcessingStageEnum = z.infer;
-export type BaseNotificationMetadata = z.infer;
+export type BaseInboxItemMetadata = 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;
+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;
+
diff --git a/surfsense_web/hooks/use-notifications.ts b/surfsense_web/hooks/use-inbox.ts
similarity index 65%
rename from surfsense_web/hooks/use-notifications.ts
rename to surfsense_web/hooks/use-inbox.ts
index eca00a935..afd3675ce 100644
--- a/surfsense_web/hooks/use-notifications.ts
+++ b/surfsense_web/hooks/use-inbox.ts
@@ -1,38 +1,38 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
-import type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
+import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.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";
+export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
/**
- * Hook for managing notifications with Electric SQL real-time sync
+ * Hook for managing inbox items 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)
+ * - User-level sync: Syncs ALL inbox items for a user (runs once per user)
+ * - Search-space-level query: Filters inbox items 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)
+ * @param userId - The user ID to fetch inbox items for
+ * @param searchSpaceId - The search space ID to filter inbox items (null shows global items only)
+ * @param typeFilter - Optional inbox item type to filter by (null shows all types)
*/
-export function useNotifications(
+export function useInbox(
userId: string | null,
searchSpaceId: number | null,
- typeFilter: NotificationTypeEnum | null = null
+ typeFilter: InboxItemTypeEnum | null = null
) {
// Get Electric client from context - ElectricProvider handles initialization
const electricClient = useElectricClient();
- const [notifications, setNotifications] = useState([]);
+ const [inboxItems, setInboxItems] = useState([]);
const [totalUnreadCount, setTotalUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -43,34 +43,37 @@ export function useNotifications(
// 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
+ // EFFECT 1: User-level sync - runs once per user, syncs ALL inbox items
useEffect(() => {
if (!userId || !electricClient) {
setLoading(!electricClient);
return;
}
- const userSyncKey = `notifications_${userId}`;
+ const userSyncKey = `inbox_${userId}`;
if (userSyncKeyRef.current === userSyncKey) {
// Already syncing for this user
return;
}
+ // Capture electricClient to satisfy TypeScript in async function
+ const client = electricClient;
let mounted = true;
userSyncKeyRef.current = userSyncKey;
async function startUserSync() {
try {
- console.log("[useNotifications] Starting user-level sync for:", userId);
+ console.log("[useInbox] Starting user-level sync for:", userId);
- // Sync ALL notifications for this user (cached via syncShape caching)
- const handle = await electricClient.syncShape({
+ // Sync ALL inbox items for this user (cached via syncShape caching)
+ // Note: Backend table is still named "notifications"
+ const handle = await client.syncShape({
table: "notifications",
where: `user_id = '${userId}'`,
primaryKey: ["id"],
});
- console.log("[useNotifications] User sync started:", {
+ console.log("[useInbox] User sync started:", {
isUpToDate: handle.isUpToDate,
});
@@ -82,7 +85,7 @@ export function useNotifications(
new Promise((resolve) => setTimeout(resolve, 2000)),
]);
} catch (syncErr) {
- console.error("[useNotifications] Initial sync failed:", syncErr);
+ console.error("[useInbox] Initial sync failed:", syncErr);
}
}
@@ -96,8 +99,8 @@ export function useNotifications(
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"));
+ console.error("[useInbox] Failed to start user sync:", err);
+ setError(err instanceof Error ? err : new Error("Failed to sync inbox"));
setLoading(false);
}
}
@@ -122,10 +125,12 @@ export function useNotifications(
return;
}
+ // Capture electricClient to satisfy TypeScript in async function
+ const client = electricClient;
let mounted = true;
async function updateQuery() {
- // Clean up previous live query (but DON'T clear notifications - keep showing old until new arrive)
+ // Clean up previous live query (but DON'T clear inbox items - keep showing old until new arrive)
if (liveQueryRef.current) {
liveQueryRef.current.unsubscribe();
liveQueryRef.current = null;
@@ -133,13 +138,14 @@ export function useNotifications(
try {
console.log(
- "[useNotifications] Updating query for searchSpace:",
+ "[useInbox] Updating query for searchSpace:",
searchSpaceId,
"typeFilter:",
typeFilter
);
// Build query with optional type filter
+ // Note: Backend table is still named "notifications"
const baseQuery = `SELECT * FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)`;
@@ -148,16 +154,15 @@ export function useNotifications(
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);
+ // Fetch inbox items for current search space immediately
+ const result = await client.db.query(fullQuery, params);
if (mounted) {
- setNotifications(result.rows || []);
+ setInboxItems(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;
+ const db = client.db as any;
if (db.live?.query && typeof db.live.query === "function") {
const liveQuery = await db.live.query(fullQuery, params);
@@ -169,16 +174,16 @@ export function useNotifications(
// Set initial results from live query
if (liveQuery.initialResults?.rows) {
- setNotifications(liveQuery.initialResults.rows);
+ setInboxItems(liveQuery.initialResults.rows);
} else if (liveQuery.rows) {
- setNotifications(liveQuery.rows);
+ setInboxItems(liveQuery.rows);
}
// Subscribe to changes
if (typeof liveQuery.subscribe === "function") {
- liveQuery.subscribe((result: { rows: Notification[] }) => {
+ liveQuery.subscribe((result: { rows: InboxItem[] }) => {
if (mounted && result.rows) {
- setNotifications(result.rows);
+ setInboxItems(result.rows);
}
});
}
@@ -188,7 +193,7 @@ export function useNotifications(
}
}
} catch (err) {
- console.error("[useNotifications] Failed to update query:", err);
+ console.error("[useInbox] Failed to update query:", err);
}
}
@@ -210,6 +215,8 @@ export function useNotifications(
return;
}
+ // Capture electricClient to satisfy TypeScript in async function
+ const client = electricClient;
let mounted = true;
async function updateUnreadCount() {
@@ -220,13 +227,14 @@ export function useNotifications(
}
try {
+ // Note: Backend table is still named "notifications"
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, [
+ const result = await client.db.query<{ count: number }>(countQuery, [
userId,
searchSpaceId,
]);
@@ -236,8 +244,7 @@ export function useNotifications(
}
// Set up live query for real-time updates
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const db = electricClient.db as any;
+ const db = client.db as any;
if (db.live?.query && typeof db.live.query === "function") {
const liveQuery = await db.live.query(countQuery, [userId, searchSpaceId]);
@@ -268,7 +275,7 @@ export function useNotifications(
}
}
} catch (err) {
- console.error("[useNotifications] Failed to update unread count:", err);
+ console.error("[useInbox] Failed to update unread count:", err);
}
}
@@ -283,29 +290,31 @@ export function useNotifications(
};
}, [userId, searchSpaceId, electricClient]);
- // Mark notification as read via backend API
- const markAsRead = useCallback(async (notificationId: number) => {
+ // Mark inbox item as read via backend API
+ const markAsRead = useCallback(async (itemId: number) => {
try {
+ // Note: Backend API endpoint is still /notifications/
const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${notificationId}/read`,
+ `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/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");
+ throw new Error(error.detail || "Failed to mark inbox item as read");
}
return true;
} catch (err) {
- console.error("Failed to mark notification as read:", err);
+ console.error("Failed to mark inbox item as read:", err);
return false;
}
}, []);
- // Mark all notifications as read via backend API
+ // Mark all inbox items as read via backend API
const markAllAsRead = useCallback(async () => {
try {
+ // Note: Backend API endpoint is still /notifications/
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`,
{ method: "PATCH" }
@@ -313,22 +322,48 @@ export function useNotifications(
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");
+ throw new Error(error.detail || "Failed to mark all inbox items as read");
}
return true;
} catch (err) {
- console.error("Failed to mark all notifications as read:", err);
+ console.error("Failed to mark all inbox items as read:", err);
+ return false;
+ }
+ }, []);
+
+ // Archive/unarchive an inbox item via backend API
+ const archiveItem = useCallback(async (itemId: number, archived: boolean) => {
+ try {
+ const response = await authenticatedFetch(
+ `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/archive`,
+ {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ archived }),
+ }
+ );
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ detail: "Failed to update archive status" }));
+ throw new Error(error.detail || "Failed to update inbox item archive status");
+ }
+
+ return true;
+ } catch (err) {
+ console.error("Failed to update inbox item archive status:", err);
return false;
}
}, []);
return {
- notifications,
+ inboxItems,
unreadCount: totalUnreadCount,
markAsRead,
markAllAsRead,
+ archiveItem,
loading,
error,
};
}
+
diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json
index cda522b61..59d948769 100644
--- a/surfsense_web/messages/en.json
+++ b/surfsense_web/messages/en.json
@@ -692,7 +692,22 @@
"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",
+ "unread_and_read": "Unread & read",
+ "unread": "Unread",
+ "archived": "Archived"
},
"errors": {
"something_went_wrong": "Something went wrong",
diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json
index 7f2f49cfc..09b080c27 100644
--- a/surfsense_web/messages/zh.json
+++ b/surfsense_web/messages/zh.json
@@ -677,7 +677,18 @@
"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": "文档和连接器更新将显示在这里"
},
"errors": {
"something_went_wrong": "出错了",
From 22b2d6e400412296b4e3821bf02e0b3de4cb0367 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 21 Jan 2026 20:34:58 +0530
Subject: [PATCH 06/20] feat: add archived column to notifications and
implement archiving functionality
- Introduced an archived boolean column in the notifications table to allow users to archive inbox items without deletion.
- Updated Notification model to include the archived field with default value.
- Added ArchiveRequest and ArchiveResponse models for handling archive/unarchive operations.
- Implemented API endpoint to archive or unarchive notifications, ensuring real-time updates with Electric SQL.
- Enhanced InboxSidebar to filter and display archived notifications appropriately.
---
...73_add_archived_column_to_notifications.py | 51 ++++++++++++++
surfsense_backend/app/db.py | 3 +
.../app/routes/notifications_routes.py | 51 ++++++++++++++
.../ui/sidebar/AllPrivateChatsSidebar.tsx | 17 +----
.../ui/sidebar/AllSharedChatsSidebar.tsx | 17 +----
.../layout/ui/sidebar/InboxSidebar.tsx | 70 ++++++++++++++++---
surfsense_web/contracts/types/inbox.types.ts | 1 +
surfsense_web/lib/electric/client.ts | 7 +-
8 files changed, 178 insertions(+), 39 deletions(-)
create mode 100644 surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py
diff --git a/surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py b/surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py
new file mode 100644
index 000000000..99962e501
--- /dev/null
+++ b/surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py
@@ -0,0 +1,51 @@
+"""Add archived column to notifications table
+
+Revision ID: 73
+Revises: 72
+
+Adds an archived boolean column to the notifications table to allow users
+to archive inbox items without deleting them.
+
+NOTE: Electric SQL automatically picks up schema changes when REPLICA IDENTITY FULL
+is set (which was done in migration 66). We re-affirm it here to ensure replication
+continues to work after adding the new column.
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "73"
+down_revision: str | None = "72"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Add archived column to notifications table."""
+ # Add the archived column with a default value
+ op.execute(
+ """
+ ALTER TABLE notifications
+ ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT FALSE;
+ """
+ )
+
+ # Create index for archived column
+ op.execute(
+ "CREATE INDEX IF NOT EXISTS ix_notifications_archived ON notifications (archived);"
+ )
+
+ # Re-affirm REPLICA IDENTITY FULL for Electric SQL after schema change
+ # This ensures Electric SQL continues to replicate all columns including the new one
+ op.execute("ALTER TABLE notifications REPLICA IDENTITY FULL;")
+
+
+def downgrade() -> None:
+ """Remove archived column from notifications table."""
+ op.execute("DROP INDEX IF EXISTS ix_notifications_archived;")
+ op.execute("ALTER TABLE notifications DROP COLUMN IF EXISTS archived;")
+ # Re-affirm REPLICA IDENTITY FULL after removing the column
+ op.execute("ALTER TABLE notifications REPLICA IDENTITY FULL;")
+
diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py
index 38e27ecf2..b969f9e55 100644
--- a/surfsense_backend/app/db.py
+++ b/surfsense_backend/app/db.py
@@ -784,6 +784,9 @@ class Notification(BaseModel, TimestampMixin):
read = Column(
Boolean, nullable=False, default=False, server_default=text("false"), index=True
)
+ archived = Column(
+ Boolean, nullable=False, default=False, server_default=text("false"), index=True
+ )
notification_metadata = Column("metadata", JSONB, nullable=True, default={})
updated_at = Column(
TIMESTAMP(timezone=True),
diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py
index deee748d8..3bf7a4880 100644
--- a/surfsense_backend/app/routes/notifications_routes.py
+++ b/surfsense_backend/app/routes/notifications_routes.py
@@ -30,6 +30,19 @@ class MarkAllReadResponse(BaseModel):
updated_count: int
+class ArchiveRequest(BaseModel):
+ """Request body for archive/unarchive operations."""
+
+ archived: bool
+
+
+class ArchiveResponse(BaseModel):
+ """Response for archive operations."""
+
+ success: bool
+ message: str
+
+
@router.patch("/{notification_id}/read", response_model=MarkReadResponse)
async def mark_notification_as_read(
notification_id: int,
@@ -100,3 +113,41 @@ async def mark_all_notifications_as_read(
message=f"Marked {updated_count} notification(s) as read",
updated_count=updated_count,
)
+
+
+@router.patch("/{notification_id}/archive", response_model=ArchiveResponse)
+async def archive_notification(
+ notification_id: int,
+ request: ArchiveRequest,
+ user: User = Depends(current_active_user),
+ session: AsyncSession = Depends(get_async_session),
+) -> ArchiveResponse:
+ """
+ Archive or unarchive a notification.
+
+ Electric SQL will automatically sync this change to all connected clients.
+ """
+ # Verify the notification belongs to the user
+ result = await session.execute(
+ select(Notification).where(
+ Notification.id == notification_id,
+ Notification.user_id == user.id,
+ )
+ )
+ notification = result.scalar_one_or_none()
+
+ if not notification:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Notification not found",
+ )
+
+ # Update the notification
+ notification.archived = request.archived
+ await session.commit()
+
+ action = "archived" if request.archived else "unarchived"
+ return ArchiveResponse(
+ success=True,
+ message=f"Notification {action}",
+ )
diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
index fb1a6ed0d..6be4809cf 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
@@ -238,20 +238,9 @@ export function AllPrivateChatsSidebar({
aria-label={t("chats") || "Private Chats"}
>
-
-
-
-
{t("chats") || "Private Chats"}
-
-
onOpenChange(false)}
- >
-
- Close
-
+
+
+
{t("chats") || "Private Chats"}
diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
index f400e6fc8..ea80cc920 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
@@ -238,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"}
diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
index 4171ac267..9f5a50bc4 100644
--- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -143,15 +143,16 @@ export function InboxSidebar({
let items = currentTabItems;
// Apply filter
+ // Note: Use `item.archived === true` to handle undefined/null as false
if (activeFilter === "all") {
// "Unread & read" shows all non-archived items
- items = items.filter((item) => !(item as InboxItem & { archived?: boolean }).archived);
+ items = items.filter((item) => item.archived !== true);
} else if (activeFilter === "unread") {
// "Unread" shows only unread non-archived items
- items = items.filter((item) => !item.read && !(item as InboxItem & { archived?: boolean }).archived);
+ items = items.filter((item) => !item.read && item.archived !== true);
} else if (activeFilter === "archived") {
- // "Archived" shows only archived items
- items = items.filter((item) => (item as InboxItem & { archived?: boolean }).archived);
+ // "Archived" shows only archived items (must be explicitly true)
+ items = items.filter((item) => item.archived === true);
}
// Apply search query
@@ -340,7 +341,7 @@ export function InboxSidebar({
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
- className="fixed inset-y-0 left-0 z-70 w-96 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
+ className="fixed inset-y-0 left-0 z-70 w-90 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
role="dialog"
aria-modal="true"
aria-label={t("inbox") || "Inbox"}
@@ -500,7 +501,7 @@ export function InboxSidebar({
const isMarkingAsRead = markingAsReadId === item.id;
const isArchiving = archivingItemId === item.id;
const isBusy = isMarkingAsRead || isArchiving;
- const isArchived = (item as InboxItem & { archived?: boolean }).archived;
+ const isArchived = item.archived === true;
return (
{convertRenderedToDisplay(item.message)}
+ {/* Mobile action buttons - shown below description on mobile only */}
+
+ {!item.read && (
+
{
+ e.stopPropagation();
+ handleMarkAsRead(item.id);
+ }}
+ disabled={isBusy}
+ >
+ {isMarkingAsRead ? (
+
+ ) : (
+
+ )}
+ {t("mark_as_read") || "Mark as read"}
+
+ )}
+
{
+ e.stopPropagation();
+ handleToggleArchive(item.id, isArchived);
+ }}
+ disabled={isArchiving}
+ >
+ {isArchiving ? (
+
+ ) : isArchived ? (
+
+ ) : (
+
+ )}
+ {isArchived ? (t("unarchive") || "Restore") : (t("archive") || "Archive")}
+
+
@@ -544,8 +586,8 @@ export function InboxSidebar({
- {/* Time/dot and 3-dot button container - swap on hover */}
-
+ {/* Time/dot and 3-dot button container - swap on hover (desktop only) */}
+
{/* Time and unread dot - visible by default, hidden on hover or when dropdown is open */}
)}
handleToggleArchive(item.id, !!isArchived)}
+ onClick={() => handleToggleArchive(item.id, isArchived)}
disabled={isArchiving}
>
{isArchived ? (
@@ -623,6 +665,16 @@ export function InboxSidebar({
+
+ {/* Mobile time and unread dot - always visible on mobile */}
+
+
+ {formatTime(item.created_at)}
+
+ {!item.read && (
+
+ )}
+
);
})}
diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts
index 515ba5864..2e80a9909 100644
--- a/surfsense_web/contracts/types/inbox.types.ts
+++ b/surfsense_web/contracts/types/inbox.types.ts
@@ -110,6 +110,7 @@ export const inboxItem = z.object({
title: z.string(),
message: z.string(),
read: z.boolean(),
+ archived: z.boolean().default(false),
metadata: z.record(z.string(), z.unknown()),
created_at: z.string(),
updated_at: z.string().nullable(),
diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts
index 514185d23..222553f32 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: added archived column to notifications
+const SYNC_VERSION = 3;
// Database name prefix for identifying SurfSense databases
const DB_PREFIX = "surfsense-";
@@ -181,6 +182,7 @@ export async function initElectric(userId: string): Promise {
title TEXT NOT NULL,
message TEXT NOT NULL,
read BOOLEAN NOT NULL DEFAULT FALSE,
+ archived BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
@@ -188,6 +190,7 @@ export async function initElectric(userId: string): Promise {
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read);
+ CREATE INDEX IF NOT EXISTS idx_notifications_archived ON notifications(archived);
`);
// Create the search_source_connectors table schema in PGlite
From 8dcdd27d1059b261e71082faddb60bc7d6259ad2 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 21 Jan 2026 22:22:28 +0530
Subject: [PATCH 07/20] fix: update sidebar components for consistent styling
and improved unread item display
- Changed text color for active and archived item counts in AllPrivateChatsSidebar and AllSharedChatsSidebar to use 'text-muted-foreground' for better visibility.
- Replaced Loader2 with a new Spinner component in InboxSidebar for a consistent loading indicator.
- Enhanced unread item count display in InboxSidebar to show zero when no unread items are present, improving user feedback.
- Adjusted styles for MoreHorizontal and ListFilter icons in InboxSidebar to align with the updated design system.
---
.../ui/sidebar/AllPrivateChatsSidebar.tsx | 6 +-
.../ui/sidebar/AllSharedChatsSidebar.tsx | 6 +-
.../layout/ui/sidebar/InboxSidebar.tsx | 74 ++++++++++---------
surfsense_web/components/ui/spinner.tsx | 35 +++++++++
4 files changed, 81 insertions(+), 40 deletions(-)
create mode 100644 surfsense_web/components/ui/spinner.tsx
diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
index 6be4809cf..78bac3371 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
@@ -280,7 +280,7 @@ export function AllPrivateChatsSidebar({
Active
- {activeCount}
+ {activeCount}
Archived
- {archivedCount}
+ {archivedCount}
@@ -363,7 +363,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 ea80cc920..e3b6174c3 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
@@ -280,7 +280,7 @@ export function AllSharedChatsSidebar({
Active
- {activeCount}
+ {activeCount}
Archived
- {archivedCount}
+ {archivedCount}
@@ -363,7 +363,7 @@ export function AllSharedChatsSidebar({
{isDeleting ? (
) : (
-
+
)}
{t("more_options") || "More options"}
diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
index 9f5a50bc4..f81417a45 100644
--- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -11,7 +11,6 @@ import {
History,
Inbox,
ListFilter,
- Loader2,
MoreHorizontal,
RotateCcw,
Search,
@@ -34,6 +33,7 @@ import {
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 type { InboxItem } from "@/hooks/use-inbox";
@@ -168,16 +168,24 @@ export function InboxSidebar({
return items;
}, [currentTabItems, activeFilter, searchQuery]);
- // Count unread items per tab
- const unreadMentionsCount = useMemo(
- () => mentionItems.filter((item) => !item.read).length,
- [mentionItems]
- );
+ // Count unread items per tab (filter-aware)
+ const unreadMentionsCount = useMemo(() => {
+ if (activeFilter === "archived") {
+ // In archived view, show unread archived items
+ return mentionItems.filter((item) => !item.read && item.archived === true).length;
+ }
+ // For "all" and "unread" filters, show unread non-archived items
+ return mentionItems.filter((item) => !item.read && item.archived !== true).length;
+ }, [mentionItems, activeFilter]);
- const unreadStatusCount = useMemo(
- () => statusItems.filter((item) => !item.read).length,
- [statusItems]
- );
+ const unreadStatusCount = useMemo(() => {
+ if (activeFilter === "archived") {
+ // In archived view, show unread archived items
+ return statusItems.filter((item) => !item.read && item.archived === true).length;
+ }
+ // For "all" and "unread" filters, show unread non-archived items
+ return statusItems.filter((item) => !item.read && item.archived !== true).length;
+ }, [statusItems, activeFilter]);
const handleItemClick = useCallback(
async (item: InboxItem) => {
@@ -283,7 +291,7 @@ export function InboxSidebar({
case "in_progress":
return (
-
+
);
case "completed":
@@ -363,7 +371,7 @@ export function InboxSidebar({
size="icon"
className="h-8 w-8 rounded-full"
>
-
+
{t("filter") || "Filter"}
@@ -413,7 +421,7 @@ export function InboxSidebar({
size="icon"
className="h-8 w-8 rounded-full"
>
-
+
{t("more_options") || "More options"}
@@ -466,11 +474,12 @@ export function InboxSidebar({
{t("mentions") || "Mentions"}
- {unreadMentionsCount > 0 && (
-
- {unreadMentionsCount}
-
- )}
+
+ {unreadMentionsCount || 0}
+
{t("status") || "Status"}
- {unreadStatusCount > 0 && (
-
- {unreadStatusCount}
-
- )}
+
+ {unreadStatusCount || 0}
+
@@ -493,7 +503,7 @@ export function InboxSidebar({
{loading ? (
-
+
) : filteredItems.length > 0 ? (
@@ -507,7 +517,7 @@ export function InboxSidebar({
- {isMarkingAsRead ? (
-
- ) : (
-
- )}
+
{t("mark_as_read") || "Mark as read"}
)}
@@ -566,7 +572,7 @@ export function InboxSidebar({
disabled={isArchiving}
>
{isArchiving ? (
-
+
) : isArchived ? (
) : (
@@ -623,10 +629,10 @@ export function InboxSidebar({
)}
disabled={isBusy}
>
- {isBusy ? (
-
+ {isArchiving ? (
+
) : (
-
+
)}
{t("more_options") || "More options"}
diff --git a/surfsense_web/components/ui/spinner.tsx b/surfsense_web/components/ui/spinner.tsx
new file mode 100644
index 000000000..483cdf73f
--- /dev/null
+++ b/surfsense_web/components/ui/spinner.tsx
@@ -0,0 +1,35 @@
+import { cn } from "@/lib/utils";
+
+interface SpinnerProps {
+ /** Size of the spinner */
+ size?: "xs" | "sm" | "md" | "lg" | "xl";
+ /** Whether to hide the track behind the spinner arc */
+ hideTrack?: boolean;
+ /** Additional classes to apply */
+ className?: string;
+}
+
+const sizeClasses = {
+ xs: "h-3 w-3 border-[1.5px]",
+ sm: "h-4 w-4 border-2",
+ md: "h-6 w-6 border-2",
+ lg: "h-8 w-8 border-[3px]",
+ xl: "h-10 w-10 border-4",
+};
+
+export function Spinner({ size = "md", hideTrack = false, className }: SpinnerProps) {
+ return (
+
+ );
+}
+
From 112f6ec4cced341811832c1ad79cc64944bfe146 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 21 Jan 2026 22:47:39 +0530
Subject: [PATCH 08/20] refactor: remove archived functionality from
notifications and related components
- Removed the archived column from the Notification model and database schema, simplifying the notification structure.
- Deleted ArchiveRequest and ArchiveResponse models, along with associated API endpoints for archiving notifications.
- Updated InboxSidebar and related components to eliminate archiving functionality, streamlining the user experience.
- Adjusted filtering logic in the InboxSidebar to focus solely on unread notifications, enhancing clarity and usability.
---
surfsense_backend/app/db.py | 3 -
.../app/routes/notifications_routes.py | 51 -----
.../layout/providers/LayoutDataProvider.tsx | 5 +-
.../layout/ui/sidebar/InboxSidebar.tsx | 208 ++----------------
surfsense_web/components/ui/spinner.tsx | 5 +-
surfsense_web/contracts/types/inbox.types.ts | 1 -
surfsense_web/hooks/use-inbox.ts | 25 ---
surfsense_web/lib/electric/client.ts | 5 +-
8 files changed, 24 insertions(+), 279 deletions(-)
diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py
index b969f9e55..38e27ecf2 100644
--- a/surfsense_backend/app/db.py
+++ b/surfsense_backend/app/db.py
@@ -784,9 +784,6 @@ class Notification(BaseModel, TimestampMixin):
read = Column(
Boolean, nullable=False, default=False, server_default=text("false"), index=True
)
- archived = Column(
- Boolean, nullable=False, default=False, server_default=text("false"), index=True
- )
notification_metadata = Column("metadata", JSONB, nullable=True, default={})
updated_at = Column(
TIMESTAMP(timezone=True),
diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py
index 3bf7a4880..deee748d8 100644
--- a/surfsense_backend/app/routes/notifications_routes.py
+++ b/surfsense_backend/app/routes/notifications_routes.py
@@ -30,19 +30,6 @@ class MarkAllReadResponse(BaseModel):
updated_count: int
-class ArchiveRequest(BaseModel):
- """Request body for archive/unarchive operations."""
-
- archived: bool
-
-
-class ArchiveResponse(BaseModel):
- """Response for archive operations."""
-
- success: bool
- message: str
-
-
@router.patch("/{notification_id}/read", response_model=MarkReadResponse)
async def mark_notification_as_read(
notification_id: int,
@@ -113,41 +100,3 @@ async def mark_all_notifications_as_read(
message=f"Marked {updated_count} notification(s) as read",
updated_count=updated_count,
)
-
-
-@router.patch("/{notification_id}/archive", response_model=ArchiveResponse)
-async def archive_notification(
- notification_id: int,
- request: ArchiveRequest,
- user: User = Depends(current_active_user),
- session: AsyncSession = Depends(get_async_session),
-) -> ArchiveResponse:
- """
- Archive or unarchive a notification.
-
- Electric SQL will automatically sync this change to all connected clients.
- """
- # Verify the notification belongs to the user
- result = await session.execute(
- select(Notification).where(
- Notification.id == notification_id,
- Notification.user_id == user.id,
- )
- )
- notification = result.scalar_one_or_none()
-
- if not notification:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="Notification not found",
- )
-
- # Update the notification
- notification.archived = request.archived
- await session.commit()
-
- action = "archived" if request.archived else "unarchived"
- return ArchiveResponse(
- success=True,
- message=f"Notification {action}",
- )
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index 53f33f27b..aa7a06c81 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 { Inbox, 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";
@@ -87,7 +87,7 @@ export function LayoutDataProvider({
// Inbox hook
const userId = user?.id ? String(user.id) : null;
- const { inboxItems, unreadCount, loading: inboxLoading, markAsRead, markAllAsRead, archiveItem } = useInbox(
+ const { inboxItems, unreadCount, loading: inboxLoading, markAsRead, markAllAsRead } = useInbox(
userId,
Number(searchSpaceId) || null,
null
@@ -551,7 +551,6 @@ export function LayoutDataProvider({
loading={inboxLoading}
markAsRead={markAsRead}
markAllAsRead={markAllAsRead}
- archiveItem={archiveItem}
/>
{/* Create Search Space Dialog */}
diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
index f81417a45..9e9ed2d21 100644
--- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -2,7 +2,6 @@
import {
AlertCircle,
- Archive,
AtSign,
BellDot,
Check,
@@ -12,7 +11,6 @@ import {
Inbox,
ListFilter,
MoreHorizontal,
- RotateCcw,
Search,
X,
} from "lucide-react";
@@ -29,7 +27,6 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
- DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
@@ -59,7 +56,7 @@ function getInitials(name: string | null | undefined, email: string | null | und
}
type InboxTab = "mentions" | "status";
-type InboxFilter = "all" | "unread" | "archived";
+type InboxFilter = "all" | "unread";
interface InboxSidebarProps {
open: boolean;
@@ -69,7 +66,6 @@ interface InboxSidebarProps {
loading: boolean;
markAsRead: (id: number) => Promise;
markAllAsRead: () => Promise;
- archiveItem: (id: number, archived: boolean) => Promise;
onCloseMobileSidebar?: () => void;
}
@@ -81,7 +77,6 @@ export function InboxSidebar({
loading,
markAsRead,
markAllAsRead,
- archiveItem,
onCloseMobileSidebar,
}: InboxSidebarProps) {
const t = useTranslations("sidebar");
@@ -91,10 +86,9 @@ export function InboxSidebar({
const [activeTab, setActiveTab] = useState("mentions");
const [activeFilter, setActiveFilter] = useState("all");
const [mounted, setMounted] = useState(false);
- // Unified dropdown state: "filter" | "options" | number (item id) | null
- const [openDropdown, setOpenDropdown] = useState<"filter" | "options" | number | null>(null);
+ // Dropdown state for filter and options menus
+ const [openDropdown, setOpenDropdown] = useState<"filter" | "options" | null>(null);
const [markingAsReadId, setMarkingAsReadId] = useState(null);
- const [archivingItemId, setArchivingItemId] = useState(null);
useEffect(() => {
setMounted(true);
@@ -143,16 +137,8 @@ export function InboxSidebar({
let items = currentTabItems;
// Apply filter
- // Note: Use `item.archived === true` to handle undefined/null as false
- if (activeFilter === "all") {
- // "Unread & read" shows all non-archived items
- items = items.filter((item) => item.archived !== true);
- } else if (activeFilter === "unread") {
- // "Unread" shows only unread non-archived items
- items = items.filter((item) => !item.read && item.archived !== true);
- } else if (activeFilter === "archived") {
- // "Archived" shows only archived items (must be explicitly true)
- items = items.filter((item) => item.archived === true);
+ if (activeFilter === "unread") {
+ items = items.filter((item) => !item.read);
}
// Apply search query
@@ -168,24 +154,14 @@ export function InboxSidebar({
return items;
}, [currentTabItems, activeFilter, searchQuery]);
- // Count unread items per tab (filter-aware)
+ // Count unread items per tab
const unreadMentionsCount = useMemo(() => {
- if (activeFilter === "archived") {
- // In archived view, show unread archived items
- return mentionItems.filter((item) => !item.read && item.archived === true).length;
- }
- // For "all" and "unread" filters, show unread non-archived items
- return mentionItems.filter((item) => !item.read && item.archived !== true).length;
- }, [mentionItems, activeFilter]);
+ return mentionItems.filter((item) => !item.read).length;
+ }, [mentionItems]);
const unreadStatusCount = useMemo(() => {
- if (activeFilter === "archived") {
- // In archived view, show unread archived items
- return statusItems.filter((item) => !item.read && item.archived === true).length;
- }
- // For "all" and "unread" filters, show unread non-archived items
- return statusItems.filter((item) => !item.read && item.archived !== true).length;
- }, [statusItems, activeFilter]);
+ return statusItems.filter((item) => !item.read).length;
+ }, [statusItems]);
const handleItemClick = useCallback(
async (item: InboxItem) => {
@@ -217,28 +193,10 @@ export function InboxSidebar({
[markAsRead, router, onOpenChange, onCloseMobileSidebar]
);
- const handleMarkAsRead = useCallback(
- async (itemId: number) => {
- setMarkingAsReadId(itemId);
- await markAsRead(itemId);
- setMarkingAsReadId(null);
- },
- [markAsRead]
- );
-
const handleMarkAllAsRead = useCallback(async () => {
await markAllAsRead();
}, [markAllAsRead]);
- const handleToggleArchive = useCallback(
- async (itemId: number, currentlyArchived: boolean) => {
- setArchivingItemId(itemId);
- await archiveItem(itemId, !currentlyArchived);
- setArchivingItemId(null);
- },
- [archiveItem]
- );
-
const handleClearSearch = useCallback(() => {
setSearchQuery("");
}, []);
@@ -385,7 +343,7 @@ export function InboxSidebar({
>
- {t("unread_and_read") || "Unread & read"}
+ {t("all") || "All"}
{activeFilter === "all" && }
@@ -399,16 +357,6 @@ export function InboxSidebar({
{activeFilter === "unread" &&
}
-
setActiveFilter("archived")}
- className="flex items-center justify-between"
- >
-
-
- {t("archived") || "Archived"}
-
- {activeFilter === "archived" && }
-
{filteredItems.map((item) => {
const isMarkingAsRead = markingAsReadId === item.id;
- const isArchiving = archivingItemId === item.id;
- const isBusy = isMarkingAsRead || isArchiving;
- const isArchived = item.archived === true;
return (
@@ -528,8 +473,8 @@ export function InboxSidebar({
handleItemClick(item)}
- disabled={isBusy}
- className="flex items-start gap-3 flex-1 min-w-0 text-left overflow-hidden self-start"
+ disabled={isMarkingAsRead}
+ className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
>
{getStatusIcon(item)}
@@ -544,43 +489,6 @@ export function InboxSidebar({
{convertRenderedToDisplay(item.message)}
- {/* Mobile action buttons - shown below description on mobile only */}
-
- {!item.read && (
-
{
- e.stopPropagation();
- handleMarkAsRead(item.id);
- }}
- disabled={isBusy}
- >
-
- {t("mark_as_read") || "Mark as read"}
-
- )}
-
{
- e.stopPropagation();
- handleToggleArchive(item.id, isArchived);
- }}
- disabled={isArchiving}
- >
- {isArchiving ? (
-
- ) : isArchived ? (
-
- ) : (
-
- )}
- {isArchived ? (t("unarchive") || "Restore") : (t("archive") || "Archive")}
-
-
@@ -592,93 +500,13 @@ export function InboxSidebar({
- {/* Time/dot and 3-dot button container - swap on hover (desktop only) */}
-
- {/* Time and unread dot - visible by default, hidden on hover or when dropdown is open */}
-
-
- {formatTime(item.created_at)}
-
- {!item.read && (
-
- )}
-
-
- {/* 3-dot menu - hidden by default, visible on hover or when dropdown is open */}
-
- setOpenDropdown(isOpen ? item.id : null)
- }
- >
-
-
- {isArchiving ? (
-
- ) : (
-
- )}
-
- {t("more_options") || "More options"}
-
-
-
-
- {!item.read && (
- <>
- handleMarkAsRead(item.id)}
- disabled={isBusy}
- >
-
- {t("mark_as_read") || "Mark as read"}
-
-
- >
- )}
- handleToggleArchive(item.id, isArchived)}
- disabled={isArchiving}
- >
- {isArchived ? (
- <>
-
- {t("unarchive") || "Restore"}
- >
- ) : (
- <>
-
- {t("archive") || "Archive"}
- >
- )}
-
-
-
-
-
- {/* Mobile time and unread dot - always visible on mobile */}
-
+ {/* Time and unread dot - fixed width to prevent content shift */}
+
{formatTime(item.created_at)}
{!item.read && (
-
+
)}
diff --git a/surfsense_web/components/ui/spinner.tsx b/surfsense_web/components/ui/spinner.tsx
index 483cdf73f..eeed30a8a 100644
--- a/surfsense_web/components/ui/spinner.tsx
+++ b/surfsense_web/components/ui/spinner.tsx
@@ -19,11 +19,10 @@ const sizeClasses = {
export function Spinner({ size = "md", hideTrack = false, className }: SpinnerProps) {
return (
-
{
- try {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/archive`,
- {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ archived }),
- }
- );
-
- if (!response.ok) {
- const error = await response.json().catch(() => ({ detail: "Failed to update archive status" }));
- throw new Error(error.detail || "Failed to update inbox item archive status");
- }
-
- return true;
- } catch (err) {
- console.error("Failed to update inbox item archive status:", err);
- return false;
- }
- }, []);
-
return {
inboxItems,
unreadCount: totalUnreadCount,
markAsRead,
markAllAsRead,
- archiveItem,
loading,
error,
};
diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts
index 222553f32..52f46d96d 100644
--- a/surfsense_web/lib/electric/client.ts
+++ b/surfsense_web/lib/electric/client.ts
@@ -55,7 +55,8 @@ const pendingSyncs = new Map>();
// Version for sync state - increment this to force fresh sync when Electric config changes
// v2: user-specific database architecture
// v3: added archived column to notifications
-const SYNC_VERSION = 3;
+// v4: removed archived column from notifications
+const SYNC_VERSION = 4;
// Database name prefix for identifying SurfSense databases
const DB_PREFIX = "surfsense-";
@@ -182,7 +183,6 @@ export async function initElectric(userId: string): Promise {
title TEXT NOT NULL,
message TEXT NOT NULL,
read BOOLEAN NOT NULL DEFAULT FALSE,
- archived BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
@@ -190,7 +190,6 @@ export async function initElectric(userId: string): Promise {
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_read ON notifications(read);
- CREATE INDEX IF NOT EXISTS idx_notifications_archived ON notifications(archived);
`);
// Create the search_source_connectors table schema in PGlite
From 306a725b7c980419057a074f37d1300ae8fb42c6 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Wed, 21 Jan 2026 23:07:08 +0530
Subject: [PATCH 09/20] refactor: streamline InboxSidebar component and improve
tooltip integration
---
.../layout/ui/sidebar/InboxSidebar.tsx | 75 +++++++++----------
surfsense_web/messages/en.json | 5 +-
surfsense_web/messages/zh.json | 5 +-
3 files changed, 40 insertions(+), 45 deletions(-)
diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
index 9e9ed2d21..ac7fb428e 100644
--- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -10,7 +10,6 @@ import {
History,
Inbox,
ListFilter,
- MoreHorizontal,
Search,
X,
} from "lucide-react";
@@ -86,8 +85,8 @@ export function InboxSidebar({
const [activeTab, setActiveTab] = useState("mentions");
const [activeFilter, setActiveFilter] = useState("all");
const [mounted, setMounted] = useState(false);
- // Dropdown state for filter and options menus
- const [openDropdown, setOpenDropdown] = useState<"filter" | "options" | null>(null);
+ // Dropdown state for filter menu
+ const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
const [markingAsReadId, setMarkingAsReadId] = useState(null);
useEffect(() => {
@@ -323,16 +322,23 @@ export function InboxSidebar({
open={openDropdown === "filter"}
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
>
-
-
-
- {t("filter") || "Filter"}
-
-
+
+
+
+
+
+ {t("filter") || "Filter"}
+
+
+
+
+ {t("filter") || "Filter"}
+
+
{t("filter") || "Filter"}
@@ -359,30 +365,23 @@ export function InboxSidebar({
- setOpenDropdown(isOpen ? "options" : null)}
- >
-
+
+
-
- {t("more_options") || "More options"}
-
-
-
-
-
- {t("mark_all_read") || "Mark all as read"}
-
-
-
+
+ {t("mark_all_read") || "Mark all as read"}
+
+
+
+ {t("mark_all_read") || "Mark all as read"}
+
+
@@ -422,11 +421,8 @@ export function InboxSidebar({
{t("mentions") || "Mentions"}
-
- {unreadMentionsCount || 0}
+
+ {unreadMentionsCount}
@@ -437,11 +433,8 @@ export function InboxSidebar({
{t("status") || "Status"}
-
- {unreadStatusCount || 0}
+
+ {unreadStatusCount}
@@ -462,7 +455,7 @@ export function InboxSidebar({
Date: Thu, 22 Jan 2026 02:13:35 +0530
Subject: [PATCH 10/20] feat: add connector filtering to InboxSidebar for
enhanced item management
- Introduced a connector filter in the InboxSidebar to allow users to filter status items by connector type.
- Implemented state management for selected connectors and reset functionality when switching tabs.
- Enhanced the filtering logic to accommodate connector selection alongside existing filters.
- Added UI elements for displaying available connectors as filter options in the status tab.
---
.../layout/ui/sidebar/InboxSidebar.tsx | 84 ++++++++++++++++++-
1 file changed, 81 insertions(+), 3 deletions(-)
diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
index ac7fb428e..2b57e5e6e 100644
--- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -32,7 +32,9 @@ 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 type { ConnectorIndexingMetadata } from "@/contracts/types/inbox.types";
import { cn } from "@/lib/utils";
/**
@@ -84,6 +86,7 @@ export function InboxSidebar({
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
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
@@ -114,6 +117,13 @@ export function InboxSidebar({
};
}, [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"),
@@ -128,18 +138,48 @@ export function InboxSidebar({
[inboxItems]
);
+ // Get unique connectors from status items for filtering
+ const uniqueConnectors = useMemo(() => {
+ const connectorMap = new Map();
+
+ statusItems
+ .filter((item) => item.type === "connector_indexing")
+ .forEach((item) => {
+ const metadata = item.metadata as ConnectorIndexingMetadata;
+ if (metadata?.connector_type && !connectorMap.has(metadata.connector_type)) {
+ connectorMap.set(metadata.connector_type, {
+ type: metadata.connector_type,
+ name: metadata.connector_name || metadata.connector_type,
+ });
+ }
+ });
+
+ return Array.from(connectorMap.values());
+ }, [statusItems]);
+
// Get items for current tab
const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems;
- // Filter items based on filter type and search query
+ // Filter items based on filter type, connector filter, and search query
const filteredItems = useMemo(() => {
let items = currentTabItems;
- // Apply filter
+ // 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") {
+ const metadata = item.metadata as ConnectorIndexingMetadata;
+ return metadata?.connector_type === selectedConnector;
+ }
+ return false; // Hide document_processing when a specific connector is selected
+ });
+ }
+
// Apply search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
@@ -151,7 +191,7 @@ export function InboxSidebar({
}
return items;
- }, [currentTabItems, activeFilter, searchQuery]);
+ }, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]);
// Count unread items per tab
const unreadMentionsCount = useMemo(() => {
@@ -441,6 +481,44 @@ export function InboxSidebar({
+ {/* Connector filter chips - only show in status tab when there are connectors */}
+ {activeTab === "status" && uniqueConnectors.length > 0 && (
+
+ {/* Left shadow indicator */}
+
+ {/* Right shadow indicator */}
+
+
+ setSelectedConnector(null)}
+ >
+ {t("all") || "All"}
+
+ {uniqueConnectors.map((connector) => (
+
+
+ setSelectedConnector(connector.type)}
+ >
+ {getConnectorIcon(connector.type, "h-3.5 w-3.5")}
+ {connector.name}
+
+
+
+ {connector.name}
+
+
+ ))}
+
+
+ )}
+
{loading ? (
From 57baeda7677ac75be7b072e4fce0fce0ecd92249 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 22 Jan 2026 02:14:46 +0530
Subject: [PATCH 11/20] refactor: remove archived column migration for
notifications
---
...73_add_archived_column_to_notifications.py | 51 -------------------
1 file changed, 51 deletions(-)
delete mode 100644 surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py
diff --git a/surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py b/surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py
deleted file mode 100644
index 99962e501..000000000
--- a/surfsense_backend/alembic/versions/73_add_archived_column_to_notifications.py
+++ /dev/null
@@ -1,51 +0,0 @@
-"""Add archived column to notifications table
-
-Revision ID: 73
-Revises: 72
-
-Adds an archived boolean column to the notifications table to allow users
-to archive inbox items without deleting them.
-
-NOTE: Electric SQL automatically picks up schema changes when REPLICA IDENTITY FULL
-is set (which was done in migration 66). We re-affirm it here to ensure replication
-continues to work after adding the new column.
-"""
-
-from collections.abc import Sequence
-
-from alembic import op
-
-# revision identifiers, used by Alembic.
-revision: str = "73"
-down_revision: str | None = "72"
-branch_labels: str | Sequence[str] | None = None
-depends_on: str | Sequence[str] | None = None
-
-
-def upgrade() -> None:
- """Add archived column to notifications table."""
- # Add the archived column with a default value
- op.execute(
- """
- ALTER TABLE notifications
- ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT FALSE;
- """
- )
-
- # Create index for archived column
- op.execute(
- "CREATE INDEX IF NOT EXISTS ix_notifications_archived ON notifications (archived);"
- )
-
- # Re-affirm REPLICA IDENTITY FULL for Electric SQL after schema change
- # This ensures Electric SQL continues to replicate all columns including the new one
- op.execute("ALTER TABLE notifications REPLICA IDENTITY FULL;")
-
-
-def downgrade() -> None:
- """Remove archived column from notifications table."""
- op.execute("DROP INDEX IF EXISTS ix_notifications_archived;")
- op.execute("ALTER TABLE notifications DROP COLUMN IF EXISTS archived;")
- # Re-affirm REPLICA IDENTITY FULL after removing the column
- op.execute("ALTER TABLE notifications REPLICA IDENTITY FULL;")
-
From 596515f5236cada751dd1d37736369a05250ec96 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 22 Jan 2026 04:02:32 +0530
Subject: [PATCH 12/20] feat: integrate Drawer component for mobile filtering
in InboxSidebar
- Added a Drawer component to replace the dropdown menu for filtering in the InboxSidebar on mobile devices, enhancing usability.
- Implemented state management for the filter drawer, allowing users to select filters and connectors seamlessly.
- Updated the UI to accommodate the new Drawer, ensuring a consistent experience across different screen sizes.
- Enhanced connector filtering logic to improve item management and display options effectively.
---
.../comment-panel/comment-panel.tsx | 4 +-
.../comment-sheet/comment-sheet.tsx | 40 ++-
.../layout/ui/sidebar/InboxSidebar.tsx | 333 +++++++++++++-----
surfsense_web/components/ui/drawer.tsx | 140 ++++++++
surfsense_web/components/ui/sheet.tsx | 4 +-
surfsense_web/messages/en.json | 4 +-
surfsense_web/messages/zh.json | 4 +-
surfsense_web/package.json | 1 +
surfsense_web/pnpm-lock.yaml | 18 +
9 files changed, 445 insertions(+), 103 deletions(-)
create mode 100644 surfsense_web/components/ui/drawer.tsx
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/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
index 2b57e5e6e..ebe537869 100644
--- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -21,6 +21,13 @@ 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,
@@ -34,6 +41,7 @@ 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 } from "@/contracts/types/inbox.types";
import { cn } from "@/lib/utils";
@@ -56,6 +64,40 @@ function getInitials(name: string | null | undefined, email: string | null | und
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";
@@ -82,14 +124,17 @@ export function InboxSidebar({
}: 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
+ // 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);
useEffect(() => {
@@ -138,23 +183,23 @@ export function InboxSidebar({
[inboxItems]
);
- // Get unique connectors from status items for filtering
- const uniqueConnectors = useMemo(() => {
- const connectorMap = new Map();
+ // 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) => {
const metadata = item.metadata as ConnectorIndexingMetadata;
- if (metadata?.connector_type && !connectorMap.has(metadata.connector_type)) {
- connectorMap.set(metadata.connector_type, {
- type: metadata.connector_type,
- name: metadata.connector_name || metadata.connector_type,
- });
+ if (metadata?.connector_type) {
+ connectorTypes.add(metadata.connector_type);
}
});
- return Array.from(connectorMap.values());
+ return Array.from(connectorTypes).map((type) => ({
+ type,
+ displayName: getConnectorTypeDisplayName(type),
+ }));
}, [statusItems]);
// Get items for current tab
@@ -358,53 +403,205 @@ export function InboxSidebar({
{t("inbox") || "Inbox"}
-
setOpenDropdown(isOpen ? "filter" : null)}
- >
-
-
-
+ {/* Mobile: Button that opens bottom drawer */}
+ {isMobile ? (
+ <>
+
+
setFilterDrawerOpen(true)}
>
{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" && }
-
-
-
+
+
+ {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 && }
+
+ ))}
+ >
+ )}
+
+
+ )}
- {/* Connector filter chips - only show in status tab when there are connectors */}
- {activeTab === "status" && uniqueConnectors.length > 0 && (
-
- {/* Left shadow indicator */}
-
- {/* Right shadow indicator */}
-
-
- setSelectedConnector(null)}
- >
- {t("all") || "All"}
-
- {uniqueConnectors.map((connector) => (
-
-
- setSelectedConnector(connector.type)}
- >
- {getConnectorIcon(connector.type, "h-3.5 w-3.5")}
- {connector.name}
-
-
-
- {connector.name}
-
-
- ))}
-
-
- )}
-
{loading ? (
diff --git a/surfsense_web/components/ui/drawer.tsx b/surfsense_web/components/ui/drawer.tsx
new file mode 100644
index 000000000..81733487d
--- /dev/null
+++ b/surfsense_web/components/ui/drawer.tsx
@@ -0,0 +1,140 @@
+"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 (
-
+
Date: Thu, 22 Jan 2026 04:27:34 +0530
Subject: [PATCH 13/20] fix: always show the 3-dot and the folder in mobile
view
---
surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx | 4 ++--
surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
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/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}
)}
From 36f1d28632ebc0131b788c2d003eae0649eac36d Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 22 Jan 2026 11:27:45 +0530
Subject: [PATCH 14/20] feat: implement infinite scroll and pagination for
inbox items
- Enhanced the inbox functionality by adding infinite scroll support in the InboxSidebar, allowing users to load more items seamlessly as they scroll.
- Updated the useInbox hook to manage pagination, including loading states and item counts, improving performance with large datasets.
- Introduced new props in InboxSidebar for loading more items, handling loading states, and indicating if more items are available.
- Refactored the LayoutDataProvider to accommodate the new inbox loading logic, ensuring a smooth user experience.
---
.../layout/providers/LayoutDataProvider.tsx | 14 ++-
.../layout/ui/sidebar/InboxSidebar.tsx | 46 +++++++++-
surfsense_web/hooks/use-inbox.ts | 86 +++++++++++++++++--
surfsense_web/lib/electric/client.ts | 4 +-
4 files changed, 137 insertions(+), 13 deletions(-)
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index aa7a06c81..16e6da4cc 100644
--- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
@@ -87,7 +87,16 @@ export function LayoutDataProvider({
// Inbox hook
const userId = user?.id ? String(user.id) : null;
- const { inboxItems, unreadCount, loading: inboxLoading, markAsRead, markAllAsRead } = useInbox(
+ const {
+ inboxItems,
+ unreadCount,
+ loading: inboxLoading,
+ loadingMore: inboxLoadingMore,
+ hasMore: inboxHasMore,
+ loadMore: inboxLoadMore,
+ markAsRead,
+ markAllAsRead
+ } = useInbox(
userId,
Number(searchSpaceId) || null,
null
@@ -549,6 +558,9 @@ export function LayoutDataProvider({
inboxItems={inboxItems}
unreadCount={unreadCount}
loading={inboxLoading}
+ loadingMore={inboxLoadingMore}
+ hasMore={inboxHasMore}
+ loadMore={inboxLoadMore}
markAsRead={markAsRead}
markAllAsRead={markAllAsRead}
/>
diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
index ebe537869..a0a0d1f5e 100644
--- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -16,7 +16,7 @@ import {
import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
-import { useCallback, useEffect, useMemo, useState } from "react";
+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";
@@ -107,6 +107,9 @@ interface InboxSidebarProps {
inboxItems: InboxItem[];
unreadCount: number;
loading: boolean;
+ loadingMore?: boolean;
+ hasMore?: boolean;
+ loadMore?: () => void;
markAsRead: (id: number) => Promise
;
markAllAsRead: () => Promise;
onCloseMobileSidebar?: () => void;
@@ -118,6 +121,9 @@ export function InboxSidebar({
inboxItems,
unreadCount,
loading,
+ loadingMore = false,
+ hasMore = false,
+ loadMore,
markAsRead,
markAllAsRead,
onCloseMobileSidebar,
@@ -136,6 +142,9 @@ export function InboxSidebar({
// 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);
@@ -238,6 +247,32 @@ export function InboxSidebar({
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;
@@ -685,12 +720,15 @@ export function InboxSidebar({
) : filteredItems.length > 0 ? (
- {filteredItems.map((item) => {
+ {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 (
);
})}
+ {/* Fallback trigger at the very end if less than 5 items and not searching */}
+ {!searchQuery && filteredItems.length < 5 && hasMore && (
+
+ )}
) : searchQuery ? (
diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts
index aa3de559d..c1541f71c 100644
--- a/surfsense_web/hooks/use-inbox.ts
+++ b/surfsense_web/hooks/use-inbox.ts
@@ -8,6 +8,8 @@ import { useElectricClient } from "@/lib/electric/context";
export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
+const PAGE_SIZE = 50; // Items per batch
+
/**
* Hook for managing inbox items with Electric SQL real-time sync
*
@@ -17,6 +19,7 @@ export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types
* Architecture:
* - User-level sync: Syncs ALL inbox items for a user (runs once per user)
* - Search-space-level query: Filters inbox items by searchSpaceId (updates on search space change)
+ * - Pagination: Loads items in batches for better performance with large datasets
*
* This separation ensures smooth transitions when switching search spaces (no flash).
*
@@ -35,10 +38,13 @@ export function useInbox(
const [inboxItems, setInboxItems] = useState
([]);
const [totalUnreadCount, setTotalUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [hasMore, setHasMore] = 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);
+ const offsetRef = useRef(0);
// Track user-level sync key to prevent duplicate sync subscriptions
const userSyncKeyRef = useRef(null);
@@ -118,6 +124,13 @@ export function useInbox(
};
}, [userId, electricClient]);
+ // Reset pagination when filters change
+ useEffect(() => {
+ offsetRef.current = 0;
+ setHasMore(true);
+ setInboxItems([]);
+ }, [userId, searchSpaceId, typeFilter]);
+
// EFFECT 2: Search-space-level query - updates when searchSpaceId or typeFilter changes
// This runs independently of sync, allowing smooth transitions between search spaces
useEffect(() => {
@@ -144,24 +157,28 @@ export function useInbox(
typeFilter
);
- // Build query with optional type filter
+ // Build query with optional type filter and LIMIT for pagination
// Note: Backend table is still named "notifications"
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 limitClause = ` LIMIT ${PAGE_SIZE}`;
+ const fullQuery = baseQuery + typeClause + orderClause + limitClause;
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
// Fetch inbox items for current search space immediately
const result = await client.db.query(fullQuery, params);
if (mounted) {
- setInboxItems(result.rows || []);
+ const items = result.rows || [];
+ setInboxItems(items);
+ setHasMore(items.length === PAGE_SIZE);
+ offsetRef.current = items.length;
}
- // Set up live query for real-time updates
+ // Set up live query for real-time updates (first page only)
const db = client.db as any;
if (db.live?.query && typeof db.live.query === "function") {
@@ -174,16 +191,36 @@ export function useInbox(
// Set initial results from live query
if (liveQuery.initialResults?.rows) {
- setInboxItems(liveQuery.initialResults.rows);
+ const items = liveQuery.initialResults.rows;
+ setInboxItems(items);
+ setHasMore(items.length === PAGE_SIZE);
+ offsetRef.current = items.length;
} else if (liveQuery.rows) {
- setInboxItems(liveQuery.rows);
+ const items = liveQuery.rows;
+ setInboxItems(items);
+ setHasMore(items.length === PAGE_SIZE);
+ offsetRef.current = items.length;
}
// Subscribe to changes
if (typeof liveQuery.subscribe === "function") {
liveQuery.subscribe((result: { rows: InboxItem[] }) => {
if (mounted && result.rows) {
- setInboxItems(result.rows);
+ // Only update first page from live query
+ // Keep any additionally loaded items
+ setInboxItems(prev => {
+ if (prev.length <= PAGE_SIZE) {
+ const items = result.rows;
+ setHasMore(items.length === PAGE_SIZE);
+ offsetRef.current = items.length;
+ return items;
+ }
+ // Merge: new first page + existing extra items
+ const newFirstPage = result.rows;
+ const existingExtra = prev.slice(PAGE_SIZE);
+ offsetRef.current = newFirstPage.length + existingExtra.length;
+ return [...newFirstPage, ...existingExtra];
+ });
}
});
}
@@ -290,6 +327,38 @@ export function useInbox(
};
}, [userId, searchSpaceId, electricClient]);
+ // Load more items (for infinite scroll)
+ const loadMore = useCallback(async () => {
+ if (!userId || !electricClient || loadingMore || !hasMore) {
+ return;
+ }
+
+ setLoadingMore(true);
+ const client = electricClient;
+
+ try {
+ 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 limitOffsetClause = ` LIMIT ${PAGE_SIZE} OFFSET ${offsetRef.current}`;
+ const fullQuery = baseQuery + typeClause + orderClause + limitOffsetClause;
+ const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
+
+ const result = await client.db.query(fullQuery, params);
+ const newItems = result.rows || [];
+
+ setInboxItems(prev => [...prev, ...newItems]);
+ setHasMore(newItems.length === PAGE_SIZE);
+ offsetRef.current += newItems.length;
+ } catch (err) {
+ console.error("[useInbox] Failed to load more:", err);
+ } finally {
+ setLoadingMore(false);
+ }
+ }, [userId, searchSpaceId, typeFilter, electricClient, loadingMore, hasMore]);
+
// Mark inbox item as read via backend API
const markAsRead = useCallback(async (itemId: number) => {
try {
@@ -338,6 +407,9 @@ export function useInbox(
markAsRead,
markAllAsRead,
loading,
+ loadingMore,
+ hasMore,
+ loadMore,
error,
};
}
diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts
index 52f46d96d..7f7eb7552 100644
--- a/surfsense_web/lib/electric/client.ts
+++ b/surfsense_web/lib/electric/client.ts
@@ -54,9 +54,7 @@ const pendingSyncs = new Map>();
// Version for sync state - increment this to force fresh sync when Electric config changes
// v2: user-specific database architecture
-// v3: added archived column to notifications
-// v4: removed archived column from notifications
-const SYNC_VERSION = 4;
+const SYNC_VERSION = 2;
// Database name prefix for identifying SurfSense databases
const DB_PREFIX = "surfsense-";
From a449e7e2a651d2964fcd5480b721b0d01fc73832 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 22 Jan 2026 16:02:25 +0530
Subject: [PATCH 15/20] feat: enhance notifications API and inbox functionality
- Added a new endpoint to list notifications with pagination, allowing users to fetch older notifications beyond the sync window.
- Introduced response models for notifications and improved error handling for date filtering.
- Updated the useInbox hook to support API fallback for loading older notifications when Electric SQL returns no recent items.
- Implemented deduplication and sorting logic for inbox items to prevent race conditions and ensure consistent data display.
- Enhanced loading logic for inbox items, including improved pagination and handling of loading states.
---
.../app/routes/notifications_routes.py | 129 +++++-
surfsense_web/hooks/use-inbox.ts | 414 ++++++++++--------
surfsense_web/lib/electric/client.ts | 3 +-
3 files changed, 354 insertions(+), 192 deletions(-)
diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py
index deee748d8..f7b52f3e7 100644
--- a/surfsense_backend/app/routes/notifications_routes.py
+++ b/surfsense_backend/app/routes/notifications_routes.py
@@ -1,12 +1,16 @@
"""
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 datetime
+from typing import Optional
+
+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
@@ -15,6 +19,33 @@ from app.users import current_active_user
router = APIRouter(prefix="/notifications", tags=["notifications"])
+class NotificationResponse(BaseModel):
+ """Response model for a single notification."""
+
+ id: int
+ user_id: str
+ search_space_id: Optional[int]
+ type: str
+ title: str
+ message: str
+ read: bool
+ metadata: dict
+ created_at: str
+ updated_at: Optional[str]
+
+ class Config:
+ from_attributes = True
+
+
+class NotificationListResponse(BaseModel):
+ """Response for listing notifications with pagination."""
+
+ items: list[NotificationResponse]
+ total: int
+ has_more: bool
+ next_offset: Optional[int]
+
+
class MarkReadResponse(BaseModel):
"""Response for mark as read operations."""
@@ -30,6 +61,96 @@ class MarkAllReadResponse(BaseModel):
updated_count: int
+@router.get("", response_model=NotificationListResponse)
+async def list_notifications(
+ search_space_id: Optional[int] = Query(None, description="Filter by search space ID"),
+ type_filter: Optional[str] = Query(None, alias="type", description="Filter by notification type"),
+ before_date: Optional[str] = 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/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts
index c1541f71c..bd4a6ee35 100644
--- a/surfsense_web/hooks/use-inbox.ts
+++ b/surfsense_web/hooks/use-inbox.ts
@@ -8,31 +8,74 @@ import { useElectricClient } from "@/lib/electric/context";
export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
-const PAGE_SIZE = 50; // Items per batch
+const PAGE_SIZE = 50;
+const SYNC_WINDOW_DAYS = 14;
/**
- * Hook for managing inbox items with Electric SQL real-time sync
+ * 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
*
- * Uses the Electric client from context (provided by ElectricProvider)
- * instead of initializing its own - prevents race conditions and memory leaks
+ * 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)
*
- * Architecture:
- * - User-level sync: Syncs ALL inbox items for a user (runs once per user)
- * - Search-space-level query: Filters inbox items by searchSpaceId (updates on search space change)
- * - Pagination: Loads items in batches for better performance with large datasets
- *
- * This separation ensures smooth transitions when switching search spaces (no flash).
+ * 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 (null shows global items only)
- * @param typeFilter - Optional inbox item type to filter by (null shows all types)
+ * @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
) {
- // Get Electric client from context - ElectricProvider handles initialization
const electricClient = useElectricClient();
const [inboxItems, setInboxItems] = useState([]);
@@ -41,58 +84,51 @@ export function useInbox(
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = 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);
- const offsetRef = useRef(0);
-
- // 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 inbox items
+ // EFFECT 1: Electric SQL sync for real-time updates
useEffect(() => {
if (!userId || !electricClient) {
setLoading(!electricClient);
return;
}
- const userSyncKey = `inbox_${userId}`;
- if (userSyncKeyRef.current === userSyncKey) {
- // Already syncing for this user
- return;
- }
-
- // Capture electricClient to satisfy TypeScript in async function
const client = electricClient;
let mounted = true;
- userSyncKeyRef.current = userSyncKey;
- async function startUserSync() {
+ async function startSync() {
try {
- console.log("[useInbox] Starting user-level sync for:", userId);
+ 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;
- // Sync ALL inbox items for this user (cached via syncShape caching)
- // Note: Backend table is still named "notifications"
const handle = await client.syncShape({
table: "notifications",
- where: `user_id = '${userId}'`,
+ where: `user_id = '${userId}' AND created_at > '${cutoffDate}'`,
primaryKey: ["id"],
});
- console.log("[useInbox] 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("[useInbox] Initial sync failed:", syncErr);
- }
+ await Promise.race([
+ handle.initialSyncPromise,
+ new Promise((resolve) => setTimeout(resolve, 3000)),
+ ]);
}
if (!mounted) {
@@ -105,18 +141,17 @@ export function useInbox(
setError(null);
} catch (err) {
if (!mounted) return;
- console.error("[useInbox] Failed to start user sync:", err);
- setError(err instanceof Error ? err : new Error("Failed to sync inbox"));
+ console.error("[useInbox] Sync failed:", err);
+ setError(err instanceof Error ? err : new Error("Sync failed"));
setLoading(false);
}
}
- startUserSync();
+ startSync();
return () => {
mounted = false;
userSyncKeyRef.current = null;
-
if (syncHandleRef.current) {
syncHandleRef.current.unsubscribe();
syncHandleRef.current = null;
@@ -124,117 +159,126 @@ export function useInbox(
};
}, [userId, electricClient]);
- // Reset pagination when filters change
+ // Reset when filters change
useEffect(() => {
- offsetRef.current = 0;
setHasMore(true);
setInboxItems([]);
}, [userId, searchSpaceId, typeFilter]);
- // EFFECT 2: Search-space-level query - updates when searchSpaceId or typeFilter changes
- // This runs independently of sync, allowing smooth transitions between search spaces
+ // EFFECT 2: Live query for real-time updates + auto-fetch from API if empty
useEffect(() => {
- if (!userId || !electricClient) {
- return;
- }
+ if (!userId || !electricClient) return;
- // Capture electricClient to satisfy TypeScript in async function
const client = electricClient;
let mounted = true;
- async function updateQuery() {
- // Clean up previous live query (but DON'T clear inbox items - keep showing old until new arrive)
+ async function setupLiveQuery() {
+ // Clean up previous live query
if (liveQueryRef.current) {
liveQueryRef.current.unsubscribe();
liveQueryRef.current = null;
}
try {
- console.log(
- "[useInbox] Updating query for searchSpace:",
- searchSpaceId,
- "typeFilter:",
- typeFilter
- );
+ const cutoff = getSyncCutoffDate();
- // Build query with optional type filter and LIMIT for pagination
- // Note: Backend table is still named "notifications"
- 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 limitClause = ` LIMIT ${PAGE_SIZE}`;
- const fullQuery = baseQuery + typeClause + orderClause + limitClause;
- const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
+ 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}`;
- // Fetch inbox items for current search space immediately
- const result = await client.db.query(fullQuery, params);
+ const params = typeFilter
+ ? [userId, searchSpaceId, typeFilter]
+ : [userId, searchSpaceId];
- if (mounted) {
- const items = result.rows || [];
- setInboxItems(items);
- setHasMore(items.length === PAGE_SIZE);
- offsetRef.current = items.length;
- }
-
- // Set up live query for real-time updates (first page only)
const db = client.db as any;
- if (db.live?.query && typeof db.live.query === "function") {
- const liveQuery = await db.live.query(fullQuery, params);
+ // Initial fetch from PGLite
+ 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 {
+ const apiParams = new URLSearchParams();
+ if (searchSpaceId !== null) {
+ apiParams.append("search_space_id", String(searchSpaceId));
+ }
+ if (typeFilter) {
+ apiParams.append("type", typeFilter);
+ }
+ apiParams.append("limit", String(PAGE_SIZE));
+
+ const response = await authenticatedFetch(
+ `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications?${apiParams.toString()}`
+ );
+
+ if (response.ok && mounted) {
+ const data = await response.json();
+ const apiItems: InboxItem[] = data.items.map((item: any) => ({
+ ...item,
+ metadata: item.metadata || {},
+ }));
+
+ if (apiItems.length > 0) {
+ setInboxItems(apiItems);
+ }
+ setHasMore(data.has_more ?? apiItems.length === PAGE_SIZE);
+ }
+ } 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;
}
- // Set initial results from live query
- if (liveQuery.initialResults?.rows) {
- const items = liveQuery.initialResults.rows;
- setInboxItems(items);
- setHasMore(items.length === PAGE_SIZE);
- offsetRef.current = items.length;
- } else if (liveQuery.rows) {
- const items = liveQuery.rows;
- setInboxItems(items);
- setHasMore(items.length === PAGE_SIZE);
- offsetRef.current = items.length;
- }
-
- // Subscribe to changes
- if (typeof liveQuery.subscribe === "function") {
+ if (liveQuery.subscribe) {
liveQuery.subscribe((result: { rows: InboxItem[] }) => {
if (mounted && result.rows) {
- // Only update first page from live query
- // Keep any additionally loaded items
- setInboxItems(prev => {
- if (prev.length <= PAGE_SIZE) {
- const items = result.rows;
- setHasMore(items.length === PAGE_SIZE);
- offsetRef.current = items.length;
- return items;
- }
- // Merge: new first page + existing extra items
- const newFirstPage = result.rows;
- const existingExtra = prev.slice(PAGE_SIZE);
- offsetRef.current = newFirstPage.length + existingExtra.length;
- return [...newFirstPage, ...existingExtra];
+ setInboxItems((prev) => {
+ const liveItems = result.rows;
+ 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 (typeof liveQuery.unsubscribe === "function") {
+ if (liveQuery.unsubscribe) {
liveQueryRef.current = liveQuery;
}
}
} catch (err) {
- console.error("[useInbox] Failed to update query:", err);
+ console.error("[useInbox] Live query error:", err);
}
}
- updateQuery();
+ setupLiveQuery();
return () => {
mounted = false;
@@ -245,61 +289,45 @@ export function useInbox(
};
}, [userId, searchSpaceId, typeFilter, electricClient]);
- // EFFECT 3: Total unread count - independent of type filter
- // This ensures the badge count stays consistent regardless of active filter
+ // EFFECT 3: Unread count with live updates
useEffect(() => {
- if (!userId || !electricClient) {
- return;
- }
+ if (!userId || !electricClient) return;
- // Capture electricClient to satisfy TypeScript in async function
const client = electricClient;
let mounted = true;
async function updateUnreadCount() {
- // Clean up previous live query
if (unreadCountLiveQueryRef.current) {
unreadCountLiveQueryRef.current.unsubscribe();
unreadCountLiveQueryRef.current = null;
}
try {
- // Note: Backend table is still named "notifications"
- 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`;
+ const cutoff = getSyncCutoffDate();
+ const query = `SELECT COUNT(*) as count FROM notifications
+ WHERE user_id = $1
+ AND (search_space_id = $2 OR search_space_id IS NULL)
+ AND read = false
+ AND created_at > '${cutoff}'`;
- // Fetch initial count
- const result = await client.db.query<{ count: number }>(countQuery, [
+ const result = await client.db.query<{ count: number }>(query, [
userId,
searchSpaceId,
]);
-
if (mounted && result.rows?.[0]) {
setTotalUnreadCount(Number(result.rows[0].count) || 0);
}
- // Set up live query for real-time updates
const db = client.db as any;
-
- if (db.live?.query && typeof db.live.query === "function") {
- const liveQuery = await db.live.query(countQuery, [userId, searchSpaceId]);
+ if (db.live?.query) {
+ const liveQuery = await db.live.query(query, [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") {
+ if (liveQuery.subscribe) {
liveQuery.subscribe((result: { rows: { count: number }[] }) => {
if (mounted && result.rows?.[0]) {
setTotalUnreadCount(Number(result.rows[0].count) || 0);
@@ -307,12 +335,12 @@ export function useInbox(
});
}
- if (typeof liveQuery.unsubscribe === "function") {
+ if (liveQuery.unsubscribe) {
unreadCountLiveQueryRef.current = liveQuery;
}
}
} catch (err) {
- console.error("[useInbox] Failed to update unread count:", err);
+ console.error("[useInbox] Unread count error:", err);
}
}
@@ -327,76 +355,88 @@ export function useInbox(
};
}, [userId, searchSpaceId, electricClient]);
- // Load more items (for infinite scroll)
+ // loadMore - Pure cursor-based pagination, no race conditions
+ // Cursor is computed from current state, not stored in refs
const loadMore = useCallback(async () => {
- if (!userId || !electricClient || loadingMore || !hasMore) {
- return;
- }
+ // Removed inboxItems.length === 0 check to allow loading older items
+ // when Electric returns 0 items
+ if (!userId || loadingMore || !hasMore) return;
setLoadingMore(true);
- const client = electricClient;
try {
- 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 limitOffsetClause = ` LIMIT ${PAGE_SIZE} OFFSET ${offsetRef.current}`;
- const fullQuery = baseQuery + typeClause + orderClause + limitOffsetClause;
- const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
+ // 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;
- const result = await client.db.query(fullQuery, params);
- const newItems = result.rows || [];
+ const params = new URLSearchParams();
+ if (searchSpaceId !== null) {
+ params.append("search_space_id", String(searchSpaceId));
+ }
+ if (typeFilter) {
+ params.append("type", typeFilter);
+ }
+ // Only add before_date if we have a cursor
+ // Without before_date, API returns newest items first
+ if (beforeDate) {
+ params.append("before_date", beforeDate);
+ }
+ params.append("limit", String(PAGE_SIZE));
- setInboxItems(prev => [...prev, ...newItems]);
- setHasMore(newItems.length === PAGE_SIZE);
- offsetRef.current += newItems.length;
+ console.log("[useInbox] Loading more, before:", beforeDate ?? "none (initial)");
+
+ const response = await authenticatedFetch(
+ `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications?${params.toString()}`
+ );
+
+ if (!response.ok) {
+ throw new Error("Failed to fetch notifications");
+ }
+
+ const data = await response.json();
+ const apiItems: InboxItem[] = data.items.map((item: any) => ({
+ ...item,
+ metadata: item.metadata || {},
+ }));
+
+ if (apiItems.length > 0) {
+ // Functional update ensures we always merge with latest state
+ setInboxItems((prev) => deduplicateAndSort([...prev, ...apiItems]));
+ }
+
+ // Use API's has_more flag if available, otherwise check count
+ setHasMore(data.has_more ?? apiItems.length === PAGE_SIZE);
} catch (err) {
- console.error("[useInbox] Failed to load more:", err);
+ console.error("[useInbox] Load more failed:", err);
} finally {
setLoadingMore(false);
}
- }, [userId, searchSpaceId, typeFilter, electricClient, loadingMore, hasMore]);
+ }, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]);
- // Mark inbox item as read via backend API
+ // Mark inbox item as read
const markAsRead = useCallback(async (itemId: number) => {
try {
- // Note: Backend API endpoint is still /notifications/
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/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 inbox item as read");
- }
-
- return true;
+ return response.ok;
} catch (err) {
- console.error("Failed to mark inbox item as read:", err);
+ console.error("Failed to mark as read:", err);
return false;
}
}, []);
- // Mark all inbox items as read via backend API
+ // Mark all inbox items as read
const markAllAsRead = useCallback(async () => {
try {
- // Note: Backend API endpoint is still /notifications/
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 inbox items as read");
- }
-
- return true;
+ return response.ok;
} catch (err) {
- console.error("Failed to mark all inbox items as read:", err);
+ console.error("Failed to mark all as read:", err);
return false;
}
}, []);
@@ -410,7 +450,7 @@ export function useInbox(
loadingMore,
hasMore,
loadMore,
+ isUsingApiFallback: true, // Always use API for pagination
error,
};
}
-
diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts
index 7f7eb7552..6a9d87b88 100644
--- a/surfsense_web/lib/electric/client.ts
+++ b/surfsense_web/lib/electric/client.ts
@@ -54,7 +54,8 @@ const pendingSyncs = new Map>();
// Version for sync state - increment this to force fresh sync when Electric config changes
// v2: user-specific database architecture
-const SYNC_VERSION = 2;
+// 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-";
From be7ba764179a01f3e9ff4bc5d18cd917918c0aac Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 22 Jan 2026 16:07:06 +0530
Subject: [PATCH 16/20] chore: ran backend and frontend linting
---
.../app/routes/notifications_routes.py | 87 +++++++++++--------
.../dashboard/[search_space_id]/team/page.tsx | 9 +-
.../comment-sheet/comment-sheet.tsx | 8 +-
.../layout/providers/LayoutDataProvider.tsx | 18 ++--
.../ui/sidebar/AllPrivateChatsSidebar.tsx | 8 +-
.../ui/sidebar/AllSharedChatsSidebar.tsx | 8 +-
.../components/shared/llm-config-form.tsx | 4 +-
surfsense_web/components/ui/drawer.tsx | 47 +++-------
surfsense_web/components/ui/spinner.tsx | 47 +++++-----
surfsense_web/contracts/types/inbox.types.ts | 1 -
surfsense_web/hooks/use-inbox.ts | 9 +-
11 files changed, 119 insertions(+), 127 deletions(-)
diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py
index f7b52f3e7..6172aacc5 100644
--- a/surfsense_backend/app/routes/notifications_routes.py
+++ b/surfsense_backend/app/routes/notifications_routes.py
@@ -6,7 +6,6 @@ For older items (beyond the sync window), use the list endpoint.
"""
from datetime import datetime
-from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
@@ -24,14 +23,14 @@ class NotificationResponse(BaseModel):
id: int
user_id: str
- search_space_id: Optional[int]
+ search_space_id: int | None
type: str
title: str
message: str
read: bool
metadata: dict
created_at: str
- updated_at: Optional[str]
+ updated_at: str | None
class Config:
from_attributes = True
@@ -43,7 +42,7 @@ class NotificationListResponse(BaseModel):
items: list[NotificationResponse]
total: int
has_more: bool
- next_offset: Optional[int]
+ next_offset: int | None
class MarkReadResponse(BaseModel):
@@ -63,9 +62,15 @@ class MarkAllReadResponse(BaseModel):
@router.get("", response_model=NotificationListResponse)
async def list_notifications(
- search_space_id: Optional[int] = Query(None, description="Filter by search space ID"),
- type_filter: Optional[str] = Query(None, alias="type", description="Filter by notification type"),
- before_date: Optional[str] = Query(None, description="Get notifications before this ISO date (for pagination)"),
+ 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),
@@ -73,32 +78,34 @@ async def list_notifications(
) -> 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)
-
+ 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))
+ (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))
+ (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:
@@ -110,39 +117,47 @@ async def list_notifications(
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)
-
+ 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,
- ))
-
+ 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,
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 a0bc6be03..b661e9222 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
@@ -711,12 +711,9 @@ function MembersTab({
) : (
-
- {member.role?.name || "No role"}
-
+
+ {member.role?.name || "No role"}
+
)}
diff --git a/surfsense_web/components/chat-comments/comment-sheet/comment-sheet.tsx b/surfsense_web/components/chat-comments/comment-sheet/comment-sheet.tsx
index b8e58ad2b..d483ab261 100644
--- a/surfsense_web/components/chat-comments/comment-sheet/comment-sheet.tsx
+++ b/surfsense_web/components/chat-comments/comment-sheet/comment-sheet.tsx
@@ -1,7 +1,13 @@
"use client";
import { MessageSquare } from "lucide-react";
-import { Drawer, DrawerContent, DrawerHandle, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
+import {
+ Drawer,
+ DrawerContent,
+ DrawerHandle,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { CommentPanelContainer } from "../comment-panel-container/comment-panel-container";
diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
index 16e6da4cc..5f4617b84 100644
--- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
+++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx
@@ -87,20 +87,16 @@ export function LayoutDataProvider({
// Inbox hook
const userId = user?.id ? String(user.id) : null;
- const {
- inboxItems,
- unreadCount,
- loading: inboxLoading,
+ const {
+ inboxItems,
+ unreadCount,
+ loading: inboxLoading,
loadingMore: inboxLoadingMore,
hasMore: inboxHasMore,
loadMore: inboxLoadMore,
- markAsRead,
- markAllAsRead
- } = useInbox(
- userId,
- Number(searchSpaceId) || null,
- null
- );
+ markAsRead,
+ markAllAsRead,
+ } = useInbox(userId, Number(searchSpaceId) || null, null);
// Delete dialogs state
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
index 78bac3371..39f1b95bc 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx
@@ -280,7 +280,9 @@ export function AllPrivateChatsSidebar({
Active
- {activeCount}
+
+ {activeCount}
+
Archived
- {archivedCount}
+
+ {archivedCount}
+
diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
index e3b6174c3..8dd593945 100644
--- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx
@@ -280,7 +280,9 @@ export function AllSharedChatsSidebar({
Active
- {activeCount}
+
+ {activeCount}
+
Archived
- {archivedCount}
+
+ {archivedCount}
+
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
index 81733487d..015d6ac07 100644
--- a/surfsense_web/components/ui/drawer.tsx
+++ b/surfsense_web/components/ui/drawer.tsx
@@ -9,12 +9,7 @@ function Drawer({
shouldScaleBackground = true,
...props
}: React.ComponentProps
) {
- return (
-
- );
+ return ;
}
Drawer.displayName = "Drawer";
@@ -62,42 +57,20 @@ function DrawerContent({
}
DrawerContent.displayName = "DrawerContent";
-function DrawerHeader({
- className,
- ...props
-}: React.HTMLAttributes) {
- return (
-
- );
+function DrawerHeader({ className, ...props }: React.HTMLAttributes) {
+ return
;
}
DrawerHeader.displayName = "DrawerHeader";
-function DrawerFooter({
- className,
- ...props
-}: React.HTMLAttributes) {
- return (
-
- );
+function DrawerFooter({ className, ...props }: React.HTMLAttributes) {
+ return
;
}
DrawerFooter.displayName = "DrawerFooter";
-function DrawerTitle({
- className,
- ...props
-}: React.ComponentProps) {
+function DrawerTitle({ className, ...props }: React.ComponentProps) {
return (
);
@@ -119,7 +92,10 @@ DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
function DrawerHandle({ className, ...props }: React.HTMLAttributes) {
return (
-
+
);
}
DrawerHandle.displayName = "DrawerHandle";
@@ -137,4 +113,3 @@ export {
DrawerDescription,
DrawerHandle,
};
-
diff --git a/surfsense_web/components/ui/spinner.tsx b/surfsense_web/components/ui/spinner.tsx
index eeed30a8a..22d190afa 100644
--- a/surfsense_web/components/ui/spinner.tsx
+++ b/surfsense_web/components/ui/spinner.tsx
@@ -1,34 +1,33 @@
import { cn } from "@/lib/utils";
interface SpinnerProps {
- /** Size of the spinner */
- size?: "xs" | "sm" | "md" | "lg" | "xl";
- /** Whether to hide the track behind the spinner arc */
- hideTrack?: boolean;
- /** Additional classes to apply */
- className?: string;
+ /** Size of the spinner */
+ size?: "xs" | "sm" | "md" | "lg" | "xl";
+ /** Whether to hide the track behind the spinner arc */
+ hideTrack?: boolean;
+ /** Additional classes to apply */
+ className?: string;
}
const sizeClasses = {
- xs: "h-3 w-3 border-[1.5px]",
- sm: "h-4 w-4 border-2",
- md: "h-6 w-6 border-2",
- lg: "h-8 w-8 border-[3px]",
- xl: "h-10 w-10 border-4",
+ xs: "h-3 w-3 border-[1.5px]",
+ sm: "h-4 w-4 border-2",
+ md: "h-6 w-6 border-2",
+ lg: "h-8 w-8 border-[3px]",
+ xl: "h-10 w-10 border-4",
};
export function Spinner({ size = "md", hideTrack = false, className }: SpinnerProps) {
- return (
-
- );
+ return (
+
+ );
}
-
diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts
index 515ba5864..c1627ebee 100644
--- a/surfsense_web/contracts/types/inbox.types.ts
+++ b/surfsense_web/contracts/types/inbox.types.ts
@@ -146,4 +146,3 @@ export type InboxItem = z.infer;
export type ConnectorIndexingInboxItem = z.infer;
export type DocumentProcessingInboxItem = z.infer;
export type NewMentionInboxItem = z.infer;
-
diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts
index bd4a6ee35..7ce33ac9a 100644
--- a/surfsense_web/hooks/use-inbox.ts
+++ b/surfsense_web/hooks/use-inbox.ts
@@ -190,9 +190,7 @@ export function useInbox(
ORDER BY created_at DESC
LIMIT ${PAGE_SIZE}`;
- const params = typeFilter
- ? [userId, searchSpaceId, typeFilter]
- : [userId, searchSpaceId];
+ const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
const db = client.db as any;
@@ -310,10 +308,7 @@ export function useInbox(
AND read = false
AND created_at > '${cutoff}'`;
- const result = await client.db.query<{ count: number }>(query, [
- userId,
- searchSpaceId,
- ]);
+ const result = await client.db.query<{ count: number }>(query, [userId, searchSpaceId]);
if (mounted && result.rows?.[0]) {
setTotalUnreadCount(Number(result.rows[0].count) || 0);
}
From c98cfac49fb4a66d02911e034f115bd92ec12b0a Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 22 Jan 2026 17:46:44 +0530
Subject: [PATCH 17/20] refactor: improve InboxSidebar and useInbox hook
- Added LayoutGrid icon to the "All connectors" option in the InboxSidebar for better visual representation.
- Simplified the return statement in getConnectorTypeDisplayName for improved readability.
- Refactored filter and drawer components in InboxSidebar for cleaner code structure.
- Removed unused totalUnreadCount state from useInbox hook and replaced it with a memoized calculation for better performance.
- Implemented optimistic updates for marking inbox items as read, enhancing user experience with immediate feedback.
---
.../layout/ui/sidebar/InboxSidebar.tsx | 85 ++++++++------
surfsense_web/hooks/use-inbox.ts | 105 ++++++------------
2 files changed, 89 insertions(+), 101 deletions(-)
diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
index a0a0d1f5e..48553cc85 100644
--- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -9,6 +9,7 @@ import {
CheckCircle2,
History,
Inbox,
+ LayoutGrid,
ListFilter,
Search,
X,
@@ -95,7 +96,13 @@ function getConnectorTypeDisplayName(connectorType: string): string {
BAIDU_SEARCH_API: "Baidu",
};
- return displayNames[connectorType] || connectorType.replace(/_/g, " ").replace(/CONNECTOR|API/gi, "").trim();
+ return (
+ displayNames[connectorType] ||
+ connectorType
+ .replace(/_/g, " ")
+ .replace(/CONNECTOR|API/gi, "")
+ .trim()
+ );
}
type InboxTab = "mentions" | "status";
@@ -142,7 +149,7 @@ export function InboxSidebar({
// 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);
@@ -239,8 +246,7 @@ export function InboxSidebar({
const query = searchQuery.toLowerCase();
items = items.filter(
(item) =>
- item.title.toLowerCase().includes(query) ||
- item.message.toLowerCase().includes(query)
+ item.title.toLowerCase().includes(query) || item.message.toLowerCase().includes(query)
);
}
@@ -453,15 +459,14 @@ export function InboxSidebar({
{t("filter") || "Filter"}
-
- {t("filter") || "Filter"}
-
+ {t("filter") || "Filter"}
-
-
+
+
@@ -484,7 +489,9 @@ export function InboxSidebar({
}}
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"
+ activeFilter === "all"
+ ? "bg-primary/10 text-primary"
+ : "hover:bg-muted"
)}
>
@@ -501,7 +508,9 @@ export function InboxSidebar({
}}
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"
+ activeFilter === "unread"
+ ? "bg-primary/10 text-primary"
+ : "hover:bg-muted"
)}
>
@@ -527,10 +536,15 @@ export function InboxSidebar({
}}
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"
+ selectedConnector === null
+ ? "bg-primary/10 text-primary"
+ : "hover:bg-muted"
)}
>
- {t("all_connectors") || "All connectors"}
+
+
+ {t("all_connectors") || "All connectors"}
+
{selectedConnector === null && }
{uniqueConnectorTypes.map((connector) => (
@@ -543,14 +557,18 @@ export function InboxSidebar({
}}
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"
+ selectedConnector === connector.type
+ ? "bg-primary/10 text-primary"
+ : "hover:bg-muted"
)}
>
{getConnectorIcon(connector.type, "h-4 w-4")}
{connector.displayName}
- {selectedConnector === connector.type && }
+ {selectedConnector === connector.type && (
+
+ )}
))}
@@ -569,21 +587,18 @@ export function InboxSidebar({
-
+
{t("filter") || "Filter"}
-
- {t("filter") || "Filter"}
-
+ {t("filter") || "Filter"}
-
+
{t("filter") || "Filter"}
@@ -616,7 +631,10 @@ export function InboxSidebar({
onClick={() => setSelectedConnector(null)}
className="flex items-center justify-between"
>
- {t("all_connectors") || "All connectors"}
+
+
+ {t("all_connectors") || "All connectors"}
+
{selectedConnector === null && }
{uniqueConnectorTypes.map((connector) => (
@@ -629,7 +647,9 @@ export function InboxSidebar({
{getConnectorIcon(connector.type, "h-4 w-4")}
{connector.displayName}
- {selectedConnector === connector.type && }
+ {selectedConnector === connector.type && (
+
+ )}
))}
>
@@ -723,7 +743,8 @@ export function InboxSidebar({
{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;
+ const isPrefetchTrigger =
+ !searchQuery && hasMore && index === filteredItems.length - 5;
return (
)}
-
- {getEmptyStateMessage().title}
-
+ {getEmptyStateMessage().title}
{getEmptyStateMessage().hint}
diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts
index 7ce33ac9a..7c421c341 100644
--- a/surfsense_web/hooks/use-inbox.ts
+++ b/surfsense_web/hooks/use-inbox.ts
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
import { authenticatedFetch } from "@/lib/auth-utils";
import type { SyncHandle } from "@/lib/electric/client";
@@ -79,7 +79,6 @@ export function useInbox(
const electricClient = useElectricClient();
const [inboxItems, setInboxItems] = useState([]);
- const [totalUnreadCount, setTotalUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
@@ -87,9 +86,14 @@ export function useInbox(
const syncHandleRef = useRef(null);
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
- const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
const userSyncKeyRef = useRef(null);
+ // Calculate unread count from inboxItems (includes both recent and older when loaded)
+ // This ensures the count is always in sync with what's displayed
+ const totalUnreadCount = useMemo(() => {
+ return inboxItems.filter((item) => !item.read).length;
+ }, [inboxItems]);
+
// EFFECT 1: Electric SQL sync for real-time updates
useEffect(() => {
if (!userId || !electricClient) {
@@ -287,69 +291,6 @@ export function useInbox(
};
}, [userId, searchSpaceId, typeFilter, electricClient]);
- // EFFECT 3: Unread count with live updates
- useEffect(() => {
- if (!userId || !electricClient) return;
-
- const client = electricClient;
- let mounted = true;
-
- async function updateUnreadCount() {
- if (unreadCountLiveQueryRef.current) {
- unreadCountLiveQueryRef.current.unsubscribe();
- unreadCountLiveQueryRef.current = null;
- }
-
- try {
- const cutoff = getSyncCutoffDate();
- const query = `SELECT COUNT(*) as count FROM notifications
- WHERE user_id = $1
- AND (search_space_id = $2 OR search_space_id IS NULL)
- AND read = false
- AND created_at > '${cutoff}'`;
-
- const result = await client.db.query<{ count: number }>(query, [userId, searchSpaceId]);
- if (mounted && result.rows?.[0]) {
- setTotalUnreadCount(Number(result.rows[0].count) || 0);
- }
-
- const db = client.db as any;
- if (db.live?.query) {
- const liveQuery = await db.live.query(query, [userId, searchSpaceId]);
-
- if (!mounted) {
- liveQuery.unsubscribe?.();
- return;
- }
-
- if (liveQuery.subscribe) {
- liveQuery.subscribe((result: { rows: { count: number }[] }) => {
- if (mounted && result.rows?.[0]) {
- setTotalUnreadCount(Number(result.rows[0].count) || 0);
- }
- });
- }
-
- if (liveQuery.unsubscribe) {
- unreadCountLiveQueryRef.current = liveQuery;
- }
- }
- } catch (err) {
- console.error("[useInbox] Unread count error:", err);
- }
- }
-
- updateUnreadCount();
-
- return () => {
- mounted = false;
- if (unreadCountLiveQueryRef.current) {
- unreadCountLiveQueryRef.current.unsubscribe();
- unreadCountLiveQueryRef.current = null;
- }
- };
- }, [userId, searchSpaceId, electricClient]);
-
// loadMore - Pure cursor-based pagination, no race conditions
// Cursor is computed from current state, not stored in refs
const loadMore = useCallback(async () => {
@@ -408,30 +349,58 @@ export function useInbox(
}
}, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]);
- // Mark inbox item as read
+ // Mark inbox item as read with optimistic update
const markAsRead = useCallback(async (itemId: number) => {
+ // Optimistic update: mark as read immediately for instant UI feedback
+ setInboxItems((prev) =>
+ prev.map((item) => (item.id === itemId ? { ...item, read: true } : item))
+ );
+
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/read`,
{ method: "PATCH" }
);
+
+ if (!response.ok) {
+ // Rollback on error
+ setInboxItems((prev) =>
+ prev.map((item) => (item.id === itemId ? { ...item, read: false } : item))
+ );
+ }
+ // If successful, Electric SQL will sync the change and live query will update
+ // This ensures eventual consistency even if optimistic update was wrong
return response.ok;
} catch (err) {
console.error("Failed to mark as read:", err);
+ // Rollback on error
+ setInboxItems((prev) =>
+ prev.map((item) => (item.id === itemId ? { ...item, read: false } : item))
+ );
return false;
}
}, []);
- // Mark all inbox items as read
+ // Mark all inbox items as read with optimistic update
const markAllAsRead = useCallback(async () => {
+ // Optimistic update: mark all as read immediately for instant UI feedback
+ setInboxItems((prev) => prev.map((item) => ({ ...item, read: true })));
+
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`,
{ method: "PATCH" }
);
+
+ if (!response.ok) {
+ console.error("Failed to mark all as read");
+ // On error, let Electric SQL sync correct the state
+ }
+ // Electric SQL will sync and live query will ensure consistency
return response.ok;
} catch (err) {
console.error("Failed to mark all as read:", err);
+ // On error, let Electric SQL sync correct the state
return false;
}
}, []);
From 00596f991d5b6fc5afea5028089fbdc3e1b052c0 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 22 Jan 2026 18:32:25 +0530
Subject: [PATCH 18/20] refactor: enhance inbox functionality with type guards
and API service integration
- Introduced type guards for metadata in InboxSidebar to ensure safe access and improve type safety.
- Refactored the useInbox hook to utilize the new notifications API service for fetching notifications, enhancing validation and error handling.
- Added new API request/response schemas for notifications, improving structure and clarity.
- Updated logic for loading and marking notifications as read, ensuring consistent state management and user experience.
---
.../layout/ui/sidebar/InboxSidebar.tsx | 77 +++++++-----
surfsense_web/contracts/types/inbox.types.ts | 115 ++++++++++++++++++
surfsense_web/hooks/use-inbox.ts | 107 ++++++----------
.../lib/apis/notifications-api.service.ts | 94 ++++++++++++++
4 files changed, 294 insertions(+), 99 deletions(-)
create mode 100644 surfsense_web/lib/apis/notifications-api.service.ts
diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
index 48553cc85..166d77eca 100644
--- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
+++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
@@ -43,7 +43,12 @@ 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 } from "@/contracts/types/inbox.types";
+import {
+ type ConnectorIndexingMetadata,
+ type NewMentionMetadata,
+ isConnectorIndexingMetadata,
+ isNewMentionMetadata,
+} from "@/contracts/types/inbox.types";
import { cn } from "@/lib/utils";
/**
@@ -206,9 +211,9 @@ export function InboxSidebar({
statusItems
.filter((item) => item.type === "connector_indexing")
.forEach((item) => {
- const metadata = item.metadata as ConnectorIndexingMetadata;
- if (metadata?.connector_type) {
- connectorTypes.add(metadata.connector_type);
+ // Use type guard for safe metadata access
+ if (isConnectorIndexingMetadata(item.metadata)) {
+ connectorTypes.add(item.metadata.connector_type);
}
});
@@ -234,8 +239,11 @@ export function InboxSidebar({
if (activeTab === "status" && selectedConnector) {
items = items.filter((item) => {
if (item.type === "connector_indexing") {
- const metadata = item.metadata as ConnectorIndexingMetadata;
- return metadata?.connector_type === selectedConnector;
+ // 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
});
@@ -297,21 +305,20 @@ export function InboxSidebar({
}
if (item.type === "new_mention") {
- const metadata = item.metadata as {
- thread_id?: number;
- comment_id?: number;
- };
- const searchSpaceId = item.search_space_id;
- const threadId = metadata?.thread_id;
- const commentId = metadata?.comment_id;
+ // 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);
+ if (searchSpaceId && threadId) {
+ const url = commentId
+ ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
+ : `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
+ onOpenChange(false);
+ onCloseMobileSidebar?.();
+ router.push(url);
+ }
}
}
},
@@ -348,27 +355,35 @@ export function InboxSidebar({
const getStatusIcon = (item: InboxItem) => {
// For mentions, show the author's avatar with initials fallback
if (item.type === "new_mention") {
- const metadata = item.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;
+ // 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 (
- {avatarUrl && }
- {getInitials(authorName, authorEmail)}
+ {getInitials(null, null)}
);
}
// For status items (connector/document), show status icons
- const status = item.metadata?.status as string | undefined;
+ // 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":
diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts
index c1627ebee..12ebfe1e9 100644
--- a/surfsense_web/contracts/types/inbox.types.ts
+++ b/surfsense_web/contracts/types/inbox.types.ts
@@ -133,7 +133,115 @@ export const newMentionInboxItem = inboxItem.extend({
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(),
+});
+
+// =============================================================================
+// 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;
@@ -146,3 +254,10 @@ 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;
diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts
index 7c421c341..7f0bd59ef 100644
--- a/surfsense_web/hooks/use-inbox.ts
+++ b/surfsense_web/hooks/use-inbox.ts
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
-import { authenticatedFetch } from "@/lib/auth-utils";
+import { notificationsApiService } from "@/lib/apis/notifications-api.service";
import type { SyncHandle } from "@/lib/electric/client";
import { useElectricClient } from "@/lib/electric/context";
@@ -198,7 +198,7 @@ export function useInbox(
const db = client.db as any;
- // Initial fetch from PGLite
+ // 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) {
@@ -213,30 +213,20 @@ export function useInbox(
"[useInbox] Electric returned 0 items, checking API for older notifications"
);
try {
- const apiParams = new URLSearchParams();
- if (searchSpaceId !== null) {
- apiParams.append("search_space_id", String(searchSpaceId));
- }
- if (typeFilter) {
- apiParams.append("type", typeFilter);
- }
- apiParams.append("limit", String(PAGE_SIZE));
+ // 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,
+ },
+ });
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications?${apiParams.toString()}`
- );
-
- if (response.ok && mounted) {
- const data = await response.json();
- const apiItems: InboxItem[] = data.items.map((item: any) => ({
- ...item,
- metadata: item.metadata || {},
- }));
-
- if (apiItems.length > 0) {
- setInboxItems(apiItems);
+ if (mounted) {
+ if (data.items.length > 0) {
+ setInboxItems(data.items);
}
- setHasMore(data.has_more ?? apiItems.length === PAGE_SIZE);
+ setHasMore(data.has_more);
}
} catch (err) {
console.error("[useInbox] API fallback failed:", err);
@@ -254,10 +244,12 @@ export function useInbox(
}
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 liveItems = result.rows;
const liveItemIds = new Set(liveItems.map((item) => item.id));
// FIXED: Keep ALL items not in live result (not just slice)
@@ -305,43 +297,26 @@ export function useInbox(
const oldestItem = inboxItems.length > 0 ? inboxItems[inboxItems.length - 1] : null;
const beforeDate = oldestItem ? toISOString(oldestItem.created_at) : null;
- const params = new URLSearchParams();
- if (searchSpaceId !== null) {
- params.append("search_space_id", String(searchSpaceId));
- }
- if (typeFilter) {
- params.append("type", typeFilter);
- }
- // Only add before_date if we have a cursor
- // Without before_date, API returns newest items first
- if (beforeDate) {
- params.append("before_date", beforeDate);
- }
- params.append("limit", String(PAGE_SIZE));
-
console.log("[useInbox] Loading more, before:", beforeDate ?? "none (initial)");
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications?${params.toString()}`
- );
+ // 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 (!response.ok) {
- throw new Error("Failed to fetch notifications");
- }
-
- const data = await response.json();
- const apiItems: InboxItem[] = data.items.map((item: any) => ({
- ...item,
- metadata: item.metadata || {},
- }));
-
- if (apiItems.length > 0) {
+ if (data.items.length > 0) {
// Functional update ensures we always merge with latest state
- setInboxItems((prev) => deduplicateAndSort([...prev, ...apiItems]));
+ // Items are already validated by the API service
+ setInboxItems((prev) => deduplicateAndSort([...prev, ...data.items]));
}
- // Use API's has_more flag if available, otherwise check count
- setHasMore(data.has_more ?? apiItems.length === PAGE_SIZE);
+ // Use API's has_more flag
+ setHasMore(data.has_more);
} catch (err) {
console.error("[useInbox] Load more failed:", err);
} finally {
@@ -357,12 +332,10 @@ export function useInbox(
);
try {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/read`,
- { method: "PATCH" }
- );
+ // Use the API service with proper Zod validation
+ const result = await notificationsApiService.markAsRead({ notificationId: itemId });
- if (!response.ok) {
+ if (!result.success) {
// Rollback on error
setInboxItems((prev) =>
prev.map((item) => (item.id === itemId ? { ...item, read: false } : item))
@@ -370,7 +343,7 @@ export function useInbox(
}
// If successful, Electric SQL will sync the change and live query will update
// This ensures eventual consistency even if optimistic update was wrong
- return response.ok;
+ return result.success;
} catch (err) {
console.error("Failed to mark as read:", err);
// Rollback on error
@@ -387,17 +360,15 @@ export function useInbox(
setInboxItems((prev) => prev.map((item) => ({ ...item, read: true })));
try {
- const response = await authenticatedFetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`,
- { method: "PATCH" }
- );
+ // Use the API service with proper Zod validation
+ const result = await notificationsApiService.markAllAsRead();
- if (!response.ok) {
+ if (!result.success) {
console.error("Failed to mark all as read");
// On error, let Electric SQL sync correct the state
}
// Electric SQL will sync and live query will ensure consistency
- return response.ok;
+ return result.success;
} catch (err) {
console.error("Failed to mark all as read:", err);
// On error, let Electric SQL sync correct the state
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..a2489cdee
--- /dev/null
+++ b/surfsense_web/lib/apis/notifications-api.service.ts
@@ -0,0 +1,94 @@
+import {
+ type GetNotificationsRequest,
+ type GetNotificationsResponse,
+ type MarkAllNotificationsReadResponse,
+ type MarkNotificationReadRequest,
+ type MarkNotificationReadResponse,
+ getNotificationsRequest,
+ getNotificationsResponse,
+ 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
+ );
+ };
+}
+
+export const notificationsApiService = new NotificationsApiService();
+
From 3a1fa25a6fe1765641703baebd2a423f30975f20 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 22 Jan 2026 19:08:13 +0530
Subject: [PATCH 19/20] refactor: streamline markAllAsRead method in
notifications API service
- Simplified the return statement in the markAllAsRead method for improved readability and consistency.
---
surfsense_web/lib/apis/notifications-api.service.ts | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/surfsense_web/lib/apis/notifications-api.service.ts b/surfsense_web/lib/apis/notifications-api.service.ts
index a2489cdee..aff220f17 100644
--- a/surfsense_web/lib/apis/notifications-api.service.ts
+++ b/surfsense_web/lib/apis/notifications-api.service.ts
@@ -83,12 +83,8 @@ class NotificationsApiService {
* Mark all notifications as read
*/
markAllAsRead = async (): Promise => {
- return baseApiService.patch(
- "/api/v1/notifications/read-all",
- markAllNotificationsReadResponse
- );
+ return baseApiService.patch("/api/v1/notifications/read-all", markAllNotificationsReadResponse);
};
}
export const notificationsApiService = new NotificationsApiService();
-
From 076de2f3d71255748b736ad6000a612444a3ad50 Mon Sep 17 00:00:00 2001
From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com>
Date: Thu, 22 Jan 2026 22:56:02 +0530
Subject: [PATCH 20/20] feat: add unread count endpoint and update inbox logic
- Introduced a new API endpoint to fetch unread notification counts, providing both total and recent counts for better tracking.
- Updated the useInbox hook to manage separate states for older and recent unread counts, enhancing user experience with real-time updates.
- Implemented logic to handle marking notifications as read, ensuring accurate count adjustments for both recent and older items.
- Enhanced type definitions in the inbox types to support the new unread count functionality.
---
.../app/routes/notifications_routes.py | 68 +++++-
surfsense_web/contracts/types/inbox.types.ts | 18 ++
surfsense_web/hooks/use-inbox.ts | 200 +++++++++++++++---
.../lib/apis/notifications-api.service.ts | 20 ++
4 files changed, 268 insertions(+), 38 deletions(-)
diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py
index 6172aacc5..6bc945643 100644
--- a/surfsense_backend/app/routes/notifications_routes.py
+++ b/surfsense_backend/app/routes/notifications_routes.py
@@ -5,7 +5,7 @@ Electric SQL automatically syncs the changes to all connected clients for recent
For older items (beyond the sync window), use the list endpoint.
"""
-from datetime import datetime
+from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
@@ -17,6 +17,9 @@ 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."""
@@ -60,11 +63,68 @@ 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"
- ),
+ 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"
),
diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts
index 12ebfe1e9..0983bbc55 100644
--- a/surfsense_web/contracts/types/inbox.types.ts
+++ b/surfsense_web/contracts/types/inbox.types.ts
@@ -184,6 +184,22 @@ export const markAllNotificationsReadResponse = z.object({
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
// =============================================================================
@@ -261,3 +277,5 @@ 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/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts
index 7f0bd59ef..4c26ddcb9 100644
--- a/surfsense_web/hooks/use-inbox.ts
+++ b/surfsense_web/hooks/use-inbox.ts
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+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";
@@ -11,6 +11,15 @@ 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.
@@ -84,15 +93,19 @@ export function useInbox(
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);
- // Calculate unread count from inboxItems (includes both recent and older when loaded)
- // This ensures the count is always in sync with what's displayed
- const totalUnreadCount = useMemo(() => {
- return inboxItems.filter((item) => !item.read).length;
- }, [inboxItems]);
+ // Total unread = older (static from server) + recent (live from Electric)
+ const totalUnreadCount = olderUnreadCount + recentUnreadCount;
// EFFECT 1: Electric SQL sync for real-time updates
useEffect(() => {
@@ -167,6 +180,9 @@ export function useInbox(
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
@@ -283,6 +299,97 @@ export function useInbox(
};
}, [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 () => {
@@ -325,39 +432,60 @@ export function useInbox(
}, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]);
// Mark inbox item as read with optimistic update
- const markAsRead = useCallback(async (itemId: number) => {
- // Optimistic update: mark as read immediately for instant UI feedback
- setInboxItems((prev) =>
- prev.map((item) => (item.id === itemId ? { ...item, read: true } : item))
- );
+ // 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);
- try {
- // Use the API service with proper Zod validation
- const result = await notificationsApiService.markAsRead({ notificationId: itemId });
+ // Optimistic update: mark as read immediately for instant UI feedback
+ setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: true } : i)));
- if (!result.success) {
- // Rollback on error
- setInboxItems((prev) =>
- prev.map((item) => (item.id === itemId ? { ...item, read: false } : item))
- );
+ // 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));
}
- // 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((item) => (item.id === itemId ? { ...item, read: false } : item))
- );
- return false;
- }
- }, []);
+
+ 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
@@ -365,16 +493,20 @@ export function useInbox(
if (!result.success) {
console.error("Failed to mark all as read");
- // On error, let Electric SQL sync correct the state
+ // 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);
- // On error, let Electric SQL sync correct the state
+ // Rollback counts on error
+ setOlderUnreadCount(prevOlderCount);
+ setRecentUnreadCount(prevRecentCount);
return false;
}
- }, []);
+ }, [olderUnreadCount, recentUnreadCount]);
return {
inboxItems,
diff --git a/surfsense_web/lib/apis/notifications-api.service.ts b/surfsense_web/lib/apis/notifications-api.service.ts
index aff220f17..927aee747 100644
--- a/surfsense_web/lib/apis/notifications-api.service.ts
+++ b/surfsense_web/lib/apis/notifications-api.service.ts
@@ -1,11 +1,13 @@
import {
type GetNotificationsRequest,
type GetNotificationsResponse,
+ type GetUnreadCountResponse,
type MarkAllNotificationsReadResponse,
type MarkNotificationReadRequest,
type MarkNotificationReadResponse,
getNotificationsRequest,
getNotificationsResponse,
+ getUnreadCountResponse,
markAllNotificationsReadResponse,
markNotificationReadRequest,
markNotificationReadResponse,
@@ -85,6 +87,24 @@ class NotificationsApiService {
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();