"use client" import { useAuth } from "@lib/auth-context" import { $fetch } from "@lib/api" import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" import { useInfiniteQuery, useQuery } from "@tanstack/react-query" import { useCallback, memo, useMemo, useState, useRef, useEffect } from "react" import { useQueryState } from "nuqs" import type { z } from "zod" import { Masonry, useInfiniteLoader } from "masonic" import { dmSansClassName } from "@/lib/fonts" import { ErrorBoundary } from "@/components/error-boundary" import { cn } from "@lib/utils" import { useProject } from "@/stores" import { useIsMobile } from "@hooks/use-mobile" import type { Tweet } from "react-tweet/api" import { TweetPreview } from "./document-cards/tweet-preview" import { WebsitePreview } from "./document-cards/website-preview" import { GoogleDocsPreview } from "./document-cards/google-docs-preview" import { FilePreview } from "./document-cards/file-preview" import { NotePreview } from "./document-cards/note-preview" import { YoutubePreview } from "./document-cards/youtube-preview" import { getAbsoluteUrl, isYouTubeUrl, useYouTubeChannelName } from "./utils" import { SyncLogoIcon } from "@ui/assets/icons" import { McpPreview } from "./document-cards/mcp-preview" import { NotionPreview } from "./document-cards/notion-preview" import { getFaviconUrl } from "@/lib/url-helpers" import { QuickNoteCard } from "./quick-note-card" import { HighlightsCard, type HighlightItem } from "./highlights-card" import { GraphCard } from "./memory-graph" import { Button } from "@ui/components/button" import { categoriesParam, type IntegrationParamValue, } from "@/lib/search-params" import { NovaEmptyState } from "@/components/nova/nova-empty-state" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@ui/components/alert-dialog" import { CheckIcon, Loader, Trash2Icon, XIcon } from "lucide-react" // Document category type type DocumentCategory = | "webpage" | "tweet" | "google_drive" | "notion" | "onedrive" | "files" | "notes" | "mcp" type DocumentFacet = { category: DocumentCategory count: number label: string } type FacetsResponse = { facets: DocumentFacet[] total: number } type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] type OgData = { title?: string image?: string } const ogCache = new Map() const ogInflight = new Map>() const ogFailures = new Map() const OG_FAILURE_TTL = 30_000 function fetchOgData(url: string): Promise { const cached = ogCache.get(url) if (cached) return Promise.resolve(cached) const failedAt = ogFailures.get(url) if (failedAt && Date.now() - failedAt < OG_FAILURE_TTL) { return Promise.resolve(null) } const inflight = ogInflight.get(url) if (inflight) return inflight const promise = fetch(`/api/og?url=${encodeURIComponent(url)}`) .then((res) => { if (!res.ok) throw new Error("Failed") return res.json() }) .then((data) => { const result: OgData = { title: data?.title, image: data?.image } if (!result.title && !result.image) { throw new Error("Empty metadata") } ogCache.set(url, result) ogInflight.delete(url) ogFailures.delete(url) return result }) .catch(() => { ogInflight.delete(url) ogFailures.set(url, Date.now()) return null }) ogInflight.set(url, promise) return promise } const PAGE_SIZE = 100 const MAX_TOTAL = 1000 const MEMORIES_LOADING_LABELS = [ "Getting your supermemories…", "Fetching your documents…", "Warming up Nova…", "Almost there…", ] as const function useRotatingLoadingLabel( labels: readonly string[], intervalMs = 2400, ): string { const [index, setIndex] = useState(0) useEffect(() => { const id = window.setInterval(() => { setIndex((i) => (i + 1) % labels.length) }, intervalMs) return () => window.clearInterval(id) }, [labels.length, intervalMs]) const label = labels.at(index) ?? labels.at(0) return label ?? "Loading…" } function MemoriesGridLoading() { const label = useRotatingLoadingLabel(MEMORIES_LOADING_LABELS) return (

{label}

) } // Discriminated union for masonry items type MasonryItem = { type: "document"; id: string; data: DocumentWithMemories } interface QuickNoteProps { onSave: (content: string) => void onMaximize: (content: string) => void isSaving: boolean } interface HighlightsProps { items: HighlightItem[] onChat: (seed: string) => void onShowRelated: (query: string) => void isLoading: boolean } interface NovaEmptyStateProps { onAddMemory: (tab: "note" | "link") => void onOpenIntegrations: (integration?: IntegrationParamValue) => void isAllSpaces: boolean spaceName?: string onSwitchToAllSpaces?: () => void } interface MemoriesGridProps { isChatOpen: boolean onOpenDocument: (document: DocumentWithMemories) => void isSelectionMode?: boolean selectedDocumentIds?: Set onEnterSelectionMode?: () => void onToggleSelection?: (documentId: string) => void onClearSelection?: () => void onSelectAllVisible?: (visibleIds: string[]) => void onBulkDelete?: () => void isBulkDeleting?: boolean quickNoteProps?: QuickNoteProps highlightsProps?: HighlightsProps emptyStateProps?: NovaEmptyStateProps } export function MemoriesGrid({ isChatOpen, onOpenDocument, isSelectionMode = false, selectedDocumentIds = new Set(), onEnterSelectionMode, onToggleSelection, onClearSelection, onSelectAllVisible, onBulkDelete, isBulkDeleting = false, quickNoteProps, highlightsProps, emptyStateProps, }: MemoriesGridProps) { const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false) const { user, isSessionPending } = useAuth() const { effectiveContainerTags } = useProject() const isMobile = useIsMobile() const [selectedCategories, setSelectedCategories] = useQueryState( "categories", categoriesParam, ) const selectedCategoriesSet = useMemo( () => new Set(selectedCategories), [selectedCategories], ) const { data: facetsData } = useQuery({ queryKey: ["document-facets", effectiveContainerTags], queryFn: async (): Promise => { const response = await $fetch("@post/documents/documents/facets", { body: { containerTags: effectiveContainerTags, }, disableValidation: true, }) if (response.error) { throw new Error(response.error?.message || "Failed to fetch facets") } return response.data as FacetsResponse }, staleTime: 5 * 60 * 1000, enabled: !!user, }) const { data, error, isPending, isFetchingNextPage, hasNextPage, fetchNextPage, } = useInfiniteQuery({ queryKey: [ "documents-with-memories", effectiveContainerTags, selectedCategories, ], initialPageParam: 1, queryFn: async ({ pageParam }) => { const response = await $fetch("@post/documents/documents", { body: { page: pageParam as number, limit: PAGE_SIZE, sort: "createdAt", order: "desc", containerTags: effectiveContainerTags, categories: selectedCategories.length > 0 ? selectedCategories : undefined, }, disableValidation: true, }) if (response.error) { throw new Error(response.error?.message || "Failed to fetch documents") } return response.data }, getNextPageParam: (lastPage, allPages) => { const loaded = allPages.reduce( (acc, p) => acc + (p.documents?.length ?? 0), 0, ) if (loaded >= MAX_TOTAL) return undefined const { currentPage, totalPages } = lastPage.pagination if (currentPage < totalPages) { return currentPage + 1 } return undefined }, staleTime: 5 * 60 * 1000, enabled: !!user, }) const handleCategoryToggle = useCallback( (category: DocumentCategory) => { setSelectedCategories((prev) => { const current = prev ?? [] if (current.includes(category)) { const next = current.filter((c) => c !== category) return next.length === 0 ? null : next } return [...current, category] }) }, [setSelectedCategories], ) const handleSelectAll = useCallback(() => { setSelectedCategories(null) }, [setSelectedCategories]) const documents = useMemo(() => { return ( data?.pages.flatMap((p: DocumentsResponse) => p.documents ?? []) ?? [] ) }, [data]) const hasQuickNote = !!quickNoteProps const hasHighlights = !!highlightsProps const masonryItems: MasonryItem[] = useMemo(() => { const items: MasonryItem[] = [] for (const doc of documents) { items.push({ type: "document", id: doc.id, data: doc }) } return items }, [documents]) // Stable key for Masonry based on document IDs, not item values const masonryKey = useMemo(() => { const docIds = documents.map((d) => d.id).join(",") return `masonry-${documents.length}-${docIds}-${isChatOpen}` }, [documents, isChatOpen]) const isLoadingMore = isFetchingNextPage const loadMoreDocuments = useCallback(async (): Promise => { if (hasNextPage && !isFetchingNextPage) { await fetchNextPage() return } return }, [hasNextPage, isFetchingNextPage, fetchNextPage]) const maybeLoadMore = useInfiniteLoader( async (_startIndex, _stopIndex, _currentItems) => { if (hasNextPage && !isFetchingNextPage) { await loadMoreDocuments() } }, { isItemLoaded: (index, items) => !!items[index], minimumBatchSize: 10, threshold: 5, }, ) const handleCardClick = useCallback( (document: DocumentWithMemories) => { if (isSelectionMode && onToggleSelection && document.id) { onToggleSelection(document.id) } else { onOpenDocument(document) } }, [isSelectionMode, onToggleSelection, onOpenDocument], ) const handleSelectAllVisible = useCallback(() => { if (onSelectAllVisible) { onSelectAllVisible(documents.map((d) => d.id).filter(Boolean) as string[]) } }, [documents, onSelectAllVisible]) const handleBulkDeleteClick = useCallback(() => { if (selectedDocumentIds.size === 0) return setShowBulkDeleteConfirm(true) }, [selectedDocumentIds.size]) const handleBulkDeleteConfirm = useCallback(() => { setShowBulkDeleteConfirm(false) onBulkDelete?.() }, [onBulkDelete]) const renderMasonryItem = useCallback( ({ index, data, width, }: { index: number data: MasonryItem width: number }) => { if (data.type === "document") { const doc = data.data return ( onToggleSelection(doc.id as string) : undefined } /> ) } return null }, [handleCardClick, isSelectionMode, selectedDocumentIds, onToggleSelection], ) if (isSessionPending) { return } if (!user) { return (

Please log in to view your memories

) } const isEmpty = documents.length === 0 && !isPending const showNovaEmptyState = isEmpty && emptyStateProps return (
{!isEmpty && (
{facetsData?.facets.map((facet: DocumentFacet) => ( ))}
{isSelectionMode && ( <> {selectedDocumentIds.size > 0 ? ( <> ) : (

Select one or more documents

)} )} {!isSelectionMode && onEnterSelectionMode && ( )}
)} Delete selected memories? This will permanently delete {selectedDocumentIds.size}{" "} {selectedDocumentIds.size === 1 ? "memory" : "memories"}. This action cannot be undone. setShowBulkDeleteConfirm(false)} > Cancel {isBulkDeleting ? "Deleting…" : "Delete"} {error ? (
Error loading documents: {error.message}
) : isPending ? ( ) : showNovaEmptyState ? ( ) : isEmpty ? (
No memories found
) : (
{!isMobile && (hasQuickNote || hasHighlights) && (
{hasQuickNote && quickNoteProps && (
)} {hasHighlights && highlightsProps && (
)}
)} {isLoadingMore && (
)}
)}
) } function DocumentUrlDisplay({ url }: { url: string }) { const isYouTube = isYouTubeUrl(url) const { data: channelName, isLoading } = useYouTubeChannelName( isYouTube ? url : null, ) if (isYouTube) { return (

{isLoading ? "YouTube" : channelName || "YouTube"}

) } return (

{getAbsoluteUrl(url)}

) } function isTemporaryId(id: string | null | undefined): boolean { if (!id) return false return id.startsWith("temp-") || id.startsWith("temp-file-") } const DocumentCard = memo( ({ index: _index, data: document, width, onClick, isSelectionMode = false, isSelected = false, onToggleSelection, }: { index: number data: DocumentWithMemories width: number onClick: (document: DocumentWithMemories) => void isSelectionMode?: boolean isSelected?: boolean onToggleSelection?: () => void }) => { const canSelect = !isTemporaryId(document.id) && !isTemporaryId(document.customId) const [rotation, setRotation] = useState({ rotateX: 0, rotateY: 0 }) const cardRef = useRef(null) const [ogData, setOgData] = useState(null) const ogImage = (document as DocumentWithMemories & { ogImage?: string }) .ogImage const needsOgData = document.url && document.type !== "notion_doc" && !document.url.includes("x.com") && !document.url.includes("twitter.com") && !document.url.includes("files.supermemory.ai") && !document.url.includes("docs.googleapis.com") && !document.url.includes("notion.so") && (!document.title || !ogImage) const hideURL = document.url?.includes("docs.googleapis.com") useEffect(() => { if (!needsOgData || ogData || !document.url) return let timeoutId: ReturnType let mounted = true const attemptFetch = () => { if (!mounted || !document.url) return fetchOgData(document.url).then((data) => { if (!mounted) return if (data) { setOgData(data) } else { // Retry when the global TTL expires timeoutId = setTimeout(attemptFetch, 30_000) } }) } attemptFetch() return () => { mounted = false clearTimeout(timeoutId) } }, [needsOgData, ogData, document.url]) useEffect(() => { if (isSelectionMode) setRotation({ rotateX: 0, rotateY: 0 }) }, [isSelectionMode]) const handleMouseMove = (e: React.MouseEvent) => { if (isSelectionMode || !cardRef.current) return const rect = cardRef.current.getBoundingClientRect() const centerX = rect.left + rect.width / 2 const centerY = rect.top + rect.height / 2 const mouseX = e.clientX - centerX const mouseY = e.clientY - centerY // Calculate rotation angles (max 15 degrees) const rotateY = (mouseX / (rect.width / 2)) * 15 const rotateX = -(mouseY / (rect.height / 2)) * 15 setRotation({ rotateX, rotateY }) } const handleMouseLeave = () => { setRotation({ rotateX: 0, rotateY: 0 }) } return (
{isSelectionMode && canSelect && ( )}
) }, ) DocumentCard.displayName = "DocumentCard" function ContentPreview({ document, ogData, }: { document: DocumentWithMemories ogData?: OgData | null }) { if ( document.url?.includes("https://docs.googleapis.com/v1/documents") || document.url?.includes("docs.google.com/document") || document.type === "google_doc" ) { return } if ( document.url?.includes("x.com/") && document.metadata?.sm_internal_twitter_metadata ) { return ( ) } if (document.source === "mcp") { return } if (isYouTubeUrl(document.url)) { return } if (document.type === "notion_doc") { return } if ( document.type === "pdf" || document.type === "image" || document.type === "video" || document.metadata?.mimeType ) { return } if (document.url?.includes("https://")) { return } // Default to Note return }