feat: implement inbox sidebar for enhanced user notifications

- Introduced a new InboxSidebar component to manage and display inbox items.
- Integrated real-time syncing of inbox items using Electric SQL for instant updates.
- Added functionality to mark items as read, archive/unarchive, and filter inbox content.
- Updated existing components to accommodate the new inbox feature, including layout adjustments and state management.
- Enhanced user experience with improved navigation and interaction for inbox items.
This commit is contained in:
Anish Sarkar 2026-01-21 19:43:20 +05:30
parent 8eec948434
commit 93aa1dcf3c
13 changed files with 860 additions and 441 deletions

View file

@ -367,7 +367,7 @@ export default function NewChatPage() {
initializeThread(); initializeThread();
}, [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 searchParams = useSearchParams();
const targetCommentId = searchParams.get("commentId"); const targetCommentId = searchParams.get("commentId");

View file

@ -2,7 +2,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react"; import { Inbox, LogOut, Logs, SquareLibrary, Trash2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation"; import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
@ -19,6 +19,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useInbox } from "@/hooks/use-inbox";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence"; import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
import { cleanupElectric } from "@/lib/electric/client"; import { cleanupElectric } from "@/lib/electric/client";
@ -29,6 +30,7 @@ import { CreateSearchSpaceDialog } from "../ui/dialogs";
import { LayoutShell } from "../ui/shell"; import { LayoutShell } from "../ui/shell";
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar"; import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar"; import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
import { InboxSidebar } from "../ui/sidebar/InboxSidebar";
interface LayoutDataProviderProps { interface LayoutDataProviderProps {
searchSpaceId: string; searchSpaceId: string;
@ -59,8 +61,8 @@ export function LayoutDataProvider({
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id) ? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
: null; : null;
// Fetch current search space // Fetch current search space (for caching purposes)
const { data: searchSpace } = useQuery({ useQuery({
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId), queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }), queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
enabled: !!searchSpaceId, enabled: !!searchSpaceId,
@ -77,9 +79,20 @@ export function LayoutDataProvider({
const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false); const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false);
const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false); const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false);
// Inbox sidebar state
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
// Search space dialog state // Search space dialog state
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
// Inbox hook
const userId = user?.id ? String(user.id) : null;
const { inboxItems, unreadCount, loading: inboxLoading, markAsRead, markAllAsRead, archiveItem } = useInbox(
userId,
Number(searchSpaceId) || null,
null
);
// Delete dialogs state // Delete dialogs state
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false); const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
@ -149,14 +162,21 @@ export function LayoutDataProvider({
icon: SquareLibrary, icon: SquareLibrary,
isActive: pathname?.includes("/documents"), isActive: pathname?.includes("/documents"),
}, },
// {
// title: "Logs",
// url: `/dashboard/${searchSpaceId}/logs`,
// icon: Logs,
// isActive: pathname?.includes("/logs"),
// },
{ {
title: "Logs", title: "Inbox",
url: `/dashboard/${searchSpaceId}/logs`, url: "#inbox", // Special URL to indicate this is handled differently
icon: Logs, icon: Inbox,
isActive: pathname?.includes("/logs"), isActive: isInboxSidebarOpen,
badge: unreadCount > 0 ? (unreadCount > 99 ? "99+" : unreadCount) : undefined,
}, },
], ],
[searchSpaceId, pathname] [searchSpaceId, pathname, isInboxSidebarOpen, unreadCount]
); );
// Handlers // Handlers
@ -248,6 +268,11 @@ export function LayoutDataProvider({
const handleNavItemClick = useCallback( const handleNavItemClick = useCallback(
(item: NavItem) => { (item: NavItem) => {
// Handle inbox specially - open sidebar instead of navigating
if (item.url === "#inbox") {
setIsInboxSidebarOpen(true);
return;
}
router.push(item.url); router.push(item.url);
}, },
[router] [router]
@ -517,6 +542,18 @@ export function LayoutDataProvider({
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
/> />
{/* Inbox Sidebar */}
<InboxSidebar
open={isInboxSidebarOpen}
onOpenChange={setIsInboxSidebarOpen}
inboxItems={inboxItems}
unreadCount={unreadCount}
loading={inboxLoading}
markAsRead={markAsRead}
markAllAsRead={markAllAsRead}
archiveItem={archiveItem}
/>
{/* Create Search Space Dialog */} {/* Create Search Space Dialog */}
<CreateSearchSpaceDialog <CreateSearchSpaceDialog
open={isCreateSearchSpaceDialogOpen} open={isCreateSearchSpaceDialogOpen}

View file

@ -4,7 +4,6 @@ import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { ChatShareButton } from "@/components/new-chat/chat-share-button"; import { ChatShareButton } from "@/components/new-chat/chat-share-button";
import { NotificationButton } from "@/components/notifications/NotificationButton";
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence"; import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
interface HeaderProps { interface HeaderProps {
@ -55,8 +54,6 @@ export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
{/* Right side - Actions */} {/* Right side - Actions */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Notifications */}
<NotificationButton />
{/* Share button - only show on chat pages when thread exists */} {/* Share button - only show on chat pages when thread exists */}
{hasThread && ( {hasThread && (
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} /> <ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />

View file

@ -0,0 +1,662 @@
"use client";
import {
AlertCircle,
Archive,
AtSign,
BellDot,
Check,
CheckCheck,
CheckCircle2,
History,
Inbox,
ListFilter,
Loader2,
MoreHorizontal,
RotateCcw,
Search,
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { InboxItem } from "@/hooks/use-inbox";
import { cn } from "@/lib/utils";
/**
* Get initials from name or email for avatar fallback
*/
function getInitials(name: string | null | undefined, email: string | null | undefined): string {
if (name) {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
}
if (email) {
const localPart = email.split("@")[0];
return localPart.slice(0, 2).toUpperCase();
}
return "U";
}
type InboxTab = "mentions" | "status";
type InboxFilter = "all" | "unread" | "archived";
interface InboxSidebarProps {
open: boolean;
onOpenChange: (open: boolean) => void;
inboxItems: InboxItem[];
unreadCount: number;
loading: boolean;
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
archiveItem: (id: number, archived: boolean) => Promise<boolean>;
onCloseMobileSidebar?: () => void;
}
export function InboxSidebar({
open,
onOpenChange,
inboxItems,
unreadCount,
loading,
markAsRead,
markAllAsRead,
archiveItem,
onCloseMobileSidebar,
}: InboxSidebarProps) {
const t = useTranslations("sidebar");
const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState<InboxTab>("mentions");
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
const [mounted, setMounted] = useState(false);
// Unified dropdown state: "filter" | "options" | number (item id) | null
const [openDropdown, setOpenDropdown] = useState<"filter" | "options" | number | null>(null);
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
const [archivingItemId, setArchivingItemId] = useState<number | null>(null);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) {
onOpenChange(false);
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]);
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
// Split items by type
const mentionItems = useMemo(
() => inboxItems.filter((item) => item.type === "new_mention"),
[inboxItems]
);
const statusItems = useMemo(
() =>
inboxItems.filter(
(item) => item.type === "connector_indexing" || item.type === "document_processing"
),
[inboxItems]
);
// Get items for current tab
const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems;
// Filter items based on filter type and search query
const filteredItems = useMemo(() => {
let items = currentTabItems;
// Apply filter
if (activeFilter === "all") {
// "Unread & read" shows all non-archived items
items = items.filter((item) => !(item as InboxItem & { archived?: boolean }).archived);
} else if (activeFilter === "unread") {
// "Unread" shows only unread non-archived items
items = items.filter((item) => !item.read && !(item as InboxItem & { archived?: boolean }).archived);
} else if (activeFilter === "archived") {
// "Archived" shows only archived items
items = items.filter((item) => (item as InboxItem & { archived?: boolean }).archived);
}
// Apply search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
items = items.filter(
(item) =>
item.title.toLowerCase().includes(query) ||
item.message.toLowerCase().includes(query)
);
}
return items;
}, [currentTabItems, activeFilter, searchQuery]);
// Count unread items per tab
const unreadMentionsCount = useMemo(
() => mentionItems.filter((item) => !item.read).length,
[mentionItems]
);
const unreadStatusCount = useMemo(
() => statusItems.filter((item) => !item.read).length,
[statusItems]
);
const handleItemClick = useCallback(
async (item: InboxItem) => {
if (!item.read) {
setMarkingAsReadId(item.id);
await markAsRead(item.id);
setMarkingAsReadId(null);
}
if (item.type === "new_mention") {
const metadata = item.metadata as {
thread_id?: number;
comment_id?: number;
};
const searchSpaceId = item.search_space_id;
const threadId = metadata?.thread_id;
const commentId = metadata?.comment_id;
if (searchSpaceId && threadId) {
const url = commentId
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
onOpenChange(false);
onCloseMobileSidebar?.();
router.push(url);
}
}
},
[markAsRead, router, onOpenChange, onCloseMobileSidebar]
);
const handleMarkAsRead = useCallback(
async (itemId: number) => {
setMarkingAsReadId(itemId);
await markAsRead(itemId);
setMarkingAsReadId(null);
},
[markAsRead]
);
const handleMarkAllAsRead = useCallback(async () => {
await markAllAsRead();
}, [markAllAsRead]);
const handleToggleArchive = useCallback(
async (itemId: number, currentlyArchived: boolean) => {
setArchivingItemId(itemId);
await archiveItem(itemId, !currentlyArchived);
setArchivingItemId(null);
},
[archiveItem]
);
const handleClearSearch = useCallback(() => {
setSearchQuery("");
}, []);
const formatTime = (dateString: string) => {
try {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) return "now";
if (diffMins < 60) return `${diffMins}m`;
if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 7) return `${diffDays}d`;
return `${Math.floor(diffDays / 7)}w`;
} catch {
return "now";
}
};
const getStatusIcon = (item: InboxItem) => {
// For mentions, show the author's avatar with initials fallback
if (item.type === "new_mention") {
const metadata = item.metadata as {
author_name?: string;
author_avatar_url?: string | null;
author_email?: string;
};
const authorName = metadata?.author_name;
const avatarUrl = metadata?.author_avatar_url;
const authorEmail = metadata?.author_email;
return (
<Avatar className="h-8 w-8">
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />}
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(authorName, authorEmail)}
</AvatarFallback>
</Avatar>
);
}
// For status items (connector/document), show status icons
const status = item.metadata?.status as string | undefined;
switch (status) {
case "in_progress":
return (
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-muted">
<Loader2 className="h-4 w-4 text-foreground animate-spin" />
</div>
);
case "completed":
return (
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-green-500/10">
<CheckCircle2 className="h-4 w-4 text-green-500" />
</div>
);
case "failed":
return (
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-red-500/10">
<AlertCircle className="h-4 w-4 text-red-500" />
</div>
);
default:
return (
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-muted">
<History className="h-4 w-4 text-muted-foreground" />
</div>
);
}
};
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(
<AnimatePresence>
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-70 bg-black/50"
onClick={() => onOpenChange(false)}
aria-hidden="true"
/>
<motion.div
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="fixed inset-y-0 left-0 z-70 w-96 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
role="dialog"
aria-modal="true"
aria-label={t("inbox") || "Inbox"}
>
<div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Inbox className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
</div>
<div className="flex items-center gap-1">
<DropdownMenu
open={openDropdown === "filter"}
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
>
<ListFilter className="h-4 w-4" />
<span className="sr-only">{t("filter") || "Filter"}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44 z-80">
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
{t("filter") || "Filter"}
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => setActiveFilter("all")}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
<Inbox className="h-4 w-4" />
<span>{t("unread_and_read") || "Unread & read"}</span>
</span>
{activeFilter === "all" && <Check className="h-4 w-4" />}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setActiveFilter("unread")}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
<BellDot className="h-4 w-4" />
<span>{t("unread") || "Unread"}</span>
</span>
{activeFilter === "unread" && <Check className="h-4 w-4" />}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setActiveFilter("archived")}
className="flex items-center justify-between"
>
<span className="flex items-center gap-2">
<Archive className="h-4 w-4" />
<span>{t("archived") || "Archived"}</span>
</span>
{activeFilter === "archived" && <Check className="h-4 w-4" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu
open={openDropdown === "options"}
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "options" : null)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">{t("more_options") || "More options"}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-80">
<DropdownMenuItem
onClick={handleMarkAllAsRead}
disabled={unreadCount === 0}
>
<CheckCheck className="mr-2 h-4 w-4" />
<span>{t("mark_all_read") || "Mark all as read"}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_inbox") || "Search inbox"}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 h-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
onClick={handleClearSearch}
>
<X className="h-3.5 w-3.5" />
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
</Button>
)}
</div>
</div>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as InboxTab)}
className="shrink-0 mx-4"
>
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
<TabsTrigger
value="mentions"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<AtSign className="h-4 w-4" />
<span>{t("mentions") || "Mentions"}</span>
{unreadMentionsCount > 0 && (
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-primary text-xs font-medium">
{unreadMentionsCount}
</span>
)}
</span>
</TabsTrigger>
<TabsTrigger
value="status"
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
<History className="h-4 w-4" />
<span>{t("status") || "Status"}</span>
{unreadStatusCount > 0 && (
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-primary text-xs font-medium">
{unreadStatusCount}
</span>
)}
</span>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : filteredItems.length > 0 ? (
<div className="space-y-2">
{filteredItems.map((item) => {
const isMarkingAsRead = markingAsReadId === item.id;
const isArchiving = archivingItemId === item.id;
const isBusy = isMarkingAsRead || isArchiving;
const isArchived = (item as InboxItem & { archived?: boolean }).archived;
return (
<div
key={item.id}
className={cn(
"group flex items-center gap-3 rounded-lg px-3 py-5 text-sm",
"hover:bg-accent hover:text-accent-foreground",
"transition-colors cursor-pointer",
isBusy && "opacity-50 pointer-events-none"
)}
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleItemClick(item)}
disabled={isBusy}
className="flex items-start gap-3 flex-1 min-w-0 text-left overflow-hidden self-start"
>
<div className="shrink-0">{getStatusIcon(item)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
<p
className={cn(
"text-xs font-medium line-clamp-2",
!item.read && "font-semibold"
)}
>
{item.title}
</p>
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
{convertRenderedToDisplay(item.message)}
</p>
</div>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-[250px]">
<p className="font-medium">{item.title}</p>
<p className="text-muted-foreground mt-1">
{convertRenderedToDisplay(item.message)}
</p>
</TooltipContent>
</Tooltip>
{/* Time/dot and 3-dot button container - swap on hover */}
<div className="relative flex items-center shrink-0 w-12 justify-end">
{/* Time and unread dot - visible by default, hidden on hover or when dropdown is open */}
<div
className={cn(
"flex items-center gap-1.5 transition-opacity duration-150",
"group-hover:opacity-0 group-hover:pointer-events-none",
openDropdown === item.id && "opacity-0 pointer-events-none"
)}
>
<span className="text-[10px] text-muted-foreground">
{formatTime(item.created_at)}
</span>
{!item.read && (
<span className="h-2 w-2 rounded-full bg-blue-500" />
)}
</div>
{/* 3-dot menu - hidden by default, visible on hover or when dropdown is open */}
<DropdownMenu
open={openDropdown === item.id}
onOpenChange={(isOpen) =>
setOpenDropdown(isOpen ? item.id : null)
}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-6 w-6 absolute right-0 transition-opacity duration-150",
"opacity-0 pointer-events-none",
"group-hover:opacity-100 group-hover:pointer-events-auto",
openDropdown === item.id && "!opacity-100 !pointer-events-auto"
)}
disabled={isBusy}
>
{isBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<MoreHorizontal className="h-3.5 w-3.5" />
)}
<span className="sr-only">
{t("more_options") || "More options"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40 z-80">
{!item.read && (
<>
<DropdownMenuItem
onClick={() => handleMarkAsRead(item.id)}
disabled={isBusy}
>
<CheckCheck className="mr-2 h-4 w-4" />
<span>{t("mark_as_read") || "Mark as read"}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => handleToggleArchive(item.id, !!isArchived)}
disabled={isArchiving}
>
{isArchived ? (
<>
<RotateCcw className="mr-2 h-4 w-4" />
<span>{t("unarchive") || "Restore"}</span>
</>
) : (
<>
<Archive className="mr-2 h-4 w-4" />
<span>{t("archive") || "Archive"}</span>
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
})}
</div>
) : searchQuery ? (
<div className="text-center py-8">
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
{t("no_results_found") || "No results found"}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
{t("try_different_search") || "Try a different search term"}
</p>
</div>
) : (
<div className="text-center py-8">
{activeTab === "mentions" ? (
<AtSign className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
) : (
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
)}
<p className="text-sm text-muted-foreground">
{getEmptyStateMessage().title}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
{getEmptyStateMessage().hint}
</p>
</div>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>,
document.body
);
}

View file

@ -30,7 +30,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
type="button" type="button"
onClick={() => onItemClick?.(item)} onClick={() => onItemClick?.(item)}
className={cn( 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", "hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
item.isActive && "bg-accent text-accent-foreground" item.isActive && "bg-accent text-accent-foreground"
@ -38,6 +38,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
{...joyrideAttr} {...joyrideAttr}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
{item.badge && (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
{item.badge}
</span>
)}
<span className="sr-only">{item.title}</span> <span className="sr-only">{item.title}</span>
</button> </button>
</TooltipTrigger> </TooltipTrigger>
@ -64,7 +69,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
> >
<Icon className="h-4 w-4 shrink-0" /> <Icon className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate">{item.title}</span> <span className="flex-1 truncate">{item.title}</span>
{item.badge && <span className="text-xs text-muted-foreground">{item.badge}</span>} {item.badge && (
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-red-500 text-white text-xs font-medium">
{item.badge}
</span>
)}
</button> </button>
); );
})} })}

View file

@ -1,6 +1,7 @@
export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar"; export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar"; export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
export { ChatListItem } from "./ChatListItem"; export { ChatListItem } from "./ChatListItem";
export { InboxSidebar } from "./InboxSidebar";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar"; export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
export { NavSection } from "./NavSection"; export { NavSection } from "./NavSection";
export { PageUsageDisplay } from "./PageUsageDisplay"; export { PageUsageDisplay } from "./PageUsageDisplay";

View file

@ -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<NotificationTypeEnum | null>(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 (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 relative border-0">
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span
className={cn(
"absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-black text-[10px] font-medium text-white dark:bg-zinc-800 dark:text-zinc-50",
unreadCount > 9 && "px-1"
)}
>
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
<span className="sr-only">Notifications</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Notifications</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-80 p-0">
<NotificationPopup
notifications={notifications}
unreadCount={unreadCount}
loading={loading}
markAsRead={markAsRead}
markAllAsRead={markAllAsRead}
onClose={() => setOpen(false)}
activeFilter={activeFilter}
onFilterChange={handleFilterChange}
/>
</PopoverContent>
</Popover>
);
}

View file

@ -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<boolean>;
markAllAsRead: () => Promise<boolean>;
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 (
<Avatar className="h-6 w-6">
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />}
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(authorName, authorEmail)}
</AvatarFallback>
</Avatar>
);
}
// For other notification types, show status icons
const status = notification.metadata?.status as string | undefined;
switch (status) {
case "in_progress":
return <Loader2 className="h-4 w-4 text-foreground animate-spin" />;
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "failed":
return <AlertCircle className="h-4 w-4 text-red-500" />;
default:
return <Bell className="h-4 w-4 text-muted-foreground" />;
}
};
return (
<div className="flex flex-col w-80 max-w-[calc(100vw-2rem)]">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm">Notifications</h3>
</div>
{unreadCount > 0 && (
<Button variant="ghost" size="sm" onClick={handleMarkAllAsRead} className="h-7 text-xs">
<CheckCheck className="h-3.5 w-3.5 mr-0" />
Mark all read
</Button>
)}
</div>
{/* Filter Pills */}
<div className="flex items-center gap-1.5 px-4 py-2 overflow-x-auto">
{(
Object.entries(NOTIFICATION_FILTERS) as [
NotificationTypeEnum,
(typeof NOTIFICATION_FILTERS)[keyof typeof NOTIFICATION_FILTERS],
][]
).map(([key, { label, icon: Icon }]) => {
const isActive = activeFilter === key;
return (
<button
key={key}
type="button"
onClick={() => onFilterChange(key)}
className={cn(
"inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-medium transition-colors whitespace-nowrap",
"border focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
isActive
? "bg-primary text-primary-foreground border-primary"
: "bg-transparent text-muted-foreground border-border hover:bg-accent hover:text-accent-foreground"
)}
>
<Icon className="h-3 w-3" />
{label}
</button>
);
})}
</div>
{/* Notifications List */}
<ScrollArea className="h-[400px]">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-foreground" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
<Bell className="h-8 w-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">No notifications</p>
</div>
) : (
<div className="pt-0 pb-2">
{notifications.map((notification, index) => (
<div key={notification.id}>
<button
type="button"
onClick={() => handleNotificationClick(notification)}
className={cn(
"w-full px-4 py-3 text-left hover:bg-accent transition-colors",
!notification.read && "bg-accent/50"
)}
>
<div className="flex items-start gap-3 overflow-hidden">
<div className="flex-shrink-0 mt-0.5">{getStatusIcon(notification)}</div>
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-start justify-between gap-2 mb-1">
<p
className={cn(
"text-xs font-medium break-all",
!notification.read && "font-semibold"
)}
>
{notification.title}
</p>
</div>
<p className="text-[11px] text-muted-foreground break-all line-clamp-2">
{convertRenderedToDisplay(notification.message)}
</p>
<div className="flex items-center justify-between mt-2">
<span className="text-[10px] text-muted-foreground">
{formatTime(notification.created_at)}
</span>
</div>
</div>
</div>
</button>
{index < notifications.length - 1 && <Separator />}
</div>
))}
</div>
)}
</ScrollArea>
</div>
);
}

View file

@ -5,11 +5,11 @@ description: Setting up Electric SQL for real-time data synchronization in SurfS
# Electric SQL # 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? ## 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 1. Backend writes data to PostgreSQL
2. Electric SQL detects changes and streams them to the frontend 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: 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 - **Document indexing progress updates live** - Watch your documents get processed
- **Connector status syncs automatically** - See when connectors finish syncing - **Connector status syncs automatically** - See when connectors finish syncing
- **Offline support** - PGlite caches data locally, so previously loaded data remains accessible - **Offline support** - PGlite caches data locally, so previously loaded data remains accessible

View file

@ -3,18 +3,18 @@ import { searchSourceConnectorTypeEnum } from "./connector.types";
import { documentTypeEnum } from "./document.types"; import { documentTypeEnum } from "./document.types";
/** /**
* Notification type enum - matches backend notification types * Inbox item type enum - matches backend notification types
*/ */
export const notificationTypeEnum = z.enum([ export const inboxItemTypeEnum = z.enum([
"connector_indexing", "connector_indexing",
"document_processing", "document_processing",
"new_mention", "new_mention",
]); ]);
/** /**
* Notification status enum - used in metadata * Inbox item status enum - used in metadata
*/ */
export const notificationStatusEnum = z.enum(["in_progress", "completed", "failed"]); export const inboxItemStatusEnum = z.enum(["in_progress", "completed", "failed"]);
/** /**
* Document processing stage enum * Document processing stage enum
@ -30,11 +30,11 @@ export const documentProcessingStageEnum = z.enum([
]); ]);
/** /**
* Base metadata schema shared across notification types * Base metadata schema shared across inbox item types
*/ */
export const baseNotificationMetadata = z.object({ export const baseInboxItemMetadata = z.object({
operation_id: z.string().optional(), operation_id: z.string().optional(),
status: notificationStatusEnum.optional(), status: inboxItemStatusEnum.optional(),
started_at: z.string().optional(), started_at: z.string().optional(),
completed_at: z.string().optional(), completed_at: z.string().optional(),
}); });
@ -42,7 +42,7 @@ export const baseNotificationMetadata = z.object({
/** /**
* Connector indexing metadata schema * Connector indexing metadata schema
*/ */
export const connectorIndexingMetadata = baseNotificationMetadata.extend({ export const connectorIndexingMetadata = baseInboxItemMetadata.extend({
connector_id: z.number(), connector_id: z.number(),
connector_name: z.string(), connector_name: z.string(),
connector_type: searchSourceConnectorTypeEnum, connector_type: searchSourceConnectorTypeEnum,
@ -62,7 +62,7 @@ export const connectorIndexingMetadata = baseNotificationMetadata.extend({
/** /**
* Document processing metadata schema * Document processing metadata schema
*/ */
export const documentProcessingMetadata = baseNotificationMetadata.extend({ export const documentProcessingMetadata = baseInboxItemMetadata.extend({
document_type: documentTypeEnum, document_type: documentTypeEnum,
document_name: z.string(), document_name: z.string(),
processing_stage: documentProcessingStageEnum, processing_stage: documentProcessingStageEnum,
@ -89,24 +89,24 @@ export const newMentionMetadata = z.object({
}); });
/** /**
* Union of all notification metadata types * Union of all inbox item metadata types
* Use this when the notification type is unknown * Use this when the inbox item type is unknown
*/ */
export const notificationMetadata = z.union([ export const inboxItemMetadata = z.union([
connectorIndexingMetadata, connectorIndexingMetadata,
documentProcessingMetadata, documentProcessingMetadata,
newMentionMetadata, newMentionMetadata,
baseNotificationMetadata, baseInboxItemMetadata,
]); ]);
/** /**
* Main notification schema * Main inbox item schema
*/ */
export const notification = z.object({ export const inboxItem = z.object({
id: z.number(), id: z.number(),
user_id: z.string(), user_id: z.string(),
search_space_id: z.number().nullable(), search_space_id: z.number().nullable(),
type: notificationTypeEnum, type: inboxItemTypeEnum,
title: z.string(), title: z.string(),
message: z.string(), message: z.string(),
read: z.boolean(), read: z.boolean(),
@ -116,33 +116,34 @@ export const notification = z.object({
}); });
/** /**
* Typed notification schemas for specific notification types * Typed inbox item schemas for specific types
*/ */
export const connectorIndexingNotification = notification.extend({ export const connectorIndexingInboxItem = inboxItem.extend({
type: z.literal("connector_indexing"), type: z.literal("connector_indexing"),
metadata: connectorIndexingMetadata, metadata: connectorIndexingMetadata,
}); });
export const documentProcessingNotification = notification.extend({ export const documentProcessingInboxItem = inboxItem.extend({
type: z.literal("document_processing"), type: z.literal("document_processing"),
metadata: documentProcessingMetadata, metadata: documentProcessingMetadata,
}); });
export const newMentionNotification = notification.extend({ export const newMentionInboxItem = inboxItem.extend({
type: z.literal("new_mention"), type: z.literal("new_mention"),
metadata: newMentionMetadata, metadata: newMentionMetadata,
}); });
// Inferred types // Inferred types
export type NotificationTypeEnum = z.infer<typeof notificationTypeEnum>; export type InboxItemTypeEnum = z.infer<typeof inboxItemTypeEnum>;
export type NotificationStatusEnum = z.infer<typeof notificationStatusEnum>; export type InboxItemStatusEnum = z.infer<typeof inboxItemStatusEnum>;
export type DocumentProcessingStageEnum = z.infer<typeof documentProcessingStageEnum>; export type DocumentProcessingStageEnum = z.infer<typeof documentProcessingStageEnum>;
export type BaseNotificationMetadata = z.infer<typeof baseNotificationMetadata>; export type BaseInboxItemMetadata = z.infer<typeof baseInboxItemMetadata>;
export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata>; export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata>;
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>; export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
export type NewMentionMetadata = z.infer<typeof newMentionMetadata>; export type NewMentionMetadata = z.infer<typeof newMentionMetadata>;
export type NotificationMetadata = z.infer<typeof notificationMetadata>; export type InboxItemMetadata = z.infer<typeof inboxItemMetadata>;
export type Notification = z.infer<typeof notification>; export type InboxItem = z.infer<typeof inboxItem>;
export type ConnectorIndexingNotification = z.infer<typeof connectorIndexingNotification>; export type ConnectorIndexingInboxItem = z.infer<typeof connectorIndexingInboxItem>;
export type DocumentProcessingNotification = z.infer<typeof documentProcessingNotification>; export type DocumentProcessingInboxItem = z.infer<typeof documentProcessingInboxItem>;
export type NewMentionNotification = z.infer<typeof newMentionNotification>; export type NewMentionInboxItem = z.infer<typeof newMentionInboxItem>;

View file

@ -1,38 +1,38 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types"; import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
import { authenticatedFetch } from "@/lib/auth-utils"; import { authenticatedFetch } from "@/lib/auth-utils";
import type { SyncHandle } from "@/lib/electric/client"; import type { SyncHandle } from "@/lib/electric/client";
import { useElectricClient } from "@/lib/electric/context"; import { useElectricClient } from "@/lib/electric/context";
export type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types"; export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
/** /**
* Hook for managing notifications with Electric SQL real-time sync * Hook for managing inbox items with Electric SQL real-time sync
* *
* Uses the Electric client from context (provided by ElectricProvider) * Uses the Electric client from context (provided by ElectricProvider)
* instead of initializing its own - prevents race conditions and memory leaks * instead of initializing its own - prevents race conditions and memory leaks
* *
* Architecture: * Architecture:
* - User-level sync: Syncs ALL notifications for a user (runs once per user) * - User-level sync: Syncs ALL inbox items for a user (runs once per user)
* - Search-space-level query: Filters notifications by searchSpaceId (updates on search space change) * - Search-space-level query: Filters inbox items by searchSpaceId (updates on search space change)
* *
* This separation ensures smooth transitions when switching search spaces (no flash). * This separation ensures smooth transitions when switching search spaces (no flash).
* *
* @param userId - The user ID to fetch notifications for * @param userId - The user ID to fetch inbox items for
* @param searchSpaceId - The search space ID to filter notifications (null shows global notifications only) * @param searchSpaceId - The search space ID to filter inbox items (null shows global items only)
* @param typeFilter - Optional notification type to filter by (null shows all types) * @param typeFilter - Optional inbox item type to filter by (null shows all types)
*/ */
export function useNotifications( export function useInbox(
userId: string | null, userId: string | null,
searchSpaceId: number | null, searchSpaceId: number | null,
typeFilter: NotificationTypeEnum | null = null typeFilter: InboxItemTypeEnum | null = null
) { ) {
// Get Electric client from context - ElectricProvider handles initialization // Get Electric client from context - ElectricProvider handles initialization
const electricClient = useElectricClient(); const electricClient = useElectricClient();
const [notifications, setNotifications] = useState<Notification[]>([]); const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
const [totalUnreadCount, setTotalUnreadCount] = useState(0); const [totalUnreadCount, setTotalUnreadCount] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
@ -43,34 +43,37 @@ export function useNotifications(
// Track user-level sync key to prevent duplicate sync subscriptions // Track user-level sync key to prevent duplicate sync subscriptions
const userSyncKeyRef = useRef<string | null>(null); const userSyncKeyRef = useRef<string | null>(null);
// EFFECT 1: User-level sync - runs once per user, syncs ALL notifications // EFFECT 1: User-level sync - runs once per user, syncs ALL inbox items
useEffect(() => { useEffect(() => {
if (!userId || !electricClient) { if (!userId || !electricClient) {
setLoading(!electricClient); setLoading(!electricClient);
return; return;
} }
const userSyncKey = `notifications_${userId}`; const userSyncKey = `inbox_${userId}`;
if (userSyncKeyRef.current === userSyncKey) { if (userSyncKeyRef.current === userSyncKey) {
// Already syncing for this user // Already syncing for this user
return; return;
} }
// Capture electricClient to satisfy TypeScript in async function
const client = electricClient;
let mounted = true; let mounted = true;
userSyncKeyRef.current = userSyncKey; userSyncKeyRef.current = userSyncKey;
async function startUserSync() { async function startUserSync() {
try { try {
console.log("[useNotifications] Starting user-level sync for:", userId); console.log("[useInbox] Starting user-level sync for:", userId);
// Sync ALL notifications for this user (cached via syncShape caching) // Sync ALL inbox items for this user (cached via syncShape caching)
const handle = await electricClient.syncShape({ // Note: Backend table is still named "notifications"
const handle = await client.syncShape({
table: "notifications", table: "notifications",
where: `user_id = '${userId}'`, where: `user_id = '${userId}'`,
primaryKey: ["id"], primaryKey: ["id"],
}); });
console.log("[useNotifications] User sync started:", { console.log("[useInbox] User sync started:", {
isUpToDate: handle.isUpToDate, isUpToDate: handle.isUpToDate,
}); });
@ -82,7 +85,7 @@ export function useNotifications(
new Promise((resolve) => setTimeout(resolve, 2000)), new Promise((resolve) => setTimeout(resolve, 2000)),
]); ]);
} catch (syncErr) { } catch (syncErr) {
console.error("[useNotifications] Initial sync failed:", syncErr); console.error("[useInbox] Initial sync failed:", syncErr);
} }
} }
@ -96,8 +99,8 @@ export function useNotifications(
setError(null); setError(null);
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
console.error("[useNotifications] Failed to start user sync:", err); console.error("[useInbox] Failed to start user sync:", err);
setError(err instanceof Error ? err : new Error("Failed to sync notifications")); setError(err instanceof Error ? err : new Error("Failed to sync inbox"));
setLoading(false); setLoading(false);
} }
} }
@ -122,10 +125,12 @@ export function useNotifications(
return; return;
} }
// Capture electricClient to satisfy TypeScript in async function
const client = electricClient;
let mounted = true; let mounted = true;
async function updateQuery() { async function updateQuery() {
// Clean up previous live query (but DON'T clear notifications - keep showing old until new arrive) // Clean up previous live query (but DON'T clear inbox items - keep showing old until new arrive)
if (liveQueryRef.current) { if (liveQueryRef.current) {
liveQueryRef.current.unsubscribe(); liveQueryRef.current.unsubscribe();
liveQueryRef.current = null; liveQueryRef.current = null;
@ -133,13 +138,14 @@ export function useNotifications(
try { try {
console.log( console.log(
"[useNotifications] Updating query for searchSpace:", "[useInbox] Updating query for searchSpace:",
searchSpaceId, searchSpaceId,
"typeFilter:", "typeFilter:",
typeFilter typeFilter
); );
// Build query with optional type filter // Build query with optional type filter
// Note: Backend table is still named "notifications"
const baseQuery = `SELECT * FROM notifications const baseQuery = `SELECT * FROM notifications
WHERE user_id = $1 WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)`; AND (search_space_id = $2 OR search_space_id IS NULL)`;
@ -148,16 +154,15 @@ export function useNotifications(
const fullQuery = baseQuery + typeClause + orderClause; const fullQuery = baseQuery + typeClause + orderClause;
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId]; const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
// Fetch notifications for current search space immediately // Fetch inbox items for current search space immediately
const result = await electricClient.db.query<Notification>(fullQuery, params); const result = await client.db.query<InboxItem>(fullQuery, params);
if (mounted) { if (mounted) {
setNotifications(result.rows || []); setInboxItems(result.rows || []);
} }
// Set up live query for real-time updates // Set up live query for real-time updates
// eslint-disable-next-line @typescript-eslint/no-explicit-any const db = client.db as any;
const db = electricClient.db as any;
if (db.live?.query && typeof db.live.query === "function") { if (db.live?.query && typeof db.live.query === "function") {
const liveQuery = await db.live.query(fullQuery, params); const liveQuery = await db.live.query(fullQuery, params);
@ -169,16 +174,16 @@ export function useNotifications(
// Set initial results from live query // Set initial results from live query
if (liveQuery.initialResults?.rows) { if (liveQuery.initialResults?.rows) {
setNotifications(liveQuery.initialResults.rows); setInboxItems(liveQuery.initialResults.rows);
} else if (liveQuery.rows) { } else if (liveQuery.rows) {
setNotifications(liveQuery.rows); setInboxItems(liveQuery.rows);
} }
// Subscribe to changes // Subscribe to changes
if (typeof liveQuery.subscribe === "function") { if (typeof liveQuery.subscribe === "function") {
liveQuery.subscribe((result: { rows: Notification[] }) => { liveQuery.subscribe((result: { rows: InboxItem[] }) => {
if (mounted && result.rows) { if (mounted && result.rows) {
setNotifications(result.rows); setInboxItems(result.rows);
} }
}); });
} }
@ -188,7 +193,7 @@ export function useNotifications(
} }
} }
} catch (err) { } catch (err) {
console.error("[useNotifications] Failed to update query:", err); console.error("[useInbox] Failed to update query:", err);
} }
} }
@ -210,6 +215,8 @@ export function useNotifications(
return; return;
} }
// Capture electricClient to satisfy TypeScript in async function
const client = electricClient;
let mounted = true; let mounted = true;
async function updateUnreadCount() { async function updateUnreadCount() {
@ -220,13 +227,14 @@ export function useNotifications(
} }
try { try {
// Note: Backend table is still named "notifications"
const countQuery = `SELECT COUNT(*) as count FROM notifications const countQuery = `SELECT COUNT(*) as count FROM notifications
WHERE user_id = $1 WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL) AND (search_space_id = $2 OR search_space_id IS NULL)
AND read = false`; AND read = false`;
// Fetch initial count // Fetch initial count
const result = await electricClient.db.query<{ count: number }>(countQuery, [ const result = await client.db.query<{ count: number }>(countQuery, [
userId, userId,
searchSpaceId, searchSpaceId,
]); ]);
@ -236,8 +244,7 @@ export function useNotifications(
} }
// Set up live query for real-time updates // Set up live query for real-time updates
// eslint-disable-next-line @typescript-eslint/no-explicit-any const db = client.db as any;
const db = electricClient.db as any;
if (db.live?.query && typeof db.live.query === "function") { if (db.live?.query && typeof db.live.query === "function") {
const liveQuery = await db.live.query(countQuery, [userId, searchSpaceId]); const liveQuery = await db.live.query(countQuery, [userId, searchSpaceId]);
@ -268,7 +275,7 @@ export function useNotifications(
} }
} }
} catch (err) { } catch (err) {
console.error("[useNotifications] Failed to update unread count:", err); console.error("[useInbox] Failed to update unread count:", err);
} }
} }
@ -283,29 +290,31 @@ export function useNotifications(
}; };
}, [userId, searchSpaceId, electricClient]); }, [userId, searchSpaceId, electricClient]);
// Mark notification as read via backend API // Mark inbox item as read via backend API
const markAsRead = useCallback(async (notificationId: number) => { const markAsRead = useCallback(async (itemId: number) => {
try { try {
// Note: Backend API endpoint is still /notifications/
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${notificationId}/read`, `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/read`,
{ method: "PATCH" } { method: "PATCH" }
); );
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Failed to mark as read" })); const error = await response.json().catch(() => ({ detail: "Failed to mark as read" }));
throw new Error(error.detail || "Failed to mark notification as read"); throw new Error(error.detail || "Failed to mark inbox item as read");
} }
return true; return true;
} catch (err) { } catch (err) {
console.error("Failed to mark notification as read:", err); console.error("Failed to mark inbox item as read:", err);
return false; return false;
} }
}, []); }, []);
// Mark all notifications as read via backend API // Mark all inbox items as read via backend API
const markAllAsRead = useCallback(async () => { const markAllAsRead = useCallback(async () => {
try { try {
// Note: Backend API endpoint is still /notifications/
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`, `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/read-all`,
{ method: "PATCH" } { method: "PATCH" }
@ -313,22 +322,48 @@ export function useNotifications(
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Failed to mark all as read" })); const error = await response.json().catch(() => ({ detail: "Failed to mark all as read" }));
throw new Error(error.detail || "Failed to mark all notifications as read"); throw new Error(error.detail || "Failed to mark all inbox items as read");
} }
return true; return true;
} catch (err) { } catch (err) {
console.error("Failed to mark all notifications as read:", err); console.error("Failed to mark all inbox items as read:", err);
return false;
}
}, []);
// Archive/unarchive an inbox item via backend API
const archiveItem = useCallback(async (itemId: number, archived: boolean) => {
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/archive`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ archived }),
}
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Failed to update archive status" }));
throw new Error(error.detail || "Failed to update inbox item archive status");
}
return true;
} catch (err) {
console.error("Failed to update inbox item archive status:", err);
return false; return false;
} }
}, []); }, []);
return { return {
notifications, inboxItems,
unreadCount: totalUnreadCount, unreadCount: totalUnreadCount,
markAsRead, markAsRead,
markAllAsRead, markAllAsRead,
archiveItem,
loading, loading,
error, error,
}; };
} }

View file

@ -692,7 +692,22 @@
"light": "Light", "light": "Light",
"dark": "Dark", "dark": "Dark",
"system": "System", "system": "System",
"logout": "Logout" "logout": "Logout",
"inbox": "Inbox",
"search_inbox": "Search inbox",
"mark_all_read": "Mark all as read",
"mark_as_read": "Mark as read",
"mentions": "Mentions",
"status": "Status",
"no_results_found": "No results found",
"no_mentions": "No mentions",
"no_mentions_hint": "You'll see mentions from others here",
"no_status_updates": "No status updates",
"no_status_updates_hint": "Document and connector updates will appear here",
"filter": "Filter",
"unread_and_read": "Unread & read",
"unread": "Unread",
"archived": "Archived"
}, },
"errors": { "errors": {
"something_went_wrong": "Something went wrong", "something_went_wrong": "Something went wrong",

View file

@ -677,7 +677,18 @@
"light": "浅色", "light": "浅色",
"dark": "深色", "dark": "深色",
"system": "系统", "system": "系统",
"logout": "退出登录" "logout": "退出登录",
"inbox": "收件箱",
"search_inbox": "搜索收件箱...",
"mark_all_read": "全部标记为已读",
"mark_as_read": "标记为已读",
"mentions": "提及",
"status": "状态",
"no_results_found": "未找到结果",
"no_mentions": "没有提及",
"no_mentions_hint": "您会在这里看到他人的提及",
"no_status_updates": "没有状态更新",
"no_status_updates_hint": "文档和连接器更新将显示在这里"
}, },
"errors": { "errors": {
"something_went_wrong": "出错了", "something_went_wrong": "出错了",