refactor: consolidate inbox data handling in LayoutDataProvider and related components, streamlining state management and improving performance by using a single data source for inbox items

This commit is contained in:
Anish Sarkar 2026-03-06 19:35:21 +05:30
parent bd783cc2d0
commit eb775fea11
5 changed files with 350 additions and 677 deletions

View file

@ -121,34 +121,19 @@ export function LayoutDataProvider({
// Search space dialog state // Search space dialog state
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
// Inbox hooks - separate data sources for mentions and status tabs // Single inbox hook - API-first with Electric real-time deltas
// This ensures each tab has independent pagination and data loading
const userId = user?.id ? String(user.id) : null; const userId = user?.id ? String(user.id) : null;
const { const {
inboxItems: mentionItems, inboxItems,
unreadCount: mentionUnreadCount, unreadCount: totalUnreadCount,
loading: mentionLoading, loading: inboxLoading,
loadingMore: mentionLoadingMore, loadingMore: inboxLoadingMore,
hasMore: mentionHasMore, hasMore: inboxHasMore,
loadMore: mentionLoadMore, loadMore: inboxLoadMore,
markAsRead: markMentionAsRead, markAsRead,
markAllAsRead: markAllMentionsAsRead, markAllAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, "new_mention"); } = useInbox(userId, Number(searchSpaceId) || null);
const {
inboxItems: statusItems,
unreadCount: allUnreadCount,
loading: statusLoading,
loadingMore: statusLoadingMore,
hasMore: statusHasMore,
loadMore: statusLoadMore,
markAsRead: markStatusAsRead,
markAllAsRead: markAllStatusAsRead,
} = useInbox(userId, Number(searchSpaceId) || null, null);
const totalUnreadCount = allUnreadCount;
const statusOnlyUnreadCount = Math.max(0, allUnreadCount - mentionUnreadCount);
// Track seen notification IDs to detect new page_limit_exceeded notifications // Track seen notification IDs to detect new page_limit_exceeded notifications
const seenPageLimitNotifications = useRef<Set<number>>(new Set()); const seenPageLimitNotifications = useRef<Set<number>>(new Set());
@ -156,14 +141,12 @@ export function LayoutDataProvider({
// Effect to show toast for new page_limit_exceeded notifications // Effect to show toast for new page_limit_exceeded notifications
useEffect(() => { useEffect(() => {
if (statusLoading) return; if (inboxLoading) return;
// Get page_limit_exceeded notifications const pageLimitNotifications = inboxItems.filter(
const pageLimitNotifications = statusItems.filter(
(item) => item.type === "page_limit_exceeded" (item) => item.type === "page_limit_exceeded"
); );
// On initial load, just mark all as seen without showing toasts
if (isInitialLoad.current) { if (isInitialLoad.current) {
for (const notification of pageLimitNotifications) { for (const notification of pageLimitNotifications) {
seenPageLimitNotifications.current.add(notification.id); seenPageLimitNotifications.current.add(notification.id);
@ -172,16 +155,13 @@ export function LayoutDataProvider({
return; return;
} }
// Find new notifications (not yet seen)
const newNotifications = pageLimitNotifications.filter( const newNotifications = pageLimitNotifications.filter(
(notification) => !seenPageLimitNotifications.current.has(notification.id) (notification) => !seenPageLimitNotifications.current.has(notification.id)
); );
// Show toast for each new page_limit_exceeded notification
for (const notification of newNotifications) { for (const notification of newNotifications) {
seenPageLimitNotifications.current.add(notification.id); seenPageLimitNotifications.current.add(notification.id);
// Extract metadata for navigation
const actionUrl = isPageLimitExceededMetadata(notification.metadata) const actionUrl = isPageLimitExceededMetadata(notification.metadata)
? notification.metadata.action_url ? notification.metadata.action_url
: `/dashboard/${searchSpaceId}/more-pages`; : `/dashboard/${searchSpaceId}/more-pages`;
@ -196,24 +176,8 @@ export function LayoutDataProvider({
}, },
}); });
} }
}, [statusItems, statusLoading, searchSpaceId, router]); }, [inboxItems, inboxLoading, searchSpaceId, router]);
// Unified mark as read that delegates to the correct hook
const markAsRead = useCallback(
async (id: number) => {
// Try both - one will succeed based on which list has the item
const mentionResult = await markMentionAsRead(id);
if (mentionResult) return true;
return markStatusAsRead(id);
},
[markMentionAsRead, markStatusAsRead]
);
// Mark all as read for both types
const markAllAsRead = useCallback(async () => {
await Promise.all([markAllMentionsAsRead(), markAllStatusAsRead()]);
return true;
}, [markAllMentionsAsRead, markAllStatusAsRead]);
// Delete dialogs state // Delete dialogs state
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false); const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
@ -643,24 +607,12 @@ export function LayoutDataProvider({
inbox={{ inbox={{
isOpen: isInboxSidebarOpen, isOpen: isInboxSidebarOpen,
onOpenChange: setIsInboxSidebarOpen, onOpenChange: setIsInboxSidebarOpen,
// Separate data sources for each tab items: inboxItems,
mentions: {
items: mentionItems,
unreadCount: mentionUnreadCount,
loading: mentionLoading,
loadingMore: mentionLoadingMore,
hasMore: mentionHasMore,
loadMore: mentionLoadMore,
},
status: {
items: statusItems,
unreadCount: statusOnlyUnreadCount,
loading: statusLoading,
loadingMore: statusLoadingMore,
hasMore: statusHasMore,
loadMore: statusLoadMore,
},
totalUnreadCount, totalUnreadCount,
loading: inboxLoading,
loadingMore: inboxLoadingMore,
hasMore: inboxHasMore,
loadMore: inboxLoadMore,
markAsRead, markAsRead,
markAllAsRead, markAllAsRead,
isDocked: isInboxDocked, isDocked: isInboxDocked,

View file

@ -20,26 +20,16 @@ import {
Sidebar, Sidebar,
} from "../sidebar"; } from "../sidebar";
// Tab-specific data source props // Inbox-related props — single data source, tab split done in InboxSidebar
interface TabDataSource { interface InboxProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
items: InboxItem[]; items: InboxItem[];
unreadCount: number; totalUnreadCount: number;
loading: boolean; loading: boolean;
loadingMore?: boolean; loadingMore?: boolean;
hasMore?: boolean; hasMore?: boolean;
loadMore?: () => void; loadMore?: () => void;
}
// Inbox-related props with separate data sources per tab
interface InboxProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
/** Mentions tab data source with independent pagination */
mentions: TabDataSource;
/** Status tab data source with independent pagination */
status: TabDataSource;
/** Combined unread count for nav badge */
totalUnreadCount: number;
markAsRead: (id: number) => Promise<boolean>; markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>; markAllAsRead: () => Promise<boolean>;
/** Whether the inbox is docked (permanent) */ /** Whether the inbox is docked (permanent) */
@ -306,9 +296,12 @@ export function LayoutShell({
<InboxSidebar <InboxSidebar
open={inbox.isOpen} open={inbox.isOpen}
onOpenChange={inbox.onOpenChange} onOpenChange={inbox.onOpenChange}
mentions={inbox.mentions} items={inbox.items}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount} totalUnreadCount={inbox.totalUnreadCount}
loading={inbox.loading}
loadingMore={inbox.loadingMore}
hasMore={inbox.hasMore}
loadMore={inbox.loadMore}
markAsRead={inbox.markAsRead} markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead} markAllAsRead={inbox.markAllAsRead}
isDocked={inbox.isDocked} isDocked={inbox.isDocked}
@ -329,9 +322,12 @@ export function LayoutShell({
<InboxSidebar <InboxSidebar
open={inbox.isOpen} open={inbox.isOpen}
onOpenChange={inbox.onOpenChange} onOpenChange={inbox.onOpenChange}
mentions={inbox.mentions} items={inbox.items}
status={inbox.status}
totalUnreadCount={inbox.totalUnreadCount} totalUnreadCount={inbox.totalUnreadCount}
loading={inbox.loading}
loadingMore={inbox.loadingMore}
hasMore={inbox.hasMore}
loadMore={inbox.loadMore}
markAsRead={inbox.markAsRead} markAsRead={inbox.markAsRead}
markAllAsRead={inbox.markAllAsRead} markAllAsRead={inbox.markAllAsRead}
isDocked={false} isDocked={false}

View file

@ -62,9 +62,6 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
/**
* Get initials from name or email for avatar fallback
*/
function getInitials(name: string | null | undefined, email: string | null | undefined): string { function getInitials(name: string | null | undefined, email: string | null | undefined): string {
if (name) { if (name) {
return name return name
@ -81,9 +78,6 @@ function getInitials(name: string | null | undefined, email: string | null | und
return "U"; return "U";
} }
/**
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
*/
function formatInboxCount(count: number): string { function formatInboxCount(count: number): string {
if (count <= 999) { if (count <= 999) {
return count.toString(); return count.toString();
@ -92,9 +86,6 @@ function formatInboxCount(count: number): string {
return `${thousands}k+`; return `${thousands}k+`;
} }
/**
* Get display name for connector type
*/
function getConnectorTypeDisplayName(connectorType: string): string { function getConnectorTypeDisplayName(connectorType: string): string {
const displayNames: Record<string, string> = { const displayNames: Record<string, string> = {
GITHUB_CONNECTOR: "GitHub", GITHUB_CONNECTOR: "GitHub",
@ -139,40 +130,34 @@ function getConnectorTypeDisplayName(connectorType: string): string {
type InboxTab = "comments" | "status"; type InboxTab = "comments" | "status";
type InboxFilter = "all" | "unread" | "errors"; type InboxFilter = "all" | "unread" | "errors";
// Tab-specific data source with independent pagination const COMMENT_TYPES = new Set(["new_mention", "comment_reply"]);
interface TabDataSource { const STATUS_TYPES = new Set(["connector_indexing", "document_processing", "page_limit_exceeded", "connector_deletion"]);
items: InboxItem[];
unreadCount: number;
loading: boolean;
loadingMore?: boolean;
hasMore?: boolean;
loadMore?: () => void;
}
interface InboxSidebarProps { interface InboxSidebarProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
/** Mentions tab data source with independent pagination */ items: InboxItem[];
mentions: TabDataSource;
/** Status tab data source with independent pagination */
status: TabDataSource;
/** Combined unread count for mark all as read */
totalUnreadCount: number; totalUnreadCount: number;
loading: boolean;
loadingMore?: boolean;
hasMore?: boolean;
loadMore?: () => void;
markAsRead: (id: number) => Promise<boolean>; markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>; markAllAsRead: () => Promise<boolean>;
onCloseMobileSidebar?: () => void; onCloseMobileSidebar?: () => void;
/** Whether the inbox is docked (permanent) or floating */
isDocked?: boolean; isDocked?: boolean;
/** Callback to toggle docked state */
onDockedChange?: (docked: boolean) => void; onDockedChange?: (docked: boolean) => void;
} }
export function InboxSidebar({ export function InboxSidebar({
open, open,
onOpenChange, onOpenChange,
mentions, items,
status,
totalUnreadCount, totalUnreadCount,
loading,
loadingMore: loadingMoreProp = false,
hasMore: hasMoreProp = false,
loadMore,
markAsRead, markAsRead,
markAllAsRead, markAllAsRead,
onCloseMobileSidebar, onCloseMobileSidebar,
@ -185,9 +170,7 @@ export function InboxSidebar({
const isMobile = !useMediaQuery("(min-width: 640px)"); const isMobile = !useMediaQuery("(min-width: 640px)");
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null; const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
// Comments collapsed state (desktop only, when docked)
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom); const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
// Target comment for navigation - also ensures comments panel is visible
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@ -197,9 +180,7 @@ export function InboxSidebar({
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all"); const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
const [selectedSource, setSelectedSource] = useState<string | null>(null); const [selectedSource, setSelectedSource] = useState<string | null>(null);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
// Dropdown state for filter menu (desktop only)
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null); const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
// Scroll shadow state for connector list
const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top"); const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top");
const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => { const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget; const el = e.currentTarget;
@ -207,15 +188,12 @@ export function InboxSidebar({
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
}, []); }, []);
// Drawer state for filter menu (mobile only)
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null); const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
// Prefetch trigger ref - placed on item near the end
const prefetchTriggerRef = useRef<HTMLDivElement>(null); const prefetchTriggerRef = useRef<HTMLDivElement>(null);
// Server-side search query (enabled only when user is typing a search) // Server-side search query
// Determines which notification types to search based on active tab
const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined; const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined;
const { data: searchResponse, isLoading: isSearchLoading } = useQuery({ const { data: searchResponse, isLoading: isSearchLoading } = useQuery({
queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab), queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab),
@ -228,7 +206,7 @@ export function InboxSidebar({
limit: 50, limit: 50,
}, },
}), }),
staleTime: 30 * 1000, // 30 seconds (search results don't need to be super fresh) staleTime: 30 * 1000,
enabled: isSearchMode && open, enabled: isSearchMode && open,
}); });
@ -246,53 +224,43 @@ export function InboxSidebar({
return () => document.removeEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape);
}, [open, onOpenChange]); }, [open, onOpenChange]);
// Only lock body scroll on mobile when inbox is open
useEffect(() => { useEffect(() => {
if (!open || !isMobile) return; if (!open || !isMobile) return;
// Store original overflow to restore on cleanup
const originalOverflow = document.body.style.overflow; const originalOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
return () => { return () => {
document.body.style.overflow = originalOverflow; document.body.style.overflow = originalOverflow;
}; };
}, [open, isMobile]); }, [open, isMobile]);
// Reset source filter when switching away from status tab
useEffect(() => { useEffect(() => {
if (activeTab !== "status") { if (activeTab !== "status") {
setSelectedSource(null); setSelectedSource(null);
} }
}, [activeTab]); }, [activeTab]);
// Each tab uses its own data source for independent pagination // Split items by tab type (client-side from single data source)
// Comments tab: uses mentions data source (fetches only mention/reply types from server) const commentsItems = useMemo(
const commentsItems = mentions.items; () => items.filter((item) => COMMENT_TYPES.has(item.type)),
[items]
// Status tab: filters status data source (fetches all types) to status-specific types
const statusItems = useMemo(
() =>
status.items.filter(
(item) =>
item.type === "connector_indexing" ||
item.type === "document_processing" ||
item.type === "page_limit_exceeded" ||
item.type === "connector_deletion"
),
[status.items]
); );
// Pagination switches based on active tab const statusItems = useMemo(
const loading = activeTab === "comments" ? mentions.loading : status.loading; () => items.filter((item) => STATUS_TYPES.has(item.type)),
const loadingMore = [items]
activeTab === "comments" ? (mentions.loadingMore ?? false) : (status.loadingMore ?? false); );
const hasMore =
activeTab === "comments" ? (mentions.hasMore ?? false) : (status.hasMore ?? false);
const loadMore = activeTab === "comments" ? mentions.loadMore : status.loadMore;
// Fetch ALL source types from the backend so the filter shows every connector/document // Derive unread counts per tab from the items array
// type the user has notifications for, regardless of how many items are loaded via pagination. const unreadCommentsCount = useMemo(
() => commentsItems.filter((item) => !item.read).length,
[commentsItems]
);
const unreadStatusCount = useMemo(
() => statusItems.filter((item) => !item.read).length,
[statusItems]
);
// Fetch source types for the status tab filter
const { data: sourceTypesData } = useQuery({ const { data: sourceTypesData } = useQuery({
queryKey: cacheKeys.notifications.sourceTypes(searchSpaceId), queryKey: cacheKeys.notifications.sourceTypes(searchSpaceId),
queryFn: () => notificationsApiService.getSourceTypes(searchSpaceId ?? undefined), queryFn: () => notificationsApiService.getSourceTypes(searchSpaceId ?? undefined),
@ -314,45 +282,7 @@ export function InboxSidebar({
})); }));
}, [sourceTypesData]); }, [sourceTypesData]);
// Get items for current tab // Client-side filter: source type
const displayItems = activeTab === "comments" ? commentsItems : statusItems;
// When a non-default filter (unread/errors) is active on the status tab,
// fetch matching items from the API so older items beyond the Electric
// sync window are included.
const isActiveFilterMode = activeTab === "status" && (activeFilter === "unread" || activeFilter === "errors");
const { data: activeFilterResponse, isLoading: isActiveFilterLoading } = useQuery({
queryKey: cacheKeys.notifications.byFilter(searchSpaceId, activeFilter),
queryFn: () =>
notificationsApiService.getNotifications({
queryParams: {
search_space_id: searchSpaceId ?? undefined,
filter: activeFilter as "unread" | "errors",
limit: 100,
},
}),
staleTime: 30 * 1000,
enabled: isActiveFilterMode && open && !isSearchMode,
});
// When a source filter is active, fetch matching items from the API so
// older items (outside the Electric sync window) are included.
const isSourceFilterMode = activeTab === "status" && !!selectedSource;
const { data: sourceFilterResponse, isLoading: isSourceFilterLoading } = useQuery({
queryKey: cacheKeys.notifications.bySourceType(searchSpaceId, selectedSource ?? ""),
queryFn: () =>
notificationsApiService.getNotifications({
queryParams: {
search_space_id: searchSpaceId ?? undefined,
source_type: selectedSource ?? undefined,
limit: 50,
},
}),
staleTime: 30 * 1000,
enabled: isSourceFilterMode && open && !isSearchMode,
});
// Client-side matcher: checks if an item matches the active source filter
const matchesSourceFilter = useCallback( const matchesSourceFilter = useCallback(
(item: InboxItem): boolean => { (item: InboxItem): boolean => {
if (!selectedSource) return true; if (!selectedSource) return true;
@ -377,7 +307,7 @@ export function InboxSidebar({
[selectedSource] [selectedSource]
); );
// Client-side matcher: checks if an item matches the active filter (unread/errors) // Client-side filter: unread / errors
const matchesActiveFilter = useCallback( const matchesActiveFilter = useCallback(
(item: InboxItem): boolean => { (item: InboxItem): boolean => {
if (activeFilter === "unread") return !item.read; if (activeFilter === "unread") return !item.read;
@ -391,93 +321,56 @@ export function InboxSidebar({
[activeFilter] [activeFilter]
); );
// Filter items based on filter type, connector filter, and search mode // Two data paths: search mode (API) or default (client-side filtered)
// Four data paths:
// 1. Search mode → server-side search results (client-side filter applied after)
// 2. Active filter mode (unread/errors) → API results merged with real-time Electric items
// 3. Source filter mode → API results merged with real-time Electric items
// 4. Default → Electric real-time items (fast, local)
const filteredItems = useMemo(() => { const filteredItems = useMemo(() => {
let items: InboxItem[]; let tabItems: InboxItem[];
if (isSearchMode) { if (isSearchMode) {
items = searchResponse?.items ?? []; tabItems = searchResponse?.items ?? [];
if (activeTab === "status") { if (activeTab === "status") {
items = items.filter( tabItems = tabItems.filter((item) => STATUS_TYPES.has(item.type));
(item) =>
item.type === "connector_indexing" ||
item.type === "document_processing" ||
item.type === "page_limit_exceeded" ||
item.type === "connector_deletion"
);
}
if (activeFilter === "unread") {
items = items.filter((item) => !item.read);
} else if (activeFilter === "errors") {
items = items.filter(matchesActiveFilter);
}
} else if (isActiveFilterMode) {
const apiItems = activeFilterResponse?.items ?? [];
const realtimeMatching = statusItems.filter(matchesActiveFilter);
const seen = new Set(apiItems.map((i) => i.id));
const merged = [...apiItems];
for (const item of realtimeMatching) {
if (!seen.has(item.id)) {
merged.push(item);
}
}
items = merged.sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
} else if (isSourceFilterMode) {
const apiItems = sourceFilterResponse?.items ?? [];
const realtimeMatching = statusItems.filter(matchesSourceFilter);
const seen = new Set(apiItems.map((i) => i.id));
const merged = [...apiItems];
for (const item of realtimeMatching) {
if (!seen.has(item.id)) {
merged.push(item);
}
}
items = merged.sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
} else { } else {
items = displayItems; tabItems = tabItems.filter((item) => COMMENT_TYPES.has(item.type));
}
} else {
tabItems = activeTab === "comments" ? commentsItems : statusItems;
} }
return items; // Apply filters
let result = tabItems;
if (activeFilter !== "all") {
result = result.filter(matchesActiveFilter);
}
if (activeTab === "status" && selectedSource) {
result = result.filter(matchesSourceFilter);
}
return result;
}, [ }, [
displayItems,
statusItems,
searchResponse,
sourceFilterResponse,
activeFilterResponse,
isSearchMode, isSearchMode,
isActiveFilterMode, searchResponse,
isSourceFilterMode,
matchesSourceFilter,
matchesActiveFilter,
activeFilter,
activeTab, activeTab,
commentsItems,
statusItems,
activeFilter,
selectedSource,
matchesActiveFilter,
matchesSourceFilter,
]); ]);
// Intersection Observer for infinite scroll with prefetching // Infinite scroll
// Re-runs when active tab changes so each tab gets its own pagination
// Disabled during server-side search (search results are not paginated via infinite scroll)
useEffect(() => { useEffect(() => {
if (!loadMore || !hasMore || loadingMore || !open || isSearchMode) return; if (!loadMore || !hasMoreProp || loadingMoreProp || !open || isSearchMode) return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
// When trigger element is visible, load more
if (entries[0]?.isIntersecting) { if (entries[0]?.isIntersecting) {
loadMore(); loadMore();
} }
}, },
{ {
root: null, // viewport root: null,
rootMargin: "100px", // Start loading 100px before visible rootMargin: "100px",
threshold: 0, threshold: 0,
} }
); );
@ -487,11 +380,7 @@ export function InboxSidebar({
} }
return () => observer.disconnect(); return () => observer.disconnect();
}, [loadMore, hasMore, loadingMore, open, isSearchMode, activeTab]); }, [loadMore, hasMoreProp, loadingMoreProp, open, isSearchMode]);
// Unread counts from server-side accurate totals (passed via props)
const unreadCommentsCount = mentions.unreadCount;
const unreadStatusCount = status.unreadCount;
const handleItemClick = useCallback( const handleItemClick = useCallback(
async (item: InboxItem) => { async (item: InboxItem) => {
@ -538,7 +427,6 @@ export function InboxSidebar({
} }
} }
} else if (item.type === "page_limit_exceeded") { } else if (item.type === "page_limit_exceeded") {
// Navigate to the upgrade/more-pages page
if (isPageLimitExceededMetadata(item.metadata)) { if (isPageLimitExceededMetadata(item.metadata)) {
const actionUrl = item.metadata.action_url; const actionUrl = item.metadata.action_url;
if (actionUrl) { if (actionUrl) {
@ -580,7 +468,6 @@ export function InboxSidebar({
}; };
const getStatusIcon = (item: InboxItem) => { const getStatusIcon = (item: InboxItem) => {
// For mentions and comment replies, show the author's avatar
if (item.type === "new_mention" || item.type === "comment_reply") { if (item.type === "new_mention" || item.type === "comment_reply") {
const metadata = const metadata =
item.type === "new_mention" item.type === "new_mention"
@ -612,7 +499,6 @@ export function InboxSidebar({
); );
} }
// For page limit exceeded, show a warning icon with amber/orange color
if (item.type === "page_limit_exceeded") { if (item.type === "page_limit_exceeded") {
return ( return (
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-amber-500/10"> <div className="h-8 w-8 flex items-center justify-center rounded-full bg-amber-500/10">
@ -621,8 +507,6 @@ export function InboxSidebar({
); );
} }
// For status items (connector/document), show status icons
// Safely access status from metadata
const metadata = item.metadata as Record<string, unknown>; const metadata = item.metadata as Record<string, unknown>;
const status = typeof metadata?.status === "string" ? metadata.status : undefined; const status = typeof metadata?.status === "string" ? metadata.status : undefined;
@ -669,13 +553,13 @@ export function InboxSidebar({
if (!mounted) return null; if (!mounted) return null;
// Shared content component for both docked and floating modes const isLoading = isSearchMode ? isSearchLoading : loading;
const inboxContent = ( const inboxContent = (
<> <>
<div className="shrink-0 p-4 pb-2 space-y-3"> <div className="shrink-0 p-4 pb-2 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Back button - mobile only */}
{isMobile && ( {isMobile && (
<Button <Button
variant="ghost" variant="ghost"
@ -690,7 +574,6 @@ export function InboxSidebar({
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2> <h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* Mobile: Button that opens bottom drawer */}
{isMobile ? ( {isMobile ? (
<> <>
<Button <Button
@ -716,7 +599,6 @@ export function InboxSidebar({
</DrawerTitle> </DrawerTitle>
</DrawerHeader> </DrawerHeader>
<div className="flex-1 overflow-y-auto p-4 space-y-4"> <div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Filter section */}
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-muted-foreground/80 font-medium px-1"> <p className="text-xs text-muted-foreground/80 font-medium px-1">
{t("filter") || "Filter"} {t("filter") || "Filter"}
@ -783,7 +665,6 @@ export function InboxSidebar({
)} )}
</div> </div>
</div> </div>
{/* Sources section - only for status tab */}
{activeTab === "status" && statusSourceOptions.length > 0 && ( {activeTab === "status" && statusSourceOptions.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-muted-foreground/80 font-medium px-1"> <p className="text-xs text-muted-foreground/80 font-medium px-1">
@ -841,7 +722,6 @@ export function InboxSidebar({
</Drawer> </Drawer>
</> </>
) : ( ) : (
/* Desktop: Dropdown menu */
<DropdownMenu <DropdownMenu
open={openDropdown === "filter"} open={openDropdown === "filter"}
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)} onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
@ -968,7 +848,6 @@ export function InboxSidebar({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
{/* Dock/Undock button - desktop only */}
{!isMobile && onDockedChange && ( {!isMobile && onDockedChange && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -978,12 +857,10 @@ export function InboxSidebar({
className="h-8 w-8 rounded-full" className="h-8 w-8 rounded-full"
onClick={() => { onClick={() => {
if (isDocked) { if (isDocked) {
// Collapse: show comments immediately, then close inbox
setCommentsCollapsed(false); setCommentsCollapsed(false);
onDockedChange(false); onDockedChange(false);
onOpenChange(false); onOpenChange(false);
} else { } else {
// Expand: hide comments immediately
setCommentsCollapsed(true); setCommentsCollapsed(true);
onDockedChange(true); onDockedChange(true);
} }
@ -1068,11 +945,10 @@ export function InboxSidebar({
</Tabs> </Tabs>
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2"> <div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
{(isSearchMode ? isSearchLoading : isActiveFilterMode ? isActiveFilterLoading : isSourceFilterMode ? isSourceFilterLoading : loading) ? ( {isLoading ? (
<div className="space-y-2"> <div className="space-y-2">
{activeTab === "comments" {activeTab === "comments"
? /* Comments skeleton: avatar + two-line text + time */ ? [85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
[85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
<div <div
key={`skeleton-comment-${i}`} key={`skeleton-comment-${i}`}
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]" className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
@ -1085,8 +961,7 @@ export function InboxSidebar({
<Skeleton className="h-3 w-6 shrink-0 rounded" /> <Skeleton className="h-3 w-6 shrink-0 rounded" />
</div> </div>
)) ))
: /* Status skeleton: status icon circle + two-line text + time */ : [75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
<div <div
key={`skeleton-status-${i}`} key={`skeleton-status-${i}`}
className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]" className="flex items-center gap-3 rounded-lg px-3 py-3 h-[80px]"
@ -1107,9 +982,8 @@ export function InboxSidebar({
<div className="space-y-2"> <div className="space-y-2">
{filteredItems.map((item, index) => { {filteredItems.map((item, index) => {
const isMarkingAsRead = markingAsReadId === item.id; const isMarkingAsRead = markingAsReadId === item.id;
// Place prefetch trigger on 5th item from end (only when not searching)
const isPrefetchTrigger = const isPrefetchTrigger =
!isSearchMode && hasMore && index === filteredItems.length - 5; !isSearchMode && hasMoreProp && index === filteredItems.length - 5;
return ( return (
<div <div
@ -1178,7 +1052,6 @@ export function InboxSidebar({
</Tooltip> </Tooltip>
)} )}
{/* Time and unread dot - fixed width to prevent content shift */}
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10"> <div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
{formatTime(item.created_at)} {formatTime(item.created_at)}
@ -1188,12 +1061,10 @@ export function InboxSidebar({
</div> </div>
); );
})} })}
{/* Fallback trigger at the very end if less than 5 items and not searching */} {!isSearchMode && filteredItems.length < 5 && hasMoreProp && (
{!isSearchMode && filteredItems.length < 5 && hasMore && (
<div ref={prefetchTriggerRef} className="h-1" /> <div ref={prefetchTriggerRef} className="h-1" />
)} )}
{/* Loading more skeletons at the bottom during infinite scroll */} {loadingMoreProp &&
{loadingMore &&
(activeTab === "comments" (activeTab === "comments"
? [80, 60, 90].map((titleWidth, i) => ( ? [80, 60, 90].map((titleWidth, i) => (
<div <div
@ -1250,7 +1121,6 @@ export function InboxSidebar({
</> </>
); );
// DOCKED MODE: Render as a static flex child (no animation, no click-away)
if (isDocked && open && !isMobile) { if (isDocked && open && !isMobile) {
return ( return (
<aside <aside
@ -1262,7 +1132,6 @@ export function InboxSidebar({
); );
} }
// FLOATING MODE: Render with animation and click-away layer
return ( return (
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}> <SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}>
{inboxContent} {inboxContent}

View file

@ -1,497 +1,367 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types"; import type { InboxItem } from "@/contracts/types/inbox.types";
import { notificationsApiService } from "@/lib/apis/notifications-api.service"; import { notificationsApiService } from "@/lib/apis/notifications-api.service";
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 { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types"; export type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types";
const PAGE_SIZE = 50; const INITIAL_PAGE_SIZE = 50;
const SYNC_WINDOW_DAYS = 14; const SCROLL_PAGE_SIZE = 30;
const SYNC_WINDOW_DAYS = 4;
/** /**
* Check if an item is older than the sync window * Calculate the cutoff date for sync window.
*/ * Rounds to the start of the day (midnight UTC) to ensure stable values
function isOlderThanSyncWindow(createdAt: string): boolean { * across re-renders.
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<number, InboxItem>();
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
* IMPORTANT: Rounds to the start of the day (midnight UTC) to ensure stable values
* across re-renders. Without this, millisecond differences cause multiple syncs!
*/ */
function getSyncCutoffDate(): string { function getSyncCutoffDate(): string {
const cutoff = new Date(); const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - SYNC_WINDOW_DAYS); cutoff.setDate(cutoff.getDate() - SYNC_WINDOW_DAYS);
// Round to start of day to prevent millisecond differences causing duplicate syncs
cutoff.setUTCHours(0, 0, 0, 0); cutoff.setUTCHours(0, 0, 0, 0);
return cutoff.toISOString(); return cutoff.toISOString();
} }
/** /**
* Convert a date value to ISO string format * Hook for managing inbox items with API-first architecture + Electric real-time deltas.
*/
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): * Architecture (Documents pattern):
* - Electric SQL: Syncs recent items (within SYNC_WINDOW_DAYS) for real-time updates * 1. API is the PRIMARY data source fetches first page on mount
* - Live Query: Provides reactive first page from PGLite * 2. Electric provides REAL-TIME updates (new items, status changes, read state)
* - API: Handles all pagination (more reliable than mixing with Electric) * 3. Baseline pattern prevents duplicates between API and Electric
* 4. Single instance serves both Comments and Status tabs
* *
* Key Design Decisions: * Unread count strategy:
* 1. No mutable refs for cursor - cursor computed from current state * - API provides the total on mount (ground truth across all time)
* 2. Single deduplicateAndSort function - prevents inconsistencies * - Electric live query counts unread within SYNC_WINDOW_DAYS
* 3. Filter-based preservation in live query - prevents data loss * - olderUnreadOffsetRef bridges the gap: total = offset + recent
* 4. Auto-fetch from API when Electric returns 0 items * - Optimistic updates adjust both the count and the offset (for old items)
* *
* @param userId - The user ID to fetch inbox items for * @param userId - The user ID to fetch inbox items for
* @param searchSpaceId - The search space ID to filter inbox items * @param searchSpaceId - The search space ID to filter inbox items
* @param typeFilter - Optional inbox item type to filter by
*/ */
export function useInbox( export function useInbox(
userId: string | null, userId: string | null,
searchSpaceId: number | null, searchSpaceId: number | null,
typeFilter: InboxItemTypeEnum | null = null
) { ) {
const electricClient = useElectricClient(); const electricClient = useElectricClient();
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]); const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(false);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const [unreadCount, setUnreadCount] = useState(0);
// Split unread count tracking for accurate counts with 14-day sync window const initialLoadDoneRef = useRef(false);
// olderUnreadCount = unread items OLDER than sync window (from server, static until reconciliation) const electricBaselineIdsRef = useRef<Set<number> | null>(null);
// recentUnreadCount = unread items within sync window (from live query, real-time)
const [olderUnreadCount, setOlderUnreadCount] = useState(0);
const [recentUnreadCount, setRecentUnreadCount] = useState(0);
const syncHandleRef = useRef<SyncHandle | null>(null); const syncHandleRef = useRef<SyncHandle | null>(null);
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); const liveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
const userSyncKeyRef = useRef<string | null>(null); const unreadLiveQueryRef = useRef<{ unsubscribe?: () => void } | null>(null);
const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
// Total unread = older (static from server) + recent (live from Electric) // Unread count offset: number of unread items OLDER than the sync window.
const totalUnreadCount = olderUnreadCount + recentUnreadCount; // Computed once from (API total - first Electric recent count), then adjusted
// when the user marks old items as read.
const olderUnreadOffsetRef = useRef<number | null>(null);
const apiUnreadTotalRef = useRef(0);
// EFFECT 1: Electric SQL sync for real-time updates // EFFECT 1: Fetch first page + unread count from API when params change
useEffect(() => { useEffect(() => {
if (!userId || !electricClient) { if (!userId || !searchSpaceId) return;
setLoading(!electricClient);
return;
}
let cancelled = false;
setLoading(true);
setInboxItems([]);
setHasMore(false);
initialLoadDoneRef.current = false;
electricBaselineIdsRef.current = null;
olderUnreadOffsetRef.current = null;
apiUnreadTotalRef.current = 0;
const fetchInitialData = async () => {
try {
const [notificationsResponse, unreadResponse] = await Promise.all([
notificationsApiService.getNotifications({
queryParams: {
search_space_id: searchSpaceId,
limit: INITIAL_PAGE_SIZE,
},
}),
notificationsApiService.getUnreadCount(searchSpaceId),
]);
if (cancelled) return;
setInboxItems(notificationsResponse.items);
setHasMore(notificationsResponse.has_more);
setUnreadCount(unreadResponse.total_unread);
apiUnreadTotalRef.current = unreadResponse.total_unread;
setError(null);
initialLoadDoneRef.current = true;
} catch (err) {
if (cancelled) return;
console.error("[useInbox] Initial load failed:", err);
setError(err instanceof Error ? err : new Error("Failed to load notifications"));
} finally {
if (!cancelled) setLoading(false);
}
};
fetchInitialData();
return () => { cancelled = true; };
}, [userId, searchSpaceId]);
// EFFECT 2: Electric sync + live query for real-time updates
useEffect(() => {
if (!userId || !searchSpaceId || !electricClient) return;
const uid = userId;
const spaceId = searchSpaceId;
const client = electricClient; const client = electricClient;
let mounted = true; let mounted = true;
async function startSync() { async function setupElectricRealtime() {
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) { if (syncHandleRef.current) {
try { try { syncHandleRef.current.unsubscribe(); } catch { /* PGlite may be closed */ }
syncHandleRef.current.unsubscribe();
} catch {
// PGlite may already be closed during cleanup
}
syncHandleRef.current = null; syncHandleRef.current = null;
} }
if (liveQueryRef.current) {
try { liveQueryRef.current.unsubscribe?.(); } catch { /* PGlite may be closed */ }
liveQueryRef.current = null;
}
if (unreadLiveQueryRef.current) {
try { unreadLiveQueryRef.current.unsubscribe?.(); } catch { /* PGlite may be closed */ }
unreadLiveQueryRef.current = null;
}
console.log("[useInbox] Starting sync for:", userId); try {
userSyncKeyRef.current = userSyncKey; const cutoffDate = getSyncCutoffDate();
const handle = await client.syncShape({ const handle = await client.syncShape({
table: "notifications", table: "notifications",
where: `user_id = '${userId}' AND created_at > '${cutoffDate}'`, where: `user_id = '${uid}' AND created_at > '${cutoffDate}'`,
primaryKey: ["id"], 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) { if (!mounted) {
handle.unsubscribe(); handle.unsubscribe();
return; return;
} }
syncHandleRef.current = handle; syncHandleRef.current = handle;
setLoading(false);
setError(null); if (!handle.isUpToDate && handle.initialSyncPromise) {
} catch (err) { await Promise.race([
handle.initialSyncPromise,
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
}
if (!mounted) return; if (!mounted) return;
console.error("[useInbox] Sync failed:", err);
setError(err instanceof Error ? err : new Error("Sync failed"));
setLoading(false);
}
}
startSync(); const db = client.db as {
live?: {
return () => { query: <T>(
mounted = false; sql: string,
userSyncKeyRef.current = null; params?: (number | string)[]
if (syncHandleRef.current) { ) => Promise<{
try { subscribe: (cb: (result: { rows: T[] }) => void) => void;
syncHandleRef.current.unsubscribe(); unsubscribe?: () => void;
} catch { }>;
// PGlite may already be closed during cleanup };
}
syncHandleRef.current = null;
}
}; };
}, [userId, electricClient]);
// Reset when filters change if (!db.live?.query) return;
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 const itemsQuery = `SELECT * FROM notifications
useEffect(() => {
if (!userId || !electricClient) return;
const client = electricClient;
let mounted = true;
async function setupLiveQuery() {
// Clean up previous live query
if (liveQueryRef.current) {
try {
liveQueryRef.current.unsubscribe();
} catch {
// PGlite may already be closed during cleanup
}
liveQueryRef.current = null;
}
try {
const cutoff = getSyncCutoffDate();
const query = `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)
AND created_at > '${cutoff}' AND created_at > '${cutoffDate}'
${typeFilter ? "AND type = $3" : ""} ORDER BY created_at DESC`;
ORDER BY created_at DESC
LIMIT ${PAGE_SIZE}`;
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId]; const liveQuery = await db.live.query<InboxItem>(itemsQuery, [uid, spaceId]);
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<InboxItem>(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) { if (!mounted) {
liveQuery.unsubscribe?.(); liveQuery.unsubscribe?.();
return; return;
} }
if (liveQuery.subscribe) {
// Live query data comes from PGlite - no validation needed
liveQuery.subscribe((result: { rows: InboxItem[] }) => { liveQuery.subscribe((result: { rows: InboxItem[] }) => {
if (mounted && result.rows) { if (!mounted || !result.rows || !initialLoadDoneRef.current) return;
const liveItems = result.rows;
const validItems = result.rows.filter((item) => item.id != null && item.title != null);
const isFullySynced = syncHandleRef.current?.isUpToDate ?? false;
const cutoff = new Date(getSyncCutoffDate());
// Build a Map for O(1) lookups instead of .find() inside .map()
const liveItemMap = new Map(validItems.map((d) => [d.id, d]));
const liveIds = new Set(liveItemMap.keys());
setInboxItems((prev) => { setInboxItems((prev) => {
const liveItemIds = new Set(liveItems.map((item) => item.id)); const prevIds = new Set(prev.map((d) => d.id));
// FIXED: Keep ALL items not in live result (not just slice) if (electricBaselineIdsRef.current === null) {
// This prevents data loss when new notifications push items electricBaselineIdsRef.current = new Set(liveIds);
// out of the LIMIT window
const itemsToKeep = prev.filter((item) => !liveItemIds.has(item.id));
return deduplicateAndSort([...liveItems, ...itemsToKeep]);
});
} }
const baseline = electricBaselineIdsRef.current;
const newItems = validItems
.filter((item) => {
if (prevIds.has(item.id)) return false;
if (baseline.has(item.id)) return false;
return true;
});
for (const item of newItems) {
baseline.add(item.id);
}
let updated = prev.map((item) => {
const liveItem = liveItemMap.get(item.id);
if (liveItem) return liveItem;
return item;
});
if (isFullySynced) {
updated = updated.filter((item) => {
if (new Date(item.created_at) < cutoff) return true;
return liveIds.has(item.id);
}); });
} }
if (liveQuery.unsubscribe) { if (newItems.length > 0) {
return [...newItems, ...updated];
}
return updated;
});
});
liveQueryRef.current = liveQuery; liveQueryRef.current = liveQuery;
}
}
} catch (err) {
console.error("[useInbox] Live query error:", err);
}
}
setupLiveQuery(); // Unread count live query — only covers the sync window.
// Combined with olderUnreadOffsetRef to produce the full count.
return () => { const countQuery = `SELECT COUNT(*) as count FROM notifications
mounted = false;
if (liveQueryRef.current) {
try {
liveQueryRef.current.unsubscribe();
} catch {
// PGlite may already be closed during cleanup
}
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",
typeFilter ? `for type: ${typeFilter}` : "for all types"
);
const serverCounts = await notificationsApiService.getUnreadCount(
searchSpaceId ?? undefined,
typeFilter ?? 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 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 created_at > '${cutoff}' AND created_at > '${cutoffDate}'
AND read = false AND read = false`;
${typeFilter ? "AND type = $3" : ""}
`;
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
if (db.live?.query) { const countLiveQuery = await db.live.query<{ count: number | string }>(countQuery, [uid, spaceId]);
const liveQuery = await db.live.query(countQuery, params);
if (!mounted) { if (!mounted) {
liveQuery.unsubscribe?.(); countLiveQuery.unsubscribe?.();
return; return;
} }
if (liveQuery.subscribe) { countLiveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => {
liveQuery.subscribe((result: { rows: Array<{ count: number | string }> }) => { if (!mounted || !result.rows?.[0] || !initialLoadDoneRef.current) return;
if (mounted && result.rows?.[0]) { const liveRecentUnread = Number(result.rows[0].count) || 0;
const liveCount = Number(result.rows[0].count) || 0;
// Update recent count from live query // First callback: compute how many unread are outside the sync window
// This fires in real-time when Electric syncs new/updated notifications if (olderUnreadOffsetRef.current === null) {
setRecentUnreadCount(liveCount); olderUnreadOffsetRef.current = Math.max(
0,
apiUnreadTotalRef.current - liveRecentUnread
);
} }
setUnreadCount(olderUnreadOffsetRef.current + liveRecentUnread);
}); });
}
if (liveQuery.unsubscribe) { unreadLiveQueryRef.current = countLiveQuery;
unreadCountLiveQueryRef.current = liveQuery;
}
}
} catch (err) { } catch (err) {
console.error("[useInbox] Unread count sync error:", err); console.error("[useInbox] Electric setup failed:", err);
// On error, counts will remain at 0 or previous values
// The items-based count will be the fallback
} }
} }
setupUnreadCountSync(); setupElectricRealtime();
return () => { return () => {
mounted = false; mounted = false;
if (unreadCountLiveQueryRef.current) { if (syncHandleRef.current) {
unreadCountLiveQueryRef.current.unsubscribe(); try { syncHandleRef.current.unsubscribe(); } catch { /* PGlite may be closed */ }
unreadCountLiveQueryRef.current = null; syncHandleRef.current = null;
}
if (liveQueryRef.current) {
try { liveQueryRef.current.unsubscribe?.(); } catch { /* PGlite may be closed */ }
liveQueryRef.current = null;
}
if (unreadLiveQueryRef.current) {
try { unreadLiveQueryRef.current.unsubscribe?.(); } catch { /* PGlite may be closed */ }
unreadLiveQueryRef.current = null;
} }
}; };
}, [userId, searchSpaceId, typeFilter, electricClient]); }, [userId, searchSpaceId, electricClient]);
// loadMore - Pure cursor-based pagination, no race conditions // Load more pages via API (cursor-based using before_date)
// Cursor is computed from current state, not stored in refs
const loadMore = useCallback(async () => { const loadMore = useCallback(async () => {
// Removed inboxItems.length === 0 check to allow loading older items if (loadingMore || !hasMore || !userId || !searchSpaceId) return;
// when Electric returns 0 items
if (!userId || loadingMore || !hasMore) return;
setLoadingMore(true); setLoadingMore(true);
try { try {
// Cursor is computed from current state - no stale refs possible
const oldestItem = inboxItems.length > 0 ? inboxItems[inboxItems.length - 1] : null; const oldestItem = inboxItems.length > 0 ? inboxItems[inboxItems.length - 1] : null;
const beforeDate = oldestItem ? toISOString(oldestItem.created_at) : null; const beforeDate = oldestItem?.created_at ?? undefined;
console.log("[useInbox] Loading more, before:", beforeDate ?? "none (initial)"); const response = await notificationsApiService.getNotifications({
// Use the API service with proper Zod validation
const data = await notificationsApiService.getNotifications({
queryParams: { queryParams: {
search_space_id: searchSpaceId ?? undefined, search_space_id: searchSpaceId,
type: typeFilter ?? undefined, before_date: beforeDate,
before_date: beforeDate ?? undefined, limit: SCROLL_PAGE_SIZE,
limit: PAGE_SIZE,
}, },
}); });
if (data.items.length > 0) { const newItems = response.items;
// 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 setInboxItems((prev) => {
setHasMore(data.has_more); const existingIds = new Set(prev.map((d) => d.id));
const deduped = newItems.filter((d) => !existingIds.has(d.id));
return [...prev, ...deduped];
});
setHasMore(response.has_more);
} catch (err) { } catch (err) {
console.error("[useInbox] Load more failed:", err); console.error("[useInbox] Load more failed:", err);
} finally { } finally {
setLoadingMore(false); setLoadingMore(false);
} }
}, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]); }, [loadingMore, hasMore, userId, searchSpaceId, inboxItems]);
// Mark inbox item as read with optimistic update // Mark single item as read with optimistic update
// Handles both recent items (live query updates count) and older items (manual count decrement)
const markAsRead = useCallback( const markAsRead = useCallback(
async (itemId: number) => { async (itemId: number) => {
// Find the item to check if it's older than sync window
const item = inboxItems.find((i) => i.id === itemId); const item = inboxItems.find((i) => i.id === itemId);
const isOlderItem = item && !item.read && isOlderThanSyncWindow(item.created_at); if (!item || item.read) return true;
const cutoff = new Date(getSyncCutoffDate());
const isOlderItem = new Date(item.created_at) < cutoff;
// Optimistic update: mark as read immediately for instant UI feedback
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: true } : i))); setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: true } : i)));
setUnreadCount((prev) => Math.max(0, prev - 1));
// If older item, manually decrement older count // Adjust older offset so the next live query callback stays consistent
// (live query won't see items outside sync window) if (isOlderItem && olderUnreadOffsetRef.current !== null) {
if (isOlderItem) { olderUnreadOffsetRef.current = Math.max(0, olderUnreadOffsetRef.current - 1);
setOlderUnreadCount((prev) => Math.max(0, prev - 1));
} }
try { try {
// Use the API service with proper Zod validation
const result = await notificationsApiService.markAsRead({ notificationId: itemId }); const result = await notificationsApiService.markAsRead({ notificationId: itemId });
if (!result.success) { if (!result.success) {
// Rollback on error
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i))); setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
if (isOlderItem) { setUnreadCount((prev) => prev + 1);
setOlderUnreadCount((prev) => prev + 1); if (isOlderItem && olderUnreadOffsetRef.current !== null) {
olderUnreadOffsetRef.current += 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; return result.success;
} catch (err) { } catch (err) {
console.error("Failed to mark as read:", err); console.error("Failed to mark as read:", err);
// Rollback on error
setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i))); setInboxItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, read: false } : i)));
if (isOlderItem) { setUnreadCount((prev) => prev + 1);
setOlderUnreadCount((prev) => prev + 1); if (isOlderItem && olderUnreadOffsetRef.current !== null) {
olderUnreadOffsetRef.current += 1;
} }
return false; return false;
} }
@ -499,49 +369,39 @@ export function useInbox(
[inboxItems] [inboxItems]
); );
// Mark all inbox items as read with optimistic update // Mark all as read with optimistic update
// Resets both older and recent counts to 0
const markAllAsRead = useCallback(async () => { const markAllAsRead = useCallback(async () => {
// Store previous counts for potential rollback const prevCount = unreadCount;
const prevOlderCount = olderUnreadCount; const prevOffset = olderUnreadOffsetRef.current;
const prevRecentCount = recentUnreadCount;
// Optimistic update: mark all as read immediately for instant UI feedback
setInboxItems((prev) => prev.map((item) => ({ ...item, read: true }))); setInboxItems((prev) => prev.map((item) => ({ ...item, read: true })));
setOlderUnreadCount(0); setUnreadCount(0);
setRecentUnreadCount(0); olderUnreadOffsetRef.current = 0;
try { try {
// Use the API service with proper Zod validation
const result = await notificationsApiService.markAllAsRead(); const result = await notificationsApiService.markAllAsRead();
if (!result.success) { if (!result.success) {
console.error("Failed to mark all as read"); setUnreadCount(prevCount);
// Rollback counts on error olderUnreadOffsetRef.current = prevOffset;
setOlderUnreadCount(prevOlderCount);
setRecentUnreadCount(prevRecentCount);
} }
// Electric SQL will sync and live query will ensure consistency
return result.success; return result.success;
} catch (err) { } catch (err) {
console.error("Failed to mark all as read:", err); console.error("Failed to mark all as read:", err);
// Rollback counts on error setUnreadCount(prevCount);
setOlderUnreadCount(prevOlderCount); olderUnreadOffsetRef.current = prevOffset;
setRecentUnreadCount(prevRecentCount);
return false; return false;
} }
}, [olderUnreadCount, recentUnreadCount]); }, [unreadCount]);
return { return {
inboxItems, inboxItems,
unreadCount: totalUnreadCount, unreadCount,
markAsRead, markAsRead,
markAllAsRead, markAllAsRead,
loading, loading,
loadingMore, loadingMore,
hasMore, hasMore,
loadMore, loadMore,
isUsingApiFallback: true, // Always use API for pagination
error, error,
}; };
} }

View file

@ -98,9 +98,5 @@ export const cacheKeys = {
["notifications", "search", searchSpaceId, search, tab] as const, ["notifications", "search", searchSpaceId, search, tab] as const,
sourceTypes: (searchSpaceId: number | null) => sourceTypes: (searchSpaceId: number | null) =>
["notifications", "source-types", searchSpaceId] as const, ["notifications", "source-types", searchSpaceId] as const,
bySourceType: (searchSpaceId: number | null, sourceType: string) =>
["notifications", "by-source-type", searchSpaceId, sourceType] as const,
byFilter: (searchSpaceId: number | null, filter: string) =>
["notifications", "by-filter", searchSpaceId, filter] as const,
}, },
}; };