diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py index deee748d8..6bc945643 100644 --- a/surfsense_backend/app/routes/notifications_routes.py +++ b/surfsense_backend/app/routes/notifications_routes.py @@ -1,12 +1,15 @@ """ Notifications API routes. -These endpoints allow marking notifications as read. -Electric SQL automatically syncs the changes to all connected clients. +These endpoints allow marking notifications as read and fetching older notifications. +Electric SQL automatically syncs the changes to all connected clients for recent items. +For older items (beyond the sync window), use the list endpoint. """ -from fastapi import APIRouter, Depends, HTTPException, status +from datetime import UTC, datetime, timedelta + +from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel -from sqlalchemy import select, update +from sqlalchemy import desc, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.db import Notification, User, get_async_session @@ -14,6 +17,36 @@ from app.users import current_active_user router = APIRouter(prefix="/notifications", tags=["notifications"]) +# Must match frontend SYNC_WINDOW_DAYS in use-inbox.ts +SYNC_WINDOW_DAYS = 14 + + +class NotificationResponse(BaseModel): + """Response model for a single notification.""" + + id: int + user_id: str + search_space_id: int | None + type: str + title: str + message: str + read: bool + metadata: dict + created_at: str + updated_at: str | None + + class Config: + from_attributes = True + + +class NotificationListResponse(BaseModel): + """Response for listing notifications with pagination.""" + + items: list[NotificationResponse] + total: int + has_more: bool + next_offset: int | None + class MarkReadResponse(BaseModel): """Response for mark as read operations.""" @@ -30,6 +63,169 @@ class MarkAllReadResponse(BaseModel): updated_count: int +class UnreadCountResponse(BaseModel): + """Response for unread count with split between recent and older items.""" + + total_unread: int + recent_unread: int # Within SYNC_WINDOW_DAYS + + +@router.get("/unread-count", response_model=UnreadCountResponse) +async def get_unread_count( + search_space_id: int | None = Query(None, description="Filter by search space ID"), + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +) -> UnreadCountResponse: + """ + Get the total unread notification count for the current user. + + Returns both: + - total_unread: All unread notifications (for accurate badge count) + - recent_unread: Unread notifications within the sync window (last 14 days) + + This allows the frontend to calculate: + - older_unread = total_unread - recent_unread (static until reconciliation) + - Display count = older_unread + live_recent_count (from Electric SQL) + """ + # Calculate cutoff date for sync window + cutoff_date = datetime.now(UTC) - timedelta(days=SYNC_WINDOW_DAYS) + + # Base filter for user's unread notifications + base_filter = [ + Notification.user_id == user.id, + Notification.read == False, # noqa: E712 + ] + + # Add search space filter if provided (include null for global notifications) + if search_space_id is not None: + base_filter.append( + (Notification.search_space_id == search_space_id) + | (Notification.search_space_id.is_(None)) + ) + + # Total unread count (all time) + total_query = select(func.count(Notification.id)).where(*base_filter) + total_result = await session.execute(total_query) + total_unread = total_result.scalar() or 0 + + # Recent unread count (within sync window) + recent_query = select(func.count(Notification.id)).where( + *base_filter, + Notification.created_at > cutoff_date, + ) + recent_result = await session.execute(recent_query) + recent_unread = recent_result.scalar() or 0 + + return UnreadCountResponse( + total_unread=total_unread, + recent_unread=recent_unread, + ) + + +@router.get("", response_model=NotificationListResponse) +async def list_notifications( + search_space_id: int | None = Query(None, description="Filter by search space ID"), + type_filter: str | None = Query( + None, alias="type", description="Filter by notification type" + ), + before_date: str | None = Query( + None, description="Get notifications before this ISO date (for pagination)" + ), + limit: int = Query(50, ge=1, le=100, description="Number of items to return"), + offset: int = Query(0, ge=0, description="Number of items to skip"), + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +) -> NotificationListResponse: + """ + List notifications for the current user with pagination. + + This endpoint is used as a fallback for older notifications that are + outside the Electric SQL sync window (2 weeks). + + Use `before_date` to paginate through older notifications efficiently. + """ + # Build base query + query = select(Notification).where(Notification.user_id == user.id) + count_query = select(func.count(Notification.id)).where( + Notification.user_id == user.id + ) + + # Filter by search space (include null search_space_id for global notifications) + if search_space_id is not None: + query = query.where( + (Notification.search_space_id == search_space_id) + | (Notification.search_space_id.is_(None)) + ) + count_query = count_query.where( + (Notification.search_space_id == search_space_id) + | (Notification.search_space_id.is_(None)) + ) + + # Filter by type + if type_filter: + query = query.where(Notification.type == type_filter) + count_query = count_query.where(Notification.type == type_filter) + + # Filter by date (for efficient pagination of older items) + if before_date: + try: + before_datetime = datetime.fromisoformat(before_date.replace("Z", "+00:00")) + query = query.where(Notification.created_at < before_datetime) + count_query = count_query.where(Notification.created_at < before_datetime) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid date format. Use ISO format (e.g., 2024-01-15T00:00:00Z)", + ) from None + + # Get total count + total_result = await session.execute(count_query) + total = total_result.scalar() or 0 + + # Apply ordering and pagination + query = ( + query.order_by(desc(Notification.created_at)).offset(offset).limit(limit + 1) + ) + + # Execute query + result = await session.execute(query) + notifications = result.scalars().all() + + # Check if there are more items + has_more = len(notifications) > limit + if has_more: + notifications = notifications[:limit] + + # Convert to response format + items = [] + for notification in notifications: + items.append( + NotificationResponse( + id=notification.id, + user_id=str(notification.user_id), + search_space_id=notification.search_space_id, + type=notification.type, + title=notification.title, + message=notification.message, + read=notification.read, + metadata=notification.notification_metadata or {}, + created_at=notification.created_at.isoformat() + if notification.created_at + else "", + updated_at=notification.updated_at.isoformat() + if notification.updated_at + else None, + ) + ) + + return NotificationListResponse( + items=items, + total=total, + has_more=has_more, + next_offset=offset + limit if has_more else None, + ) + + @router.patch("/{notification_id}/read", response_model=MarkReadResponse) async def mark_notification_as_read( notification_id: int, diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index c20436a5e..88c532cf2 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -368,7 +368,7 @@ export default function NewChatPage() { initializeThread(); }, [initializeThread]); - // Handle scroll to comment from URL query params (e.g., from notification click) + // Handle scroll to comment from URL query params (e.g., from inbox item click) const searchParams = useSearchParams(); const targetCommentId = searchParams.get("commentId"); diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index 6701342de..b661e9222 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -3,29 +3,38 @@ import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { + Bot, Calendar, Check, Clock, Copy, Crown, Edit2, + FileText, Hash, Link2, LinkIcon, Loader2, + Logs, + type LucideIcon, + MessageCircle, + MessageSquare, + Mic, MoreHorizontal, + Plug, Plus, RefreshCw, Search, + Settings, Shield, ShieldCheck, Trash2, - User, UserMinus, UserPlus, Users, } from "lucide-react"; import { motion } from "motion/react"; +import Image from "next/image"; import { useParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -512,6 +521,25 @@ export default function TeamManagementPage() { // ============ Members Tab ============ +// Helper function to get avatar initials +function getAvatarInitials(member: Membership): string { + // Try display name first + if (member.user_display_name) { + const parts = member.user_display_name.trim().split(/\s+/); + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return member.user_display_name.slice(0, 2).toUpperCase(); + } + // Try email + if (member.user_email) { + const emailName = member.user_email.split("@")[0]; + return emailName.slice(0, 2).toUpperCase(); + } + // Fallback + return "U"; +} + function MembersTab({ members, roles, @@ -560,7 +588,7 @@ function MembersTab({
setSearchQuery(e.target.value)} className="pl-9" @@ -573,10 +601,30 @@ function MembersTab({ - Member - Role - Joined - Actions + +
+ + Member +
+
+ +
+ + Role +
+
+ +
+ + Joined +
+
+ +
+ + Actions +
+
@@ -601,19 +649,36 @@ function MembersTab({
-
- -
+ {member.user_avatar_url ? ( + + ) : ( +
+ + {getAvatarInitials(member)} + +
+ )} {member.is_owner && ( -
+
)}

- {member.user_email || "Unknown"} + {member.user_display_name || member.user_email || "Unknown"}

+ {member.user_display_name && member.user_email && ( +

+ {member.user_email} +

+ )} {member.is_owner && ( No role {roles.map((role) => ( -
- - {role.name} -
+ {role.name}
))} ) : ( - - + {member.role?.name || "No role"} )} -
- + {new Date(member.joined_at).toLocaleDateString()} -
+
{canRemove && !member.is_owner && ( @@ -708,13 +765,137 @@ function MembersTab({ ); } +// ============ Role Permissions Display ============ + +const CATEGORY_CONFIG: Record = { + documents: { label: "Documents", icon: FileText, order: 1 }, + chats: { label: "Chats", icon: MessageSquare, order: 2 }, + comments: { label: "Comments", icon: MessageCircle, order: 3 }, + llm_configs: { label: "LLM Configs", icon: Bot, order: 4 }, + podcasts: { label: "Podcasts", icon: Mic, order: 5 }, + connectors: { label: "Connectors", icon: Plug, order: 6 }, + logs: { label: "Logs", icon: Logs, order: 7 }, + members: { label: "Members", icon: Users, order: 8 }, + roles: { label: "Roles", icon: Shield, order: 9 }, + settings: { label: "Settings", icon: Settings, order: 10 }, +}; + +const ACTION_LABELS: Record = { + create: "Create", + read: "Read", + update: "Update", + delete: "Delete", + invite: "Invite", + view: "View", + remove: "Remove", + manage_roles: "Manage Roles", +}; + +function RolePermissionsDisplay({ permissions }: { permissions: string[] }) { + if (permissions.includes("*")) { + return ( +
+
+ +
+
+

Full Access

+

All permissions granted

+
+
+ ); + } + + // Group permissions by category + const grouped: Record = {}; + for (const perm of permissions) { + const [category, action] = perm.split(":"); + if (!grouped[category]) grouped[category] = []; + grouped[category].push(action); + } + + // Sort categories by predefined order + const sortedCategories = Object.keys(grouped).sort((a, b) => { + const orderA = CATEGORY_CONFIG[a]?.order ?? 99; + const orderB = CATEGORY_CONFIG[b]?.order ?? 99; + return orderA - orderB; + }); + + const categoryCount = sortedCategories.length; + + return ( + + + + + + + + + Role Permissions + + + {permissions.length} permissions across {categoryCount} categories + + + +
+ {sortedCategories.map((category) => { + const actions = grouped[category]; + const config = CATEGORY_CONFIG[category] || { label: category, icon: FileText }; + const IconComponent = config.icon; + return ( +
+
+ + {config.label} +
+
+ {actions.map((action) => ( + + {ACTION_LABELS[action] || action.replace(/_/g, " ")} + + ))} +
+
+ ); + })} +
+
+
+
+ ); +} + // ============ Roles Tab ============ function RolesTab({ roles, - groupedPermissions, + groupedPermissions: _groupedPermissions, loading, - onUpdateRole, + onUpdateRole: _onUpdateRole, onDeleteRole, canUpdate, canDelete, @@ -852,32 +1033,7 @@ function RolesTab({ )} -
- -
- {role.permissions.includes("*") ? ( - - Full Access - - ) : ( - role.permissions.slice(0, 5).map((perm) => ( - - {perm.replace(":", " ")} - - )) - )} - {!role.permissions.includes("*") && role.permissions.length > 5 && ( - - +{role.permissions.length - 5} more - - )} -
-
+
@@ -1500,7 +1656,11 @@ function CreateRoleDialog({ return (
-
diff --git a/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx b/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx index 0def32932..c72c77f65 100644 --- a/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx +++ b/surfsense_web/components/chat-comments/comment-panel/comment-panel.tsx @@ -69,7 +69,7 @@ export function CommentPanel({ style={!isMobile && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined} > {hasThreads && ( -
+
{threads.map((thread) => ( )} -
+
+ + + + + + Comments + {commentCount > 0 && ( + + {commentCount} + + )} + + +
+ +
+
+ + ); + } + + // Use Sheet for medium screens (right side) return ( - {/* Drag handle indicator - only for bottom sheet */} - {isBottomSheet && ( -
-
-
- )} - + Comments diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 489fde3d7..5f4617b84 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -2,7 +2,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; -import { LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react"; +import { Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; @@ -19,6 +19,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { useInbox } from "@/hooks/use-inbox"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence"; import { cleanupElectric } from "@/lib/electric/client"; @@ -29,6 +30,7 @@ import { CreateSearchSpaceDialog } from "../ui/dialogs"; import { LayoutShell } from "../ui/shell"; import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar"; import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar"; +import { InboxSidebar } from "../ui/sidebar/InboxSidebar"; interface LayoutDataProviderProps { searchSpaceId: string; @@ -59,8 +61,8 @@ export function LayoutDataProvider({ ? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id) : null; - // Fetch current search space - const { data: searchSpace } = useQuery({ + // Fetch current search space (for caching purposes) + useQuery({ queryKey: cacheKeys.searchSpaces.detail(searchSpaceId), queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }), enabled: !!searchSpaceId, @@ -77,9 +79,25 @@ export function LayoutDataProvider({ const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false); const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false); + // Inbox sidebar state + const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false); + // Search space dialog state const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); + // Inbox hook + const userId = user?.id ? String(user.id) : null; + const { + inboxItems, + unreadCount, + loading: inboxLoading, + loadingMore: inboxLoadingMore, + hasMore: inboxHasMore, + loadMore: inboxLoadMore, + markAsRead, + markAllAsRead, + } = useInbox(userId, Number(searchSpaceId) || null, null); + // Delete dialogs state const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false); const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); @@ -149,14 +167,21 @@ export function LayoutDataProvider({ icon: SquareLibrary, isActive: pathname?.includes("/documents"), }, + // { + // title: "Logs", + // url: `/dashboard/${searchSpaceId}/logs`, + // icon: Logs, + // isActive: pathname?.includes("/logs"), + // }, { - title: "Logs", - url: `/dashboard/${searchSpaceId}/logs`, - icon: Logs, - isActive: pathname?.includes("/logs"), + title: "Inbox", + url: "#inbox", // Special URL to indicate this is handled differently + icon: Inbox, + isActive: isInboxSidebarOpen, + badge: unreadCount > 0 ? (unreadCount > 99 ? "99+" : unreadCount) : undefined, }, ], - [searchSpaceId, pathname] + [searchSpaceId, pathname, isInboxSidebarOpen, unreadCount] ); // Handlers @@ -248,6 +273,11 @@ export function LayoutDataProvider({ const handleNavItemClick = useCallback( (item: NavItem) => { + // Handle inbox specially - open sidebar instead of navigating + if (item.url === "#inbox") { + setIsInboxSidebarOpen(true); + return; + } router.push(item.url); }, [router] @@ -517,6 +547,20 @@ export function LayoutDataProvider({ searchSpaceId={searchSpaceId} /> + {/* Inbox Sidebar */} + + {/* Create Search Space Dialog */} - {/* Notifications */} - {/* Share button - only show on chat pages when thread exists */} {hasThread && ( diff --git a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx index f5c64cc67..39f1b95bc 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllPrivateChatsSidebar.tsx @@ -28,6 +28,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { @@ -237,20 +238,9 @@ export function AllPrivateChatsSidebar({ aria-label={t("chats") || "Private Chats"} >
-
-
- -

{t("chats") || "Private Chats"}

-
- +
+ +

{t("chats") || "Private Chats"}

@@ -277,32 +267,38 @@ export function AllPrivateChatsSidebar({
{!isSearchMode && ( -
- - -
+ setShowArchived(value === "archived")} + className="shrink-0 mx-4" + > + + + + + Active + + {activeCount} + + + + + + + Archived + + {archivedCount} + + + + + )}
@@ -371,7 +367,7 @@ export function AllPrivateChatsSidebar({ {isDeleting ? ( ) : ( - + )} {t("more_options") || "More options"} diff --git a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx index f50cb028a..8dd593945 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllSharedChatsSidebar.tsx @@ -28,6 +28,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { @@ -237,20 +238,9 @@ export function AllSharedChatsSidebar({ aria-label={t("shared_chats") || "Shared Chats"} >
-
-
- -

{t("shared_chats") || "Shared Chats"}

-
- +
+ +

{t("shared_chats") || "Shared Chats"}

@@ -277,32 +267,38 @@ export function AllSharedChatsSidebar({
{!isSearchMode && ( -
- - -
+ setShowArchived(value === "archived")} + className="shrink-0 mx-4" + > + + + + + Active + + {activeCount} + + + + + + + Archived + + {archivedCount} + + + + + )}
@@ -371,7 +367,7 @@ export function AllSharedChatsSidebar({ {isDeleting ? ( ) : ( - + )} {t("more_options") || "More options"} diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx index 7f5ede04c..5dd9c2cfa 100644 --- a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx +++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx @@ -39,11 +39,11 @@ export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItem {/* Actions dropdown */} -
+
diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx new file mode 100644 index 000000000..166d77eca --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -0,0 +1,854 @@ +"use client"; + +import { + AlertCircle, + AtSign, + BellDot, + Check, + CheckCheck, + CheckCircle2, + History, + Inbox, + LayoutGrid, + ListFilter, + Search, + X, +} from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerContent, + DrawerHandle, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import type { InboxItem } from "@/hooks/use-inbox"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { + type ConnectorIndexingMetadata, + type NewMentionMetadata, + isConnectorIndexingMetadata, + isNewMentionMetadata, +} from "@/contracts/types/inbox.types"; +import { cn } from "@/lib/utils"; + +/** + * Get initials from name or email for avatar fallback + */ +function getInitials(name: string | null | undefined, email: string | null | undefined): string { + if (name) { + return name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2); + } + if (email) { + const localPart = email.split("@")[0]; + return localPart.slice(0, 2).toUpperCase(); + } + return "U"; +} + +/** + * Get display name for connector type + */ +function getConnectorTypeDisplayName(connectorType: string): string { + const displayNames: Record = { + GITHUB_CONNECTOR: "GitHub", + GOOGLE_CALENDAR_CONNECTOR: "Google Calendar", + GOOGLE_GMAIL_CONNECTOR: "Gmail", + GOOGLE_DRIVE_CONNECTOR: "Google Drive", + LINEAR_CONNECTOR: "Linear", + NOTION_CONNECTOR: "Notion", + SLACK_CONNECTOR: "Slack", + TEAMS_CONNECTOR: "Microsoft Teams", + DISCORD_CONNECTOR: "Discord", + JIRA_CONNECTOR: "Jira", + CONFLUENCE_CONNECTOR: "Confluence", + BOOKSTACK_CONNECTOR: "BookStack", + CLICKUP_CONNECTOR: "ClickUp", + AIRTABLE_CONNECTOR: "Airtable", + LUMA_CONNECTOR: "Luma", + ELASTICSEARCH_CONNECTOR: "Elasticsearch", + WEBCRAWLER_CONNECTOR: "Web Crawler", + YOUTUBE_CONNECTOR: "YouTube", + CIRCLEBACK_CONNECTOR: "Circleback", + MCP_CONNECTOR: "MCP", + TAVILY_API: "Tavily", + SEARXNG_API: "SearXNG", + LINKUP_API: "Linkup", + BAIDU_SEARCH_API: "Baidu", + }; + + return ( + displayNames[connectorType] || + connectorType + .replace(/_/g, " ") + .replace(/CONNECTOR|API/gi, "") + .trim() + ); +} + +type InboxTab = "mentions" | "status"; +type InboxFilter = "all" | "unread"; + +interface InboxSidebarProps { + open: boolean; + onOpenChange: (open: boolean) => void; + inboxItems: InboxItem[]; + unreadCount: number; + loading: boolean; + loadingMore?: boolean; + hasMore?: boolean; + loadMore?: () => void; + markAsRead: (id: number) => Promise; + markAllAsRead: () => Promise; + onCloseMobileSidebar?: () => void; +} + +export function InboxSidebar({ + open, + onOpenChange, + inboxItems, + unreadCount, + loading, + loadingMore = false, + hasMore = false, + loadMore, + markAsRead, + markAllAsRead, + onCloseMobileSidebar, +}: InboxSidebarProps) { + const t = useTranslations("sidebar"); + const router = useRouter(); + const isMobile = !useMediaQuery("(min-width: 640px)"); + + const [searchQuery, setSearchQuery] = useState(""); + const [activeTab, setActiveTab] = useState("mentions"); + const [activeFilter, setActiveFilter] = useState("all"); + const [selectedConnector, setSelectedConnector] = useState(null); + const [mounted, setMounted] = useState(false); + // Dropdown state for filter menu (desktop only) + const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null); + // Drawer state for filter menu (mobile only) + const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); + const [markingAsReadId, setMarkingAsReadId] = useState(null); + + // Prefetch trigger ref - placed on item near the end + const prefetchTriggerRef = useRef(null); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && open) { + onOpenChange(false); + } + }; + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [open, onOpenChange]); + + useEffect(() => { + if (open) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [open]); + + // Reset connector filter when switching away from status tab + useEffect(() => { + if (activeTab !== "status") { + setSelectedConnector(null); + } + }, [activeTab]); + + // Split items by type + const mentionItems = useMemo( + () => inboxItems.filter((item) => item.type === "new_mention"), + [inboxItems] + ); + + const statusItems = useMemo( + () => + inboxItems.filter( + (item) => item.type === "connector_indexing" || item.type === "document_processing" + ), + [inboxItems] + ); + + // Get unique connector types from status items for filtering + const uniqueConnectorTypes = useMemo(() => { + const connectorTypes = new Set(); + + statusItems + .filter((item) => item.type === "connector_indexing") + .forEach((item) => { + // Use type guard for safe metadata access + if (isConnectorIndexingMetadata(item.metadata)) { + connectorTypes.add(item.metadata.connector_type); + } + }); + + return Array.from(connectorTypes).map((type) => ({ + type, + displayName: getConnectorTypeDisplayName(type), + })); + }, [statusItems]); + + // Get items for current tab + const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems; + + // Filter items based on filter type, connector filter, and search query + const filteredItems = useMemo(() => { + let items = currentTabItems; + + // Apply read/unread filter + if (activeFilter === "unread") { + items = items.filter((item) => !item.read); + } + + // Apply connector filter (only for status tab) + if (activeTab === "status" && selectedConnector) { + items = items.filter((item) => { + if (item.type === "connector_indexing") { + // Use type guard for safe metadata access + if (isConnectorIndexingMetadata(item.metadata)) { + return item.metadata.connector_type === selectedConnector; + } + return false; + } + return false; // Hide document_processing when a specific connector is selected + }); + } + + // Apply search query + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + items = items.filter( + (item) => + item.title.toLowerCase().includes(query) || item.message.toLowerCase().includes(query) + ); + } + + return items; + }, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]); + + // Intersection Observer for infinite scroll with prefetching + // Only active when not searching (search results are client-side filtered) + useEffect(() => { + if (!loadMore || !hasMore || loadingMore || !open || searchQuery.trim()) return; + + const observer = new IntersectionObserver( + (entries) => { + // When trigger element is visible, load more + if (entries[0]?.isIntersecting) { + loadMore(); + } + }, + { + root: null, // viewport + rootMargin: "100px", // Start loading 100px before visible + threshold: 0, + } + ); + + if (prefetchTriggerRef.current) { + observer.observe(prefetchTriggerRef.current); + } + + return () => observer.disconnect(); + }, [loadMore, hasMore, loadingMore, open, searchQuery, filteredItems.length]); + + // Count unread items per tab + const unreadMentionsCount = useMemo(() => { + return mentionItems.filter((item) => !item.read).length; + }, [mentionItems]); + + const unreadStatusCount = useMemo(() => { + return statusItems.filter((item) => !item.read).length; + }, [statusItems]); + + const handleItemClick = useCallback( + async (item: InboxItem) => { + if (!item.read) { + setMarkingAsReadId(item.id); + await markAsRead(item.id); + setMarkingAsReadId(null); + } + + if (item.type === "new_mention") { + // Use type guard for safe metadata access + if (isNewMentionMetadata(item.metadata)) { + const searchSpaceId = item.search_space_id; + const threadId = item.metadata.thread_id; + const commentId = item.metadata.comment_id; + + if (searchSpaceId && threadId) { + const url = commentId + ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}` + : `/dashboard/${searchSpaceId}/new-chat/${threadId}`; + onOpenChange(false); + onCloseMobileSidebar?.(); + router.push(url); + } + } + } + }, + [markAsRead, router, onOpenChange, onCloseMobileSidebar] + ); + + const handleMarkAllAsRead = useCallback(async () => { + await markAllAsRead(); + }, [markAllAsRead]); + + const handleClearSearch = useCallback(() => { + setSearchQuery(""); + }, []); + + const formatTime = (dateString: string) => { + try { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMins < 1) return "now"; + if (diffMins < 60) return `${diffMins}m`; + if (diffHours < 24) return `${diffHours}h`; + if (diffDays < 7) return `${diffDays}d`; + return `${Math.floor(diffDays / 7)}w`; + } catch { + return "now"; + } + }; + + const getStatusIcon = (item: InboxItem) => { + // For mentions, show the author's avatar with initials fallback + if (item.type === "new_mention") { + // Use type guard for safe metadata access + if (isNewMentionMetadata(item.metadata)) { + const authorName = item.metadata.author_name; + const avatarUrl = item.metadata.author_avatar_url; + const authorEmail = item.metadata.author_email; + + return ( + + {avatarUrl && } + + {getInitials(authorName, authorEmail)} + + + ); + } + // Fallback for invalid metadata + return ( + + + {getInitials(null, null)} + + + ); + } + + // For status items (connector/document), show status icons + // Safely access status from metadata + const metadata = item.metadata as Record; + const status = typeof metadata?.status === "string" ? metadata.status : undefined; + + switch (status) { + case "in_progress": + return ( +
+ +
+ ); + case "completed": + return ( +
+ +
+ ); + case "failed": + return ( +
+ +
+ ); + default: + return ( +
+ +
+ ); + } + }; + + const getEmptyStateMessage = () => { + if (activeTab === "mentions") { + return { + title: t("no_mentions") || "No mentions", + hint: t("no_mentions_hint") || "You'll see mentions from others here", + }; + } + return { + title: t("no_status_updates") || "No status updates", + hint: t("no_status_updates_hint") || "Document and connector updates will appear here", + }; + }; + + if (!mounted) return null; + + return createPortal( + + {open && ( + <> + onOpenChange(false)} + aria-hidden="true" + /> + + +
+
+
+ +

{t("inbox") || "Inbox"}

+
+
+ {/* Mobile: Button that opens bottom drawer */} + {isMobile ? ( + <> + + + + + {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 && ( + + )} + + ))} + + )} + + + )} + + + + + + {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} + + + + + + + {t("status") || "Status"} + + {unreadStatusCount} + + + + + + +
+ {loading ? ( +
+ +
+ ) : filteredItems.length > 0 ? ( +
+ {filteredItems.map((item, index) => { + const isMarkingAsRead = markingAsReadId === item.id; + // Place prefetch trigger on 5th item from end (only if not searching) + const isPrefetchTrigger = + !searchQuery && hasMore && index === filteredItems.length - 5; + + return ( +
+ + + + + +

{item.title}

+

+ {convertRenderedToDisplay(item.message)} +

+
+
+ + {/* Time and unread dot - fixed width to prevent content shift */} +
+ + {formatTime(item.created_at)} + + {!item.read && ( + + )} +
+
+ ); + })} + {/* Fallback trigger at the very end if less than 5 items and not searching */} + {!searchQuery && filteredItems.length < 5 && hasMore && ( +
+ )} +
+ ) : searchQuery ? ( +
+ +

+ {t("no_results_found") || "No results found"} +

+

+ {t("try_different_search") || "Try a different search term"} +

+
+ ) : ( +
+ {activeTab === "mentions" ? ( + + ) : ( + + )} +

{getEmptyStateMessage().title}

+

+ {getEmptyStateMessage().hint} +

+
+ )} +
+ + + )} + , + document.body + ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx index 7b694055b..d2d926de8 100644 --- a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx +++ b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx @@ -30,7 +30,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti type="button" onClick={() => onItemClick?.(item)} className={cn( - "flex h-10 w-10 items-center justify-center rounded-md transition-colors", + "relative flex h-10 w-10 items-center justify-center rounded-md transition-colors", "hover:bg-accent hover:text-accent-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", item.isActive && "bg-accent text-accent-foreground" @@ -38,6 +38,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti {...joyrideAttr} > + {item.badge && ( + + {item.badge} + + )} {item.title} @@ -64,7 +69,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti > {item.title} - {item.badge && {item.badge}} + {item.badge && ( + + {item.badge} + + )} ); })} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx index 52d681199..0ceafc113 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx @@ -37,7 +37,7 @@ export function SidebarSection({ {/* Action button - visible on hover (always visible on mobile) */} {action && ( -
+
{action}
)} diff --git a/surfsense_web/components/layout/ui/sidebar/index.ts b/surfsense_web/components/layout/ui/sidebar/index.ts index 282e4740b..d9c5edee5 100644 --- a/surfsense_web/components/layout/ui/sidebar/index.ts +++ b/surfsense_web/components/layout/ui/sidebar/index.ts @@ -1,6 +1,7 @@ export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar"; export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar"; export { ChatListItem } from "./ChatListItem"; +export { InboxSidebar } from "./InboxSidebar"; export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar"; export { NavSection } from "./NavSection"; export { PageUsageDisplay } from "./PageUsageDisplay"; diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index 05a8a7306..fcace2572 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -2,7 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; -import { Loader2, User, Users } from "lucide-react"; +import { User, Users } from "lucide-react"; import { useCallback, useState } from "react"; import { toast } from "sonner"; import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom"; @@ -45,7 +45,6 @@ const visibilityOptions: { export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) { const queryClient = useQueryClient(); const [open, setOpen] = useState(false); - const [isUpdating, setIsUpdating] = useState(false); // Use Jotai atom for visibility (single source of truth) const currentThreadState = useAtomValue(currentThreadAtom); @@ -62,7 +61,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS return; } - setIsUpdating(true); // Update Jotai atom immediately for instant UI feedback setThreadVisibility(newVisibility); @@ -84,8 +82,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS // Revert Jotai state on error setThreadVisibility(thread.visibility ?? "PRIVATE"); toast.error("Failed to update sharing settings"); - } finally { - setIsUpdating(false); } }, [thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility] @@ -128,16 +124,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS onCloseAutoFocus={(e) => e.preventDefault()} >
- {/* Updating overlay */} - {isUpdating && ( -
-
- - 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... -
-
- )} -
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/components/shared/llm-config-form.tsx b/surfsense_web/components/shared/llm-config-form.tsx index e2e45194b..5ffff1ab7 100644 --- a/surfsense_web/components/shared/llm-config-form.tsx +++ b/surfsense_web/components/shared/llm-config-form.tsx @@ -551,7 +551,9 @@ export function LLMConfigForm({ render={({ field }) => (
- Enable Citations + + Enable Citations + Include [citation:id] references to source documents diff --git a/surfsense_web/components/ui/drawer.tsx b/surfsense_web/components/ui/drawer.tsx new file mode 100644 index 000000000..015d6ac07 --- /dev/null +++ b/surfsense_web/components/ui/drawer.tsx @@ -0,0 +1,115 @@ +"use client"; + +import * as React from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; + +import { cn } from "@/lib/utils"; + +function Drawer({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) { + return ; +} +Drawer.displayName = "Drawer"; + +const DrawerTrigger = DrawerPrimitive.Trigger; + +const DrawerPortal = DrawerPrimitive.Portal; + +const DrawerClose = DrawerPrimitive.Close; + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; + +function DrawerContent({ + className, + children, + overlayClassName, + ...props +}: React.ComponentProps & { + overlayClassName?: string; +}) { + return ( + + + + {children} + + + ); +} +DrawerContent.displayName = "DrawerContent"; + +function DrawerHeader({ className, ...props }: React.HTMLAttributes) { + return
; +} +DrawerHeader.displayName = "DrawerHeader"; + +function DrawerFooter({ className, ...props }: React.HTMLAttributes) { + return
; +} +DrawerFooter.displayName = "DrawerFooter"; + +function DrawerTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} +DrawerTitle.displayName = DrawerPrimitive.Title.displayName; + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} +DrawerDescription.displayName = DrawerPrimitive.Description.displayName; + +function DrawerHandle({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ); +} +DrawerHandle.displayName = "DrawerHandle"; + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, + DrawerHandle, +}; diff --git a/surfsense_web/components/ui/sheet.tsx b/surfsense_web/components/ui/sheet.tsx index accd4f782..650e85403 100644 --- a/surfsense_web/components/ui/sheet.tsx +++ b/surfsense_web/components/ui/sheet.tsx @@ -42,13 +42,15 @@ function SheetContent({ className, children, side = "right", + overlayClassName, ...props }: React.ComponentProps & { side?: "top" | "right" | "bottom" | "left"; + overlayClassName?: string; }) { return ( - + + ); +} diff --git a/surfsense_web/content/docs/how-to/electric-sql.mdx b/surfsense_web/content/docs/how-to/electric-sql.mdx index 54244c19b..288745850 100644 --- a/surfsense_web/content/docs/how-to/electric-sql.mdx +++ b/surfsense_web/content/docs/how-to/electric-sql.mdx @@ -5,11 +5,11 @@ description: Setting up Electric SQL for real-time data synchronization in SurfS # Electric SQL -[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for notifications, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL. +[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for inbox items, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL. ## What Does Electric SQL Do? -When you index documents or receive notifications, Electric SQL pushes updates to your browser in real-time. The data flows like this: +When you index documents or receive inbox updates, Electric SQL pushes updates to your browser in real-time. The data flows like this: 1. Backend writes data to PostgreSQL 2. Electric SQL detects changes and streams them to the frontend @@ -18,7 +18,7 @@ When you index documents or receive notifications, Electric SQL pushes updates t This means: -- **Notifications appear instantly** - No need to refresh the page +- **Inbox updates appear instantly** - No need to refresh the page - **Document indexing progress updates live** - Watch your documents get processed - **Connector status syncs automatically** - See when connectors finish syncing - **Offline support** - PGlite caches data locally, so previously loaded data remains accessible diff --git a/surfsense_web/contracts/types/inbox.types.ts b/surfsense_web/contracts/types/inbox.types.ts new file mode 100644 index 000000000..0983bbc55 --- /dev/null +++ b/surfsense_web/contracts/types/inbox.types.ts @@ -0,0 +1,281 @@ +import { z } from "zod"; +import { searchSourceConnectorTypeEnum } from "./connector.types"; +import { documentTypeEnum } from "./document.types"; + +/** + * Inbox item type enum - matches backend notification types + */ +export const inboxItemTypeEnum = z.enum([ + "connector_indexing", + "document_processing", + "new_mention", +]); + +/** + * Inbox item status enum - used in metadata + */ +export const inboxItemStatusEnum = z.enum(["in_progress", "completed", "failed"]); + +/** + * Document processing stage enum + */ +export const documentProcessingStageEnum = z.enum([ + "queued", + "parsing", + "chunking", + "embedding", + "storing", + "completed", + "failed", +]); + +/** + * Base metadata schema shared across inbox item types + */ +export const baseInboxItemMetadata = z.object({ + operation_id: z.string().optional(), + status: inboxItemStatusEnum.optional(), + started_at: z.string().optional(), + completed_at: z.string().optional(), +}); + +/** + * Connector indexing metadata schema + */ +export const connectorIndexingMetadata = baseInboxItemMetadata.extend({ + connector_id: z.number(), + connector_name: z.string(), + connector_type: searchSourceConnectorTypeEnum, + start_date: z.string().nullable().optional(), + end_date: z.string().nullable().optional(), + indexed_count: z.number(), + total_count: z.number().optional(), + progress_percent: z.number().optional(), + error_message: z.string().nullable().optional(), + // Google Drive specific fields + folder_count: z.number().optional(), + file_count: z.number().optional(), + folder_names: z.array(z.string()).optional(), + file_names: z.array(z.string()).optional(), +}); + +/** + * Document processing metadata schema + */ +export const documentProcessingMetadata = baseInboxItemMetadata.extend({ + document_type: documentTypeEnum, + document_name: z.string(), + processing_stage: documentProcessingStageEnum, + file_size: z.number().optional(), + chunks_count: z.number().optional(), + document_id: z.number().optional(), + error_message: z.string().nullable().optional(), +}); + +/** + * New mention metadata schema + */ +export const newMentionMetadata = z.object({ + mention_id: z.number(), + comment_id: z.number(), + message_id: z.number(), + thread_id: z.number(), + thread_title: z.string(), + author_id: z.string(), + author_name: z.string(), + author_avatar_url: z.string().nullable().optional(), + author_email: z.string().optional(), + content_preview: z.string(), +}); + +/** + * Union of all inbox item metadata types + * Use this when the inbox item type is unknown + */ +export const inboxItemMetadata = z.union([ + connectorIndexingMetadata, + documentProcessingMetadata, + newMentionMetadata, + baseInboxItemMetadata, +]); + +/** + * Main inbox item schema + */ +export const inboxItem = z.object({ + id: z.number(), + user_id: z.string(), + search_space_id: z.number().nullable(), + type: inboxItemTypeEnum, + title: z.string(), + message: z.string(), + read: z.boolean(), + metadata: z.record(z.string(), z.unknown()), + created_at: z.string(), + updated_at: z.string().nullable(), +}); + +/** + * Typed inbox item schemas for specific types + */ +export const connectorIndexingInboxItem = inboxItem.extend({ + type: z.literal("connector_indexing"), + metadata: connectorIndexingMetadata, +}); + +export const documentProcessingInboxItem = inboxItem.extend({ + type: z.literal("document_processing"), + metadata: documentProcessingMetadata, +}); + +export const newMentionInboxItem = inboxItem.extend({ + type: z.literal("new_mention"), + metadata: newMentionMetadata, +}); + +// ============================================================================= +// API Request/Response Schemas +// ============================================================================= + +/** + * Request schema for getting notifications + */ +export const getNotificationsRequest = z.object({ + queryParams: z.object({ + search_space_id: z.number().optional(), + type: inboxItemTypeEnum.optional(), + before_date: z.string().optional(), + limit: z.number().min(1).max(100).optional(), + offset: z.number().min(0).optional(), + }), +}); + +/** + * Response schema for listing notifications + */ +export const getNotificationsResponse = z.object({ + items: z.array(inboxItem), + total: z.number(), + has_more: z.boolean(), + next_offset: z.number().nullable(), +}); + +/** + * Request schema for marking a single notification as read + */ +export const markNotificationReadRequest = z.object({ + notificationId: z.number(), +}); + +/** + * Response schema for mark as read operations + */ +export const markNotificationReadResponse = z.object({ + success: z.boolean(), + message: z.string(), +}); + +/** + * Response schema for mark all as read operation + */ +export const markAllNotificationsReadResponse = z.object({ + success: z.boolean(), + message: z.string(), + updated_count: z.number(), +}); + +/** + * Request schema for getting unread count + */ +export const getUnreadCountRequest = z.object({ + search_space_id: z.number().optional(), +}); + +/** + * Response schema for unread count + * Returns both total and recent counts for split tracking + */ +export const getUnreadCountResponse = z.object({ + total_unread: z.number(), + recent_unread: z.number(), // Within SYNC_WINDOW_DAYS (14 days) +}); + +// ============================================================================= +// Type Guards for Metadata +// ============================================================================= + +/** + * Type guard for ConnectorIndexingMetadata + */ +export function isConnectorIndexingMetadata( + metadata: unknown +): metadata is ConnectorIndexingMetadata { + return connectorIndexingMetadata.safeParse(metadata).success; +} + +/** + * Type guard for DocumentProcessingMetadata + */ +export function isDocumentProcessingMetadata( + metadata: unknown +): metadata is DocumentProcessingMetadata { + return documentProcessingMetadata.safeParse(metadata).success; +} + +/** + * Type guard for NewMentionMetadata + */ +export function isNewMentionMetadata(metadata: unknown): metadata is NewMentionMetadata { + return newMentionMetadata.safeParse(metadata).success; +} + +/** + * Safe metadata parser - returns typed metadata or null + */ +export function parseInboxItemMetadata( + type: InboxItemTypeEnum, + metadata: unknown +): ConnectorIndexingMetadata | DocumentProcessingMetadata | NewMentionMetadata | null { + switch (type) { + case "connector_indexing": { + const result = connectorIndexingMetadata.safeParse(metadata); + return result.success ? result.data : null; + } + case "document_processing": { + const result = documentProcessingMetadata.safeParse(metadata); + return result.success ? result.data : null; + } + case "new_mention": { + const result = newMentionMetadata.safeParse(metadata); + return result.success ? result.data : null; + } + default: + return null; + } +} + +// ============================================================================= +// Inferred types +// ============================================================================= + +export type InboxItemTypeEnum = z.infer; +export type InboxItemStatusEnum = z.infer; +export type DocumentProcessingStageEnum = z.infer; +export type BaseInboxItemMetadata = z.infer; +export type ConnectorIndexingMetadata = z.infer; +export type DocumentProcessingMetadata = z.infer; +export type NewMentionMetadata = z.infer; +export type InboxItemMetadata = z.infer; +export type InboxItem = z.infer; +export type ConnectorIndexingInboxItem = z.infer; +export type DocumentProcessingInboxItem = z.infer; +export type NewMentionInboxItem = z.infer; + +// API Request/Response types +export type GetNotificationsRequest = z.infer; +export type GetNotificationsResponse = z.infer; +export type MarkNotificationReadRequest = z.infer; +export type MarkNotificationReadResponse = z.infer; +export type MarkAllNotificationsReadResponse = z.infer; +export type GetUnreadCountRequest = z.infer; +export type GetUnreadCountResponse = z.infer; diff --git a/surfsense_web/contracts/types/notification.types.ts b/surfsense_web/contracts/types/notification.types.ts deleted file mode 100644 index b2b39d26e..000000000 --- a/surfsense_web/contracts/types/notification.types.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { z } from "zod"; -import { searchSourceConnectorTypeEnum } from "./connector.types"; -import { documentTypeEnum } from "./document.types"; - -/** - * Notification type enum - matches backend notification types - */ -export const notificationTypeEnum = z.enum([ - "connector_indexing", - "document_processing", - "new_mention", -]); - -/** - * Notification status enum - used in metadata - */ -export const notificationStatusEnum = z.enum(["in_progress", "completed", "failed"]); - -/** - * Document processing stage enum - */ -export const documentProcessingStageEnum = z.enum([ - "queued", - "parsing", - "chunking", - "embedding", - "storing", - "completed", - "failed", -]); - -/** - * Base metadata schema shared across notification types - */ -export const baseNotificationMetadata = z.object({ - operation_id: z.string().optional(), - status: notificationStatusEnum.optional(), - started_at: z.string().optional(), - completed_at: z.string().optional(), -}); - -/** - * Connector indexing metadata schema - */ -export const connectorIndexingMetadata = baseNotificationMetadata.extend({ - connector_id: z.number(), - connector_name: z.string(), - connector_type: searchSourceConnectorTypeEnum, - start_date: z.string().nullable().optional(), - end_date: z.string().nullable().optional(), - indexed_count: z.number(), - total_count: z.number().optional(), - progress_percent: z.number().optional(), - error_message: z.string().nullable().optional(), - // Google Drive specific fields - folder_count: z.number().optional(), - file_count: z.number().optional(), - folder_names: z.array(z.string()).optional(), - file_names: z.array(z.string()).optional(), -}); - -/** - * Document processing metadata schema - */ -export const documentProcessingMetadata = baseNotificationMetadata.extend({ - document_type: documentTypeEnum, - document_name: z.string(), - processing_stage: documentProcessingStageEnum, - file_size: z.number().optional(), - chunks_count: z.number().optional(), - document_id: z.number().optional(), - error_message: z.string().nullable().optional(), -}); - -/** - * New mention metadata schema - */ -export const newMentionMetadata = z.object({ - mention_id: z.number(), - comment_id: z.number(), - message_id: z.number(), - thread_id: z.number(), - thread_title: z.string(), - author_id: z.string(), - author_name: z.string(), - author_avatar_url: z.string().nullable().optional(), - author_email: z.string().optional(), - content_preview: z.string(), -}); - -/** - * Union of all notification metadata types - * Use this when the notification type is unknown - */ -export const notificationMetadata = z.union([ - connectorIndexingMetadata, - documentProcessingMetadata, - newMentionMetadata, - baseNotificationMetadata, -]); - -/** - * Main notification schema - */ -export const notification = z.object({ - id: z.number(), - user_id: z.string(), - search_space_id: z.number().nullable(), - type: notificationTypeEnum, - title: z.string(), - message: z.string(), - read: z.boolean(), - metadata: z.record(z.string(), z.unknown()), - created_at: z.string(), - updated_at: z.string().nullable(), -}); - -/** - * Typed notification schemas for specific notification types - */ -export const connectorIndexingNotification = notification.extend({ - type: z.literal("connector_indexing"), - metadata: connectorIndexingMetadata, -}); - -export const documentProcessingNotification = notification.extend({ - type: z.literal("document_processing"), - metadata: documentProcessingMetadata, -}); - -export const newMentionNotification = notification.extend({ - type: z.literal("new_mention"), - metadata: newMentionMetadata, -}); - -// Inferred types -export type NotificationTypeEnum = z.infer; -export type NotificationStatusEnum = z.infer; -export type DocumentProcessingStageEnum = z.infer; -export type BaseNotificationMetadata = z.infer; -export type ConnectorIndexingMetadata = z.infer; -export type DocumentProcessingMetadata = z.infer; -export type NewMentionMetadata = z.infer; -export type NotificationMetadata = z.infer; -export type Notification = z.infer; -export type ConnectorIndexingNotification = z.infer; -export type DocumentProcessingNotification = z.infer; -export type NewMentionNotification = z.infer; diff --git a/surfsense_web/hooks/use-inbox.ts b/surfsense_web/hooks/use-inbox.ts new file mode 100644 index 000000000..4c26ddcb9 --- /dev/null +++ b/surfsense_web/hooks/use-inbox.ts @@ -0,0 +1,523 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types"; +import { notificationsApiService } from "@/lib/apis/notifications-api.service"; +import type { SyncHandle } from "@/lib/electric/client"; +import { useElectricClient } from "@/lib/electric/context"; + +export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types"; + +const PAGE_SIZE = 50; +const SYNC_WINDOW_DAYS = 14; + +/** + * Check if an item is older than the sync window + */ +function isOlderThanSyncWindow(createdAt: string): boolean { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - SYNC_WINDOW_DAYS); + return new Date(createdAt) < cutoffDate; +} + +/** + * Deduplicate by ID and sort by created_at descending. + * This is the SINGLE source of truth for deduplication - prevents race conditions. + */ +function deduplicateAndSort(items: InboxItem[]): InboxItem[] { + const seen = new Map(); + for (const item of items) { + if (!seen.has(item.id)) { + seen.set(item.id, item); + } + } + return Array.from(seen.values()).sort( + (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); +} + +/** + * Calculate the cutoff date for sync window + */ +function getSyncCutoffDate(): string { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - SYNC_WINDOW_DAYS); + return cutoff.toISOString(); +} + +/** + * Convert a date value to ISO string format + */ +function toISOString(date: string | Date | null | undefined): string | null { + if (!date) return null; + if (date instanceof Date) return date.toISOString(); + if (typeof date === "string") { + if (date.includes("T")) return date; + try { + return new Date(date).toISOString(); + } catch { + return date; + } + } + return null; +} + +/** + * Hook for managing inbox items with Electric SQL real-time sync + API fallback + * + * Architecture (Simplified & Race-Condition Free): + * - Electric SQL: Syncs recent items (within SYNC_WINDOW_DAYS) for real-time updates + * - Live Query: Provides reactive first page from PGLite + * - API: Handles all pagination (more reliable than mixing with Electric) + * + * Key Design Decisions: + * 1. No mutable refs for cursor - cursor computed from current state + * 2. Single deduplicateAndSort function - prevents inconsistencies + * 3. Filter-based preservation in live query - prevents data loss + * 4. Auto-fetch from API when Electric returns 0 items + * + * @param userId - The user ID to fetch inbox items for + * @param searchSpaceId - The search space ID to filter inbox items + * @param typeFilter - Optional inbox item type to filter by + */ +export function useInbox( + userId: string | null, + searchSpaceId: number | null, + typeFilter: InboxItemTypeEnum | null = null +) { + const electricClient = useElectricClient(); + + const [inboxItems, setInboxItems] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [error, setError] = useState(null); + + // Split unread count tracking for accurate counts with 14-day sync window + // olderUnreadCount = unread items OLDER than sync window (from server, static until reconciliation) + // recentUnreadCount = unread items within sync window (from live query, real-time) + const [olderUnreadCount, setOlderUnreadCount] = useState(0); + const [recentUnreadCount, setRecentUnreadCount] = useState(0); + + const syncHandleRef = useRef(null); + const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); + const userSyncKeyRef = useRef(null); + const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); + + // Total unread = older (static from server) + recent (live from Electric) + const totalUnreadCount = olderUnreadCount + recentUnreadCount; + + // EFFECT 1: Electric SQL sync for real-time updates + useEffect(() => { + if (!userId || !electricClient) { + setLoading(!electricClient); + return; + } + + const client = electricClient; + let mounted = true; + + async function startSync() { + try { + const cutoffDate = getSyncCutoffDate(); + const userSyncKey = `inbox_${userId}_${cutoffDate}`; + + // Skip if already syncing with this key + if (userSyncKeyRef.current === userSyncKey) return; + + // Clean up previous sync + if (syncHandleRef.current) { + syncHandleRef.current.unsubscribe(); + syncHandleRef.current = null; + } + + console.log("[useInbox] Starting sync for:", userId); + userSyncKeyRef.current = userSyncKey; + + const handle = await client.syncShape({ + table: "notifications", + where: `user_id = '${userId}' AND created_at > '${cutoffDate}'`, + primaryKey: ["id"], + }); + + // Wait for initial sync with timeout + if (!handle.isUpToDate && handle.initialSyncPromise) { + await Promise.race([ + handle.initialSyncPromise, + new Promise((resolve) => setTimeout(resolve, 3000)), + ]); + } + + if (!mounted) { + handle.unsubscribe(); + return; + } + + syncHandleRef.current = handle; + setLoading(false); + setError(null); + } catch (err) { + if (!mounted) return; + console.error("[useInbox] Sync failed:", err); + setError(err instanceof Error ? err : new Error("Sync failed")); + setLoading(false); + } + } + + startSync(); + + return () => { + mounted = false; + userSyncKeyRef.current = null; + if (syncHandleRef.current) { + syncHandleRef.current.unsubscribe(); + syncHandleRef.current = null; + } + }; + }, [userId, electricClient]); + + // Reset when filters change + useEffect(() => { + setHasMore(true); + setInboxItems([]); + // Reset count states - will be refetched by the unread count effect + setOlderUnreadCount(0); + setRecentUnreadCount(0); + }, [userId, searchSpaceId, typeFilter]); + + // EFFECT 2: Live query for real-time updates + auto-fetch from API if empty + useEffect(() => { + if (!userId || !electricClient) return; + + const client = electricClient; + let mounted = true; + + async function setupLiveQuery() { + // Clean up previous live query + if (liveQueryRef.current) { + liveQueryRef.current.unsubscribe(); + liveQueryRef.current = null; + } + + try { + const cutoff = getSyncCutoffDate(); + + const query = `SELECT * FROM notifications + WHERE user_id = $1 + AND (search_space_id = $2 OR search_space_id IS NULL) + AND created_at > '${cutoff}' + ${typeFilter ? "AND type = $3" : ""} + ORDER BY created_at DESC + LIMIT ${PAGE_SIZE}`; + + const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId]; + + const db = client.db as any; + + // Initial fetch from PGLite - no validation needed, schema is enforced by Electric SQL sync + const result = await client.db.query(query, params); + + if (mounted && result.rows) { + const items = deduplicateAndSort(result.rows); + setInboxItems(items); + + // AUTO-FETCH: If Electric returned 0 items, check API for older items + // This handles the edge case where user has no recent notifications + // but has older ones outside the sync window + if (items.length === 0) { + console.log( + "[useInbox] Electric returned 0 items, checking API for older notifications" + ); + try { + // Use the API service with proper Zod validation for API responses + const data = await notificationsApiService.getNotifications({ + queryParams: { + search_space_id: searchSpaceId ?? undefined, + type: typeFilter ?? undefined, + limit: PAGE_SIZE, + }, + }); + + if (mounted) { + if (data.items.length > 0) { + setInboxItems(data.items); + } + setHasMore(data.has_more); + } + } catch (err) { + console.error("[useInbox] API fallback failed:", err); + } + } + } + + // Set up live query for real-time updates + if (db.live?.query) { + const liveQuery = await db.live.query(query, params); + + if (!mounted) { + liveQuery.unsubscribe?.(); + return; + } + + if (liveQuery.subscribe) { + // Live query data comes from PGlite - no validation needed + liveQuery.subscribe((result: { rows: InboxItem[] }) => { + if (mounted && result.rows) { + const liveItems = result.rows; + + setInboxItems((prev) => { + const liveItemIds = new Set(liveItems.map((item) => item.id)); + + // FIXED: Keep ALL items not in live result (not just slice) + // This prevents data loss when new notifications push items + // out of the LIMIT window + const itemsToKeep = prev.filter((item) => !liveItemIds.has(item.id)); + + return deduplicateAndSort([...liveItems, ...itemsToKeep]); + }); + } + }); + } + + if (liveQuery.unsubscribe) { + liveQueryRef.current = liveQuery; + } + } + } catch (err) { + console.error("[useInbox] Live query error:", err); + } + } + + setupLiveQuery(); + + return () => { + mounted = false; + if (liveQueryRef.current) { + liveQueryRef.current.unsubscribe(); + liveQueryRef.current = null; + } + }; + }, [userId, searchSpaceId, typeFilter, electricClient]); + + // EFFECT 3: Dedicated unread count sync with split tracking + // - Fetches server count on mount (accurate total) + // - Sets up live query for recent count (real-time updates) + // - Handles items older than sync window separately + useEffect(() => { + if (!userId || !electricClient) return; + + const client = electricClient; + let mounted = true; + + async function setupUnreadCountSync() { + // Cleanup previous live query + if (unreadCountLiveQueryRef.current) { + unreadCountLiveQueryRef.current.unsubscribe(); + unreadCountLiveQueryRef.current = null; + } + + try { + // STEP 1: Fetch server counts (total and recent) - guaranteed accurate + console.log("[useInbox] Fetching unread count from server"); + const serverCounts = await notificationsApiService.getUnreadCount( + searchSpaceId ?? undefined + ); + + if (mounted) { + // Calculate older count = total - recent + const olderCount = serverCounts.total_unread - serverCounts.recent_unread; + setOlderUnreadCount(olderCount); + setRecentUnreadCount(serverCounts.recent_unread); + console.log( + `[useInbox] Server counts: total=${serverCounts.total_unread}, recent=${serverCounts.recent_unread}, older=${olderCount}` + ); + } + + // STEP 2: Set up PGLite live query for RECENT unread count only + // This provides real-time updates for notifications within sync window + const db = client.db as any; + const cutoff = getSyncCutoffDate(); + + // Count query - NO LIMIT, counts all unread in synced window + const countQuery = ` + SELECT COUNT(*) as count FROM notifications + WHERE user_id = $1 + AND (search_space_id = $2 OR search_space_id IS NULL) + AND created_at > '${cutoff}' + AND read = false + ${typeFilter ? "AND type = $3" : ""} + `; + const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId]; + + if (db.live?.query) { + const liveQuery = await db.live.query(countQuery, params); + + if (!mounted) { + liveQuery.unsubscribe?.(); + return; + } + + if (liveQuery.subscribe) { + liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => { + if (mounted && result.rows?.[0]) { + const liveCount = Number(result.rows[0].count) || 0; + // Update recent count from live query + // This fires in real-time when Electric syncs new/updated notifications + setRecentUnreadCount(liveCount); + } + }); + } + + if (liveQuery.unsubscribe) { + unreadCountLiveQueryRef.current = liveQuery; + } + } + } catch (err) { + console.error("[useInbox] Unread count sync error:", err); + // On error, counts will remain at 0 or previous values + // The items-based count will be the fallback + } + } + + setupUnreadCountSync(); + + return () => { + mounted = false; + if (unreadCountLiveQueryRef.current) { + unreadCountLiveQueryRef.current.unsubscribe(); + unreadCountLiveQueryRef.current = null; + } + }; + }, [userId, searchSpaceId, typeFilter, electricClient]); + + // loadMore - Pure cursor-based pagination, no race conditions + // Cursor is computed from current state, not stored in refs + const loadMore = useCallback(async () => { + // Removed inboxItems.length === 0 check to allow loading older items + // when Electric returns 0 items + if (!userId || loadingMore || !hasMore) return; + + setLoadingMore(true); + + try { + // Cursor is computed from current state - no stale refs possible + const oldestItem = inboxItems.length > 0 ? inboxItems[inboxItems.length - 1] : null; + const beforeDate = oldestItem ? toISOString(oldestItem.created_at) : null; + + console.log("[useInbox] Loading more, before:", beforeDate ?? "none (initial)"); + + // Use the API service with proper Zod validation + const data = await notificationsApiService.getNotifications({ + queryParams: { + search_space_id: searchSpaceId ?? undefined, + type: typeFilter ?? undefined, + before_date: beforeDate ?? undefined, + limit: PAGE_SIZE, + }, + }); + + if (data.items.length > 0) { + // Functional update ensures we always merge with latest state + // Items are already validated by the API service + setInboxItems((prev) => deduplicateAndSort([...prev, ...data.items])); + } + + // Use API's has_more flag + setHasMore(data.has_more); + } catch (err) { + console.error("[useInbox] Load more failed:", err); + } finally { + setLoadingMore(false); + } + }, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]); + + // Mark inbox item as read with optimistic update + // Handles both recent items (live query updates count) and older items (manual count decrement) + const markAsRead = useCallback( + async (itemId: number) => { + // Find the item to check if it's older than sync window + const item = inboxItems.find((i) => i.id === itemId); + const isOlderItem = item && !item.read && isOlderThanSyncWindow(item.created_at); + + // Optimistic update: mark as read immediately for instant UI feedback + setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: true } : i))); + + // If older item, manually decrement older count + // (live query won't see items outside sync window) + if (isOlderItem) { + setOlderUnreadCount((prev) => Math.max(0, prev - 1)); + } + + try { + // Use the API service with proper Zod validation + const result = await notificationsApiService.markAsRead({ notificationId: itemId }); + + if (!result.success) { + // Rollback on error + setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i))); + if (isOlderItem) { + setOlderUnreadCount((prev) => prev + 1); + } + } + // If successful, Electric SQL will sync the change and live query will update + // This ensures eventual consistency even if optimistic update was wrong + return result.success; + } catch (err) { + console.error("Failed to mark as read:", err); + // Rollback on error + setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i))); + if (isOlderItem) { + setOlderUnreadCount((prev) => prev + 1); + } + return false; + } + }, + [inboxItems] + ); + + // Mark all inbox items as read with optimistic update + // Resets both older and recent counts to 0 + const markAllAsRead = useCallback(async () => { + // Store previous counts for potential rollback + const prevOlderCount = olderUnreadCount; + const prevRecentCount = recentUnreadCount; + + // Optimistic update: mark all as read immediately for instant UI feedback + setInboxItems((prev) => prev.map((item) => ({ ...item, read: true }))); + setOlderUnreadCount(0); + setRecentUnreadCount(0); + + try { + // Use the API service with proper Zod validation + const result = await notificationsApiService.markAllAsRead(); + + if (!result.success) { + console.error("Failed to mark all as read"); + // Rollback counts on error + setOlderUnreadCount(prevOlderCount); + setRecentUnreadCount(prevRecentCount); + } + // Electric SQL will sync and live query will ensure consistency + return result.success; + } catch (err) { + console.error("Failed to mark all as read:", err); + // Rollback counts on error + setOlderUnreadCount(prevOlderCount); + setRecentUnreadCount(prevRecentCount); + return false; + } + }, [olderUnreadCount, recentUnreadCount]); + + return { + inboxItems, + unreadCount: totalUnreadCount, + markAsRead, + markAllAsRead, + loading, + loadingMore, + hasMore, + loadMore, + isUsingApiFallback: true, // Always use API for pagination + error, + }; +} diff --git a/surfsense_web/hooks/use-notifications.ts b/surfsense_web/hooks/use-notifications.ts deleted file mode 100644 index eca00a935..000000000 --- a/surfsense_web/hooks/use-notifications.ts +++ /dev/null @@ -1,334 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types"; -import { authenticatedFetch } from "@/lib/auth-utils"; -import type { SyncHandle } from "@/lib/electric/client"; -import { useElectricClient } from "@/lib/electric/context"; - -export type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types"; - -/** - * Hook for managing notifications with Electric SQL real-time sync - * - * Uses the Electric client from context (provided by ElectricProvider) - * instead of initializing its own - prevents race conditions and memory leaks - * - * Architecture: - * - User-level sync: Syncs ALL notifications for a user (runs once per user) - * - Search-space-level query: Filters notifications by searchSpaceId (updates on search space change) - * - * This separation ensures smooth transitions when switching search spaces (no flash). - * - * @param userId - The user ID to fetch notifications for - * @param searchSpaceId - The search space ID to filter notifications (null shows global notifications only) - * @param typeFilter - Optional notification type to filter by (null shows all types) - */ -export function useNotifications( - userId: string | null, - searchSpaceId: number | null, - typeFilter: NotificationTypeEnum | null = null -) { - // Get Electric client from context - ElectricProvider handles initialization - const electricClient = useElectricClient(); - - const [notifications, setNotifications] = useState([]); - const [totalUnreadCount, setTotalUnreadCount] = useState(0); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const syncHandleRef = useRef(null); - const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); - const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); - - // Track user-level sync key to prevent duplicate sync subscriptions - const userSyncKeyRef = useRef(null); - - // EFFECT 1: User-level sync - runs once per user, syncs ALL notifications - useEffect(() => { - if (!userId || !electricClient) { - setLoading(!electricClient); - return; - } - - const userSyncKey = `notifications_${userId}`; - if (userSyncKeyRef.current === userSyncKey) { - // Already syncing for this user - return; - } - - let mounted = true; - userSyncKeyRef.current = userSyncKey; - - async function startUserSync() { - try { - console.log("[useNotifications] Starting user-level sync for:", userId); - - // Sync ALL notifications for this user (cached via syncShape caching) - const handle = await electricClient.syncShape({ - table: "notifications", - where: `user_id = '${userId}'`, - primaryKey: ["id"], - }); - - console.log("[useNotifications] User sync started:", { - isUpToDate: handle.isUpToDate, - }); - - // Wait for initial sync with timeout - if (!handle.isUpToDate && handle.initialSyncPromise) { - try { - await Promise.race([ - handle.initialSyncPromise, - new Promise((resolve) => setTimeout(resolve, 2000)), - ]); - } catch (syncErr) { - console.error("[useNotifications] Initial sync failed:", syncErr); - } - } - - if (!mounted) { - handle.unsubscribe(); - return; - } - - syncHandleRef.current = handle; - setLoading(false); - setError(null); - } catch (err) { - if (!mounted) return; - console.error("[useNotifications] Failed to start user sync:", err); - setError(err instanceof Error ? err : new Error("Failed to sync notifications")); - setLoading(false); - } - } - - startUserSync(); - - return () => { - mounted = false; - userSyncKeyRef.current = null; - - if (syncHandleRef.current) { - syncHandleRef.current.unsubscribe(); - syncHandleRef.current = null; - } - }; - }, [userId, electricClient]); - - // EFFECT 2: Search-space-level query - updates when searchSpaceId or typeFilter changes - // This runs independently of sync, allowing smooth transitions between search spaces - useEffect(() => { - if (!userId || !electricClient) { - return; - } - - let mounted = true; - - async function updateQuery() { - // Clean up previous live query (but DON'T clear notifications - keep showing old until new arrive) - if (liveQueryRef.current) { - liveQueryRef.current.unsubscribe(); - liveQueryRef.current = null; - } - - try { - console.log( - "[useNotifications] Updating query for searchSpace:", - searchSpaceId, - "typeFilter:", - typeFilter - ); - - // Build query with optional type filter - const baseQuery = `SELECT * FROM notifications - WHERE user_id = $1 - AND (search_space_id = $2 OR search_space_id IS NULL)`; - const typeClause = typeFilter ? ` AND type = $3` : ""; - const orderClause = ` ORDER BY created_at DESC`; - const fullQuery = baseQuery + typeClause + orderClause; - const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId]; - - // Fetch notifications for current search space immediately - const result = await electricClient.db.query(fullQuery, params); - - if (mounted) { - setNotifications(result.rows || []); - } - - // Set up live query for real-time updates - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const db = electricClient.db as any; - - if (db.live?.query && typeof db.live.query === "function") { - const liveQuery = await db.live.query(fullQuery, params); - - if (!mounted) { - liveQuery.unsubscribe?.(); - return; - } - - // Set initial results from live query - if (liveQuery.initialResults?.rows) { - setNotifications(liveQuery.initialResults.rows); - } else if (liveQuery.rows) { - setNotifications(liveQuery.rows); - } - - // Subscribe to changes - if (typeof liveQuery.subscribe === "function") { - liveQuery.subscribe((result: { rows: Notification[] }) => { - if (mounted && result.rows) { - setNotifications(result.rows); - } - }); - } - - if (typeof liveQuery.unsubscribe === "function") { - liveQueryRef.current = liveQuery; - } - } - } catch (err) { - console.error("[useNotifications] Failed to update query:", err); - } - } - - updateQuery(); - - return () => { - mounted = false; - if (liveQueryRef.current) { - liveQueryRef.current.unsubscribe(); - liveQueryRef.current = null; - } - }; - }, [userId, searchSpaceId, typeFilter, electricClient]); - - // EFFECT 3: Total unread count - independent of type filter - // This ensures the badge count stays consistent regardless of active filter - useEffect(() => { - if (!userId || !electricClient) { - return; - } - - let mounted = true; - - async function updateUnreadCount() { - // Clean up previous live query - if (unreadCountLiveQueryRef.current) { - unreadCountLiveQueryRef.current.unsubscribe(); - unreadCountLiveQueryRef.current = null; - } - - try { - const countQuery = `SELECT COUNT(*) as count FROM notifications - WHERE user_id = $1 - AND (search_space_id = $2 OR search_space_id IS NULL) - AND read = false`; - - // Fetch initial count - const result = await electricClient.db.query<{ count: number }>(countQuery, [ - userId, - searchSpaceId, - ]); - - if (mounted && result.rows?.[0]) { - setTotalUnreadCount(Number(result.rows[0].count) || 0); - } - - // Set up live query for real-time updates - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const db = electricClient.db as any; - - if (db.live?.query && typeof db.live.query === "function") { - const liveQuery = await db.live.query(countQuery, [userId, searchSpaceId]); - - if (!mounted) { - liveQuery.unsubscribe?.(); - return; - } - - // Set initial results from live query - if (liveQuery.initialResults?.rows?.[0]) { - setTotalUnreadCount(Number(liveQuery.initialResults.rows[0].count) || 0); - } else if (liveQuery.rows?.[0]) { - setTotalUnreadCount(Number(liveQuery.rows[0].count) || 0); - } - - // Subscribe to changes - if (typeof liveQuery.subscribe === "function") { - liveQuery.subscribe((result: { rows: { count: number }[] }) => { - if (mounted && result.rows?.[0]) { - setTotalUnreadCount(Number(result.rows[0].count) || 0); - } - }); - } - - if (typeof liveQuery.unsubscribe === "function") { - unreadCountLiveQueryRef.current = liveQuery; - } - } - } catch (err) { - console.error("[useNotifications] Failed to update unread count:", err); - } - } - - updateUnreadCount(); - - return () => { - mounted = false; - if (unreadCountLiveQueryRef.current) { - unreadCountLiveQueryRef.current.unsubscribe(); - unreadCountLiveQueryRef.current = null; - } - }; - }, [userId, searchSpaceId, electricClient]); - - // Mark notification as read via backend API - const markAsRead = useCallback(async (notificationId: number) => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${notificationId}/read`, - { method: "PATCH" } - ); - - if (!response.ok) { - const error = await response.json().catch(() => ({ detail: "Failed to mark as read" })); - throw new Error(error.detail || "Failed to mark notification as read"); - } - - return true; - } catch (err) { - console.error("Failed to mark notification as read:", err); - return false; - } - }, []); - - // Mark all notifications as read via backend API - const markAllAsRead = useCallback(async () => { - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`, - { method: "PATCH" } - ); - - if (!response.ok) { - const error = await response.json().catch(() => ({ detail: "Failed to mark all as read" })); - throw new Error(error.detail || "Failed to mark all notifications as read"); - } - - return true; - } catch (err) { - console.error("Failed to mark all notifications as read:", err); - return false; - } - }, []); - - return { - notifications, - unreadCount: totalUnreadCount, - markAsRead, - markAllAsRead, - loading, - error, - }; -} diff --git a/surfsense_web/lib/apis/notifications-api.service.ts b/surfsense_web/lib/apis/notifications-api.service.ts new file mode 100644 index 000000000..927aee747 --- /dev/null +++ b/surfsense_web/lib/apis/notifications-api.service.ts @@ -0,0 +1,110 @@ +import { + type GetNotificationsRequest, + type GetNotificationsResponse, + type GetUnreadCountResponse, + type MarkAllNotificationsReadResponse, + type MarkNotificationReadRequest, + type MarkNotificationReadResponse, + getNotificationsRequest, + getNotificationsResponse, + getUnreadCountResponse, + markAllNotificationsReadResponse, + markNotificationReadRequest, + markNotificationReadResponse, +} from "@/contracts/types/inbox.types"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +class NotificationsApiService { + /** + * Get notifications with pagination + */ + getNotifications = async ( + request: GetNotificationsRequest + ): Promise => { + const parsedRequest = getNotificationsRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + const { queryParams } = parsedRequest.data; + + // Build query string from params + const params = new URLSearchParams(); + + if (queryParams.search_space_id !== undefined) { + params.append("search_space_id", String(queryParams.search_space_id)); + } + if (queryParams.type) { + params.append("type", queryParams.type); + } + if (queryParams.before_date) { + params.append("before_date", queryParams.before_date); + } + if (queryParams.limit !== undefined) { + params.append("limit", String(queryParams.limit)); + } + if (queryParams.offset !== undefined) { + params.append("offset", String(queryParams.offset)); + } + + const queryString = params.toString(); + + return baseApiService.get( + `/api/v1/notifications${queryString ? `?${queryString}` : ""}`, + getNotificationsResponse + ); + }; + + /** + * Mark a single notification as read + */ + markAsRead = async ( + request: MarkNotificationReadRequest + ): Promise => { + const parsedRequest = markNotificationReadRequest.safeParse(request); + + if (!parsedRequest.success) { + console.error("Invalid request:", parsedRequest.error); + const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + const { notificationId } = parsedRequest.data; + + return baseApiService.patch( + `/api/v1/notifications/${notificationId}/read`, + markNotificationReadResponse + ); + }; + + /** + * Mark all notifications as read + */ + markAllAsRead = async (): Promise => { + return baseApiService.patch("/api/v1/notifications/read-all", markAllNotificationsReadResponse); + }; + + /** + * Get unread notification count with split between total and recent + * - total_unread: All unread notifications + * - recent_unread: Unread within sync window (last 14 days) + */ + getUnreadCount = async (searchSpaceId?: number): Promise => { + const params = new URLSearchParams(); + if (searchSpaceId !== undefined) { + params.append("search_space_id", String(searchSpaceId)); + } + const queryString = params.toString(); + + return baseApiService.get( + `/api/v1/notifications/unread-count${queryString ? `?${queryString}` : ""}`, + getUnreadCountResponse + ); + }; +} + +export const notificationsApiService = new NotificationsApiService(); diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts index 514185d23..6a9d87b88 100644 --- a/surfsense_web/lib/electric/client.ts +++ b/surfsense_web/lib/electric/client.ts @@ -53,8 +53,9 @@ const activeSyncHandles = new Map(); const pendingSyncs = new Map>(); // Version for sync state - increment this to force fresh sync when Electric config changes -// Set to v2 for user-specific database architecture -const SYNC_VERSION = 2; +// v2: user-specific database architecture +// v3: consistent cutoff date for sync+queries, visibility refresh support +const SYNC_VERSION = 3; // Database name prefix for identifying SurfSense databases const DB_PREFIX = "surfsense-"; diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index cda522b61..93fc8c1b4 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -692,7 +692,23 @@ "light": "Light", "dark": "Dark", "system": "System", - "logout": "Logout" + "logout": "Logout", + "inbox": "Inbox", + "search_inbox": "Search inbox", + "mark_all_read": "Mark all as read", + "mark_as_read": "Mark as read", + "mentions": "Mentions", + "status": "Status", + "no_results_found": "No results found", + "no_mentions": "No mentions", + "no_mentions_hint": "You'll see mentions from others here", + "no_status_updates": "No status updates", + "no_status_updates_hint": "Document and connector updates will appear here", + "filter": "Filter", + "all": "All", + "unread": "Unread", + "connectors": "Connectors", + "all_connectors": "All connectors" }, "errors": { "something_went_wrong": "Something went wrong", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 7f2f49cfc..47d06cb20 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -677,7 +677,23 @@ "light": "浅色", "dark": "深色", "system": "系统", - "logout": "退出登录" + "logout": "退出登录", + "inbox": "收件箱", + "search_inbox": "搜索收件箱...", + "mark_all_read": "全部标记为已读", + "mark_as_read": "标记为已读", + "mentions": "提及", + "status": "状态", + "no_results_found": "未找到结果", + "no_mentions": "没有提及", + "no_mentions_hint": "您会在这里看到他人的提及", + "no_status_updates": "没有状态更新", + "no_status_updates_hint": "文档和连接器更新将显示在这里", + "filter": "筛选", + "all": "全部", + "unread": "未读", + "connectors": "连接器", + "all_connectors": "所有连接器" }, "errors": { "something_went_wrong": "出错了", diff --git a/surfsense_web/package.json b/surfsense_web/package.json index b8e628b33..3c035c13d 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -105,6 +105,7 @@ "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "unist-util-visit": "^5.0.0", + "vaul": "^1.1.2", "zod": "^4.2.1", "zustand": "^5.0.9" }, diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml index 5e910d87a..f7928abea 100644 --- a/surfsense_web/pnpm-lock.yaml +++ b/surfsense_web/pnpm-lock.yaml @@ -260,6 +260,9 @@ importers: unist-util-visit: specifier: ^5.0.0 version: 5.0.0 + vaul: + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) zod: specifier: ^4.2.1 version: 4.2.1 @@ -6384,6 +6387,12 @@ packages: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -13451,6 +13460,15 @@ snapshots: uuid@8.3.2: {} + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3