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 && ( -
-
- - Updating -
-
- )} - {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(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(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 (
-
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 ( + + + + + + + + + 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({ )} -
- -
- {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"} + + 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("mark_all_read") || "Mark all as read"} + + + +
+
+ +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-8 h-9" + /> + {searchQuery && ( + + )} +
+
+ + 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 ( +
+ + + + + +

{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) + } + > + + + + + {!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 ( - - - - - - - - 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 && ( - - )} -
- - {/* 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 ( - - ); - })} -
- - {/* Notifications List */} - - {loading ? ( -
- -
- ) : notifications.length === 0 ? ( -
- -

No notifications

-
- ) : ( -
- {notifications.map((notification, index) => ( -
- - {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"}

-
- +
+ +

{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"}

-
- +
+ +

{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 && ( + + )} + +
@@ -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({ - )} - -
@@ -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) - } - > - - - - - {!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"} @@ -359,30 +365,23 @@ export function InboxSidebar({ - setOpenDropdown(isOpen ? "options" : null)} - > - + + - - - - - {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 */} +
+
+ + {uniqueConnectors.map((connector) => ( + + + + + + {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 ? ( + <> + + - - - - {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"} +

+
+ + +
+
+ {/* Connectors section - only for status tab */} + {activeTab === "status" && uniqueConnectorTypes.length > 0 && ( +
+

+ {t("connectors") || "Connectors"} +

+
+ + {uniqueConnectorTypes.map((connector) => ( + + ))} +
+
+ )} +
+
+
+ + ) : ( + /* Desktop: Dropdown menu */ + setOpenDropdown(isOpen ? "filter" : null)} + > + + + + + + + + {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 && } + + ))} + + )} + + + )} - {uniqueConnectors.map((connector) => ( - - - - - - {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 */} -
+
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"} @@ -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();