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": "出错了",