mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
feat: add search functionality to notifications and update related components
This commit is contained in:
parent
e01717c65d
commit
8c3b65bac2
6 changed files with 148 additions and 77 deletions
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
AlertCircle,
|
||||
|
|
@ -14,12 +15,13 @@ import {
|
|||
Inbox,
|
||||
LayoutGrid,
|
||||
ListFilter,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||
|
|
@ -52,7 +54,10 @@ import {
|
|||
isPageLimitExceededMetadata,
|
||||
} from "@/contracts/types/inbox.types";
|
||||
import type { InboxItem } from "@/hooks/use-inbox";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSidebarContextSafe } from "../../hooks";
|
||||
|
||||
|
|
@ -179,7 +184,9 @@ export function InboxSidebar({
|
|||
}: InboxSidebarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
|
||||
|
||||
// Comments collapsed state (desktop only, when docked)
|
||||
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
|
||||
|
|
@ -187,12 +194,22 @@ export function InboxSidebar({
|
|||
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(searchQuery, 300);
|
||||
const isSearchMode = !!debouncedSearch.trim();
|
||||
const [activeTab, setActiveTab] = useState<InboxTab>("comments");
|
||||
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
||||
const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
// Dropdown state for filter menu (desktop only)
|
||||
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
|
||||
// Scroll shadow state for connector list
|
||||
const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const handleConnectorScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}, []);
|
||||
// Drawer state for filter menu (mobile only)
|
||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||
|
|
@ -200,6 +217,24 @@ export function InboxSidebar({
|
|||
// Prefetch trigger ref - placed on item near the end
|
||||
const prefetchTriggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Server-side search query (enabled only when user is typing a search)
|
||||
// Determines which notification types to search based on active tab
|
||||
const searchTypeFilter = activeTab === "comments" ? "new_mention" as const : undefined;
|
||||
const { data: searchResponse, isLoading: isSearchLoading } = useQuery({
|
||||
queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab),
|
||||
queryFn: () =>
|
||||
notificationsApiService.getNotifications({
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId ?? undefined,
|
||||
type: searchTypeFilter,
|
||||
search: debouncedSearch.trim(),
|
||||
limit: 50,
|
||||
},
|
||||
}),
|
||||
staleTime: 30 * 1000, // 30 seconds (search results don't need to be super fresh)
|
||||
enabled: isSearchMode && open,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
|
@ -234,17 +269,11 @@ export function InboxSidebar({
|
|||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Both tabs now derive items from status (all types), so use status for pagination
|
||||
const { loading, loadingMore = false, hasMore = false, loadMore } = status;
|
||||
// Each tab uses its own data source for independent pagination
|
||||
// Comments tab: uses mentions data source (fetches only mention/reply types from server)
|
||||
const commentsItems = mentions.items;
|
||||
|
||||
// Comments tab: mentions and comment replies
|
||||
const commentsItems = useMemo(
|
||||
() =>
|
||||
status.items.filter((item) => item.type === "new_mention" || item.type === "comment_reply"),
|
||||
[status.items]
|
||||
);
|
||||
|
||||
// Status tab: connector indexing, document processing, page limit exceeded, connector deletion
|
||||
// Status tab: filters status data source (fetches all types) to status-specific types
|
||||
const statusItems = useMemo(
|
||||
() =>
|
||||
status.items.filter(
|
||||
|
|
@ -257,6 +286,12 @@ export function InboxSidebar({
|
|||
[status.items]
|
||||
);
|
||||
|
||||
// Pagination switches based on active tab
|
||||
const loading = activeTab === "comments" ? mentions.loading : status.loading;
|
||||
const loadingMore = 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;
|
||||
|
||||
// Get unique connector types from status items for filtering
|
||||
const uniqueConnectorTypes = useMemo(() => {
|
||||
const connectorTypes = new Set<string>();
|
||||
|
|
@ -279,9 +314,25 @@ export function InboxSidebar({
|
|||
// Get items for current tab
|
||||
const displayItems = activeTab === "comments" ? commentsItems : statusItems;
|
||||
|
||||
// Filter items based on filter type, connector filter, and search query
|
||||
// Filter items based on filter type, connector filter, and search mode
|
||||
// When searching: use server-side API results (searches ALL notifications)
|
||||
// When not searching: use Electric real-time items (fast, local)
|
||||
const filteredItems = useMemo(() => {
|
||||
let items = displayItems;
|
||||
// In search mode, use API results
|
||||
let items: InboxItem[] = isSearchMode
|
||||
? (searchResponse?.items ?? [])
|
||||
: displayItems;
|
||||
|
||||
// For status tab search results, filter to status-specific types
|
||||
if (isSearchMode && activeTab === "status") {
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.type === "connector_indexing" ||
|
||||
item.type === "document_processing" ||
|
||||
item.type === "page_limit_exceeded" ||
|
||||
item.type === "connector_deletion"
|
||||
);
|
||||
}
|
||||
|
||||
// Apply read/unread filter
|
||||
if (activeFilter === "unread") {
|
||||
|
|
@ -302,22 +353,14 @@ export function InboxSidebar({
|
|||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
}, [displayItems, activeFilter, activeTab, selectedConnector, searchQuery]);
|
||||
}, [displayItems, searchResponse, isSearchMode, activeFilter, activeTab, selectedConnector]);
|
||||
|
||||
// Intersection Observer for infinite scroll with prefetching
|
||||
// Only active when not searching (search results are client-side filtered)
|
||||
// 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(() => {
|
||||
if (!loadMore || !hasMore || loadingMore || !open || searchQuery.trim()) return;
|
||||
if (!loadMore || !hasMore || loadingMore || !open || isSearchMode) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
|
|
@ -338,17 +381,11 @@ export function InboxSidebar({
|
|||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, hasMore, loadingMore, open, searchQuery]);
|
||||
}, [loadMore, hasMore, loadingMore, open, isSearchMode, activeTab]);
|
||||
|
||||
// Unread counts derived from filtered items
|
||||
const unreadCommentsCount = useMemo(
|
||||
() => commentsItems.filter((item) => !item.read).length,
|
||||
[commentsItems]
|
||||
);
|
||||
const unreadStatusCount = useMemo(
|
||||
() => statusItems.filter((item) => !item.read).length,
|
||||
[statusItems]
|
||||
);
|
||||
// Unread counts from server-side accurate totals (passed via props)
|
||||
const unreadCommentsCount = mentions.unreadCount;
|
||||
const unreadStatusCount = status.unreadCount;
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
async (item: InboxItem) => {
|
||||
|
|
@ -725,29 +762,38 @@ export function InboxSidebar({
|
|||
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
|
||||
{t("connectors") || "Connectors"}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setSelectedConnector(null)}
|
||||
className="flex items-center justify-between"
|
||||
<div
|
||||
className="relative max-h-[30vh] overflow-y-auto -mb-1"
|
||||
onScroll={handleConnectorScroll}
|
||||
style={{
|
||||
maskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${connectorScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${connectorScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
<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" />}
|
||||
</DropdownMenuItem>
|
||||
{uniqueConnectorTypes.map((connector) => (
|
||||
<DropdownMenuItem
|
||||
key={connector.type}
|
||||
onClick={() => setSelectedConnector(connector.type)}
|
||||
onClick={() => setSelectedConnector(null)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||
<span>{connector.displayName}</span>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span>{t("all_connectors") || "All connectors"}</span>
|
||||
</span>
|
||||
{selectedConnector === connector.type && <Check className="h-4 w-4" />}
|
||||
{selectedConnector === null && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{uniqueConnectorTypes.map((connector) => (
|
||||
<DropdownMenuItem
|
||||
key={connector.type}
|
||||
onClick={() => setSelectedConnector(connector.type)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||
<span>{connector.displayName}</span>
|
||||
</span>
|
||||
{selectedConnector === connector.type && <Check className="h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
|
@ -880,18 +926,22 @@ export function InboxSidebar({
|
|||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
{(isSearchMode ? isSearchLoading : loading) ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
{isSearchMode ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredItems.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{filteredItems.map((item, index) => {
|
||||
const isMarkingAsRead = markingAsReadId === item.id;
|
||||
// Place prefetch trigger on 5th item from end (only if not searching)
|
||||
const isPrefetchTrigger =
|
||||
!searchQuery && hasMore && index === filteredItems.length - 5;
|
||||
)}
|
||||
</div>
|
||||
) : filteredItems.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{filteredItems.map((item, index) => {
|
||||
const isMarkingAsRead = markingAsReadId === item.id;
|
||||
// Place prefetch trigger on 5th item from end (only when not searching)
|
||||
const isPrefetchTrigger =
|
||||
!isSearchMode && hasMore && index === filteredItems.length - 5;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -946,21 +996,21 @@ export function InboxSidebar({
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Fallback trigger at the very end if less than 5 items and not searching */}
|
||||
{!searchQuery && filteredItems.length < 5 && hasMore && (
|
||||
<div ref={prefetchTriggerRef} className="h-1" />
|
||||
)}
|
||||
</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>
|
||||
{/* Fallback trigger at the very end if less than 5 items and not searching */}
|
||||
{!isSearchMode && filteredItems.length < 5 && hasMore && (
|
||||
<div ref={prefetchTriggerRef} className="h-1" />
|
||||
)}
|
||||
</div>
|
||||
) : isSearchMode ? (
|
||||
<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 === "comments" ? (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue