refactor: improve InboxSidebar and useInbox hook

- Added LayoutGrid icon to the "All connectors" option in the InboxSidebar for better visual representation.
- Simplified the return statement in getConnectorTypeDisplayName for improved readability.
- Refactored filter and drawer components in InboxSidebar for cleaner code structure.
- Removed unused totalUnreadCount state from useInbox hook and replaced it with a memoized calculation for better performance.
- Implemented optimistic updates for marking inbox items as read, enhancing user experience with immediate feedback.
This commit is contained in:
Anish Sarkar 2026-01-22 17:46:44 +05:30
parent be7ba76417
commit c98cfac49f
2 changed files with 89 additions and 101 deletions

View file

@ -9,6 +9,7 @@ import {
CheckCircle2, CheckCircle2,
History, History,
Inbox, Inbox,
LayoutGrid,
ListFilter, ListFilter,
Search, Search,
X, X,
@ -95,7 +96,13 @@ function getConnectorTypeDisplayName(connectorType: string): string {
BAIDU_SEARCH_API: "Baidu", BAIDU_SEARCH_API: "Baidu",
}; };
return displayNames[connectorType] || connectorType.replace(/_/g, " ").replace(/CONNECTOR|API/gi, "").trim(); return (
displayNames[connectorType] ||
connectorType
.replace(/_/g, " ")
.replace(/CONNECTOR|API/gi, "")
.trim()
);
} }
type InboxTab = "mentions" | "status"; type InboxTab = "mentions" | "status";
@ -239,8 +246,7 @@ export function InboxSidebar({
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
items = items.filter( items = items.filter(
(item) => (item) =>
item.title.toLowerCase().includes(query) || item.title.toLowerCase().includes(query) || item.message.toLowerCase().includes(query)
item.message.toLowerCase().includes(query)
); );
} }
@ -453,15 +459,14 @@ export function InboxSidebar({
<span className="sr-only">{t("filter") || "Filter"}</span> <span className="sr-only">{t("filter") || "Filter"}</span>
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="z-80"> <TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
{t("filter") || "Filter"}
</TooltipContent>
</Tooltip> </Tooltip>
<Drawer open={filterDrawerOpen} onOpenChange={setFilterDrawerOpen} shouldScaleBackground={false}> <Drawer
<DrawerContent open={filterDrawerOpen}
className="max-h-[70vh] z-80" onOpenChange={setFilterDrawerOpen}
overlayClassName="z-80" shouldScaleBackground={false}
> >
<DrawerContent className="max-h-[70vh] z-80" overlayClassName="z-80">
<DrawerHandle /> <DrawerHandle />
<DrawerHeader className="px-4 pb-3 pt-2"> <DrawerHeader className="px-4 pb-3 pt-2">
<DrawerTitle className="flex items-center gap-2 text-base font-semibold"> <DrawerTitle className="flex items-center gap-2 text-base font-semibold">
@ -484,7 +489,9 @@ export function InboxSidebar({
}} }}
className={cn( className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
activeFilter === "all" ? "bg-primary/10 text-primary" : "hover:bg-muted" activeFilter === "all"
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)} )}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@ -501,7 +508,9 @@ export function InboxSidebar({
}} }}
className={cn( className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
activeFilter === "unread" ? "bg-primary/10 text-primary" : "hover:bg-muted" activeFilter === "unread"
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)} )}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@ -527,10 +536,15 @@ export function InboxSidebar({
}} }}
className={cn( className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
selectedConnector === null ? "bg-primary/10 text-primary" : "hover:bg-muted" selectedConnector === null
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)} )}
> >
<span>{t("all_connectors") || "All connectors"}</span> <span className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
<span>{t("all_connectors") || "All connectors"}</span>
</span>
{selectedConnector === null && <Check className="h-4 w-4" />} {selectedConnector === null && <Check className="h-4 w-4" />}
</button> </button>
{uniqueConnectorTypes.map((connector) => ( {uniqueConnectorTypes.map((connector) => (
@ -543,14 +557,18 @@ export function InboxSidebar({
}} }}
className={cn( className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors", "flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
selectedConnector === connector.type ? "bg-primary/10 text-primary" : "hover:bg-muted" selectedConnector === connector.type
? "bg-primary/10 text-primary"
: "hover:bg-muted"
)} )}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{getConnectorIcon(connector.type, "h-4 w-4")} {getConnectorIcon(connector.type, "h-4 w-4")}
<span>{connector.displayName}</span> <span>{connector.displayName}</span>
</span> </span>
{selectedConnector === connector.type && <Check className="h-4 w-4" />} {selectedConnector === connector.type && (
<Check className="h-4 w-4" />
)}
</button> </button>
))} ))}
</div> </div>
@ -569,21 +587,18 @@ export function InboxSidebar({
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
>
<ListFilter className="h-4 w-4 text-muted-foreground" /> <ListFilter className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">{t("filter") || "Filter"}</span> <span className="sr-only">{t("filter") || "Filter"}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="z-80"> <TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
{t("filter") || "Filter"}
</TooltipContent>
</Tooltip> </Tooltip>
<DropdownMenuContent align="end" className={cn("z-80", activeTab === "status" ? "w-52" : "w-44")}> <DropdownMenuContent
align="end"
className={cn("z-80", activeTab === "status" ? "w-52" : "w-44")}
>
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal"> <DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
{t("filter") || "Filter"} {t("filter") || "Filter"}
</DropdownMenuLabel> </DropdownMenuLabel>
@ -616,7 +631,10 @@ export function InboxSidebar({
onClick={() => setSelectedConnector(null)} onClick={() => setSelectedConnector(null)}
className="flex items-center justify-between" className="flex items-center justify-between"
> >
<span>{t("all_connectors") || "All connectors"}</span> <span className="flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
<span>{t("all_connectors") || "All connectors"}</span>
</span>
{selectedConnector === null && <Check className="h-4 w-4" />} {selectedConnector === null && <Check className="h-4 w-4" />}
</DropdownMenuItem> </DropdownMenuItem>
{uniqueConnectorTypes.map((connector) => ( {uniqueConnectorTypes.map((connector) => (
@ -629,7 +647,9 @@ export function InboxSidebar({
{getConnectorIcon(connector.type, "h-4 w-4")} {getConnectorIcon(connector.type, "h-4 w-4")}
<span>{connector.displayName}</span> <span>{connector.displayName}</span>
</span> </span>
{selectedConnector === connector.type && <Check className="h-4 w-4" />} {selectedConnector === connector.type && (
<Check className="h-4 w-4" />
)}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</> </>
@ -723,7 +743,8 @@ export function InboxSidebar({
{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 if not searching) // Place prefetch trigger on 5th item from end (only if not searching)
const isPrefetchTrigger = !searchQuery && hasMore && index === filteredItems.length - 5; const isPrefetchTrigger =
!searchQuery && hasMore && index === filteredItems.length - 5;
return ( return (
<div <div
@ -802,9 +823,7 @@ export function InboxSidebar({
) : ( ) : (
<History 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"> <p className="text-sm text-muted-foreground">{getEmptyStateMessage().title}</p>
{getEmptyStateMessage().title}
</p>
<p className="text-xs text-muted-foreground/70 mt-1"> <p className="text-xs text-muted-foreground/70 mt-1">
{getEmptyStateMessage().hint} {getEmptyStateMessage().hint}
</p> </p>

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { InboxItem, InboxItemTypeEnum } from "@/contracts/types/inbox.types"; import 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";
@ -79,7 +79,6 @@ export function useInbox(
const electricClient = useElectricClient(); const electricClient = useElectricClient();
const [inboxItems, setInboxItems] = useState<InboxItem[]>([]); const [inboxItems, setInboxItems] = useState<InboxItem[]>([]);
const [totalUnreadCount, setTotalUnreadCount] = useState(0);
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(true);
@ -87,9 +86,14 @@ export function useInbox(
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 unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
const userSyncKeyRef = useRef<string | null>(null); const userSyncKeyRef = useRef<string | null>(null);
// Calculate unread count from inboxItems (includes both recent and older when loaded)
// This ensures the count is always in sync with what's displayed
const totalUnreadCount = useMemo(() => {
return inboxItems.filter((item) => !item.read).length;
}, [inboxItems]);
// EFFECT 1: Electric SQL sync for real-time updates // EFFECT 1: Electric SQL sync for real-time updates
useEffect(() => { useEffect(() => {
if (!userId || !electricClient) { if (!userId || !electricClient) {
@ -287,69 +291,6 @@ export function useInbox(
}; };
}, [userId, searchSpaceId, typeFilter, electricClient]); }, [userId, searchSpaceId, typeFilter, electricClient]);
// EFFECT 3: Unread count with live updates
useEffect(() => {
if (!userId || !electricClient) return;
const client = electricClient;
let mounted = true;
async function updateUnreadCount() {
if (unreadCountLiveQueryRef.current) {
unreadCountLiveQueryRef.current.unsubscribe();
unreadCountLiveQueryRef.current = null;
}
try {
const cutoff = getSyncCutoffDate();
const query = `SELECT COUNT(*) as count FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)
AND read = false
AND created_at > '${cutoff}'`;
const result = await client.db.query<{ count: number }>(query, [userId, searchSpaceId]);
if (mounted && result.rows?.[0]) {
setTotalUnreadCount(Number(result.rows[0].count) || 0);
}
const db = client.db as any;
if (db.live?.query) {
const liveQuery = await db.live.query(query, [userId, searchSpaceId]);
if (!mounted) {
liveQuery.unsubscribe?.();
return;
}
if (liveQuery.subscribe) {
liveQuery.subscribe((result: { rows: { count: number }[] }) => {
if (mounted && result.rows?.[0]) {
setTotalUnreadCount(Number(result.rows[0].count) || 0);
}
});
}
if (liveQuery.unsubscribe) {
unreadCountLiveQueryRef.current = liveQuery;
}
}
} catch (err) {
console.error("[useInbox] Unread count error:", err);
}
}
updateUnreadCount();
return () => {
mounted = false;
if (unreadCountLiveQueryRef.current) {
unreadCountLiveQueryRef.current.unsubscribe();
unreadCountLiveQueryRef.current = null;
}
};
}, [userId, searchSpaceId, electricClient]);
// loadMore - Pure cursor-based pagination, no race conditions // loadMore - Pure cursor-based pagination, no race conditions
// Cursor is computed from current state, not stored in refs // Cursor is computed from current state, not stored in refs
const loadMore = useCallback(async () => { const loadMore = useCallback(async () => {
@ -408,30 +349,58 @@ export function useInbox(
} }
}, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]); }, [userId, searchSpaceId, typeFilter, loadingMore, hasMore, inboxItems]);
// Mark inbox item as read // Mark inbox item as read with optimistic update
const markAsRead = useCallback(async (itemId: number) => { const markAsRead = useCallback(async (itemId: number) => {
// Optimistic update: mark as read immediately for instant UI feedback
setInboxItems((prev) =>
prev.map((item) => (item.id === itemId ? { ...item, read: true } : item))
);
try { try {
const response = await authenticatedFetch( const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/read`, `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/notifications/${itemId}/read`,
{ method: "PATCH" } { method: "PATCH" }
); );
if (!response.ok) {
// Rollback on error
setInboxItems((prev) =>
prev.map((item) => (item.id === itemId ? { ...item, read: false } : item))
);
}
// If successful, Electric SQL will sync the change and live query will update
// This ensures eventual consistency even if optimistic update was wrong
return response.ok; return response.ok;
} 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((item) => (item.id === itemId ? { ...item, read: false } : item))
);
return false; return false;
} }
}, []); }, []);
// Mark all inbox items as read // Mark all inbox items as read with optimistic update
const markAllAsRead = useCallback(async () => { const markAllAsRead = useCallback(async () => {
// Optimistic update: mark all as read immediately for instant UI feedback
setInboxItems((prev) => prev.map((item) => ({ ...item, read: true })));
try { try {
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" }
); );
if (!response.ok) {
console.error("Failed to mark all as read");
// On error, let Electric SQL sync correct the state
}
// Electric SQL will sync and live query will ensure consistency
return response.ok; return response.ok;
} catch (err) { } catch (err) {
console.error("Failed to mark all as read:", err); console.error("Failed to mark all as read:", err);
// On error, let Electric SQL sync correct the state
return false; return false;
} }
}, []); }, []);