import * as ReactTweet from "react-tweet"; import { memo, useCallback, useEffect, useMemo, useState } from "react"; import { useInView } from "react-intersection-observer"; import { TweetSkeleton } from "react-tweet"; import { NotionIcon } from "../icons/IntegrationIcons"; import { CustomTwitterComp } from "../twitter/render-tweet"; import Loader from "../ui/Loader"; import { Button, ButtonProps } from "../ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "../ui/command"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "../ui/dropdown-menu"; import { FileIcon } from "@radix-ui/react-icons"; import { SpaceIcon } from "@supermemory/shared/icons"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { FastAverageColor } from "fast-average-color"; import { MenuIcon, TrashIcon } from "lucide-react"; import { toast } from "sonner"; import { pastelColors } from "~/lib/constants/pastelColors"; import { typeIcons } from "~/lib/constants/typeIcons"; import { ExtraSpaceMetaData, fetchSpaces } from "~/lib/hooks/use-spaces"; import { useTextOverflow } from "~/lib/hooks/use-text-overflow"; import { Memory, WebsiteMetadata } from "~/lib/types/memory"; import { cn } from "~/lib/utils"; const { useTweet } = ReactTweet; export const typeDecider = (content: string) => { try { // If it's a tweet URL if (content.match(/https?:\/\/(x\.com|twitter\.com)\/[\w]+\/[\w]+\/[\d]+/)) { return "tweet"; } // If it's a document if (content.match(/\.(pdf|doc|docx|txt|rtf|odt|md)/i)) { return "document"; } // If it's a Notion URL if (content.match(/https?:\/\/(www\.)?notion\.so/)) { return "notion"; } // If it's any other URL if (content.match(/^(https?:\/\/)?(www\.)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(\/.*)?$/i)) { return "page"; } // Otherwise it's a note return "note"; } catch (e) { console.error("[Decide Type Error]", e); return "note"; } }; // Create a cache to store fetched metadata const metadataCache: Record = {}; const renderContent = { tweet: ({ data: ourData }: { data: Memory }) => { // If content starts with , parse the JSON inside if (ourData.content?.startsWith("")) { const rawContent = ourData.content.replace(/<\/?raw>/g, ""); try { const parsedData = JSON.parse(rawContent); return ; } catch (error) { console.error("Error parsing raw tweet data:", error); return
Error parsing tweet data
; } } // Otherwise use normal tweet ID flow const tweetId = ourData.content?.match(/\/status\/(\d+)/)?.[1] ?? ourData.content; const { data, error } = useTweet(tweetId ?? undefined); if (error) { console.error("Error parsing tweet:", error); console.log("Tweet ID:", tweetId); return
Error parsing tweet
; } if (!data || typeof data == "undefined") { return ; } return ; }, page: ({ data }: { data: Memory }) => ( ), note: ({ data }: { data: Memory }) => { const { contentRef, showFade } = useTextOverflow(data.content ?? "", 5); // Use a constant color for id 0, otherwise select based on content length const color = useMemo( () => data.id === 0 ? pastelColors[0] : pastelColors[(data.content?.length ?? 0) % pastelColors.length], [data.content, data.id], ); const formattedDate = new Date(data.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", }); return (

{data.content}

{showFade &&
}
{formattedDate}
); }, notion: ({ data }: { data: Memory }) => { const { contentRef, showFade } = useTextOverflow(data.content ?? "", 5); const formattedDate = new Date(data.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", }); return (
Notion Page

{data.content}

{showFade &&
}
{formattedDate}
); }, space: ({ data }: { data: Memory & Partial }) => { return (
Space

{data.content}

{data.isSuccessfullyProcessed ? (
Public Space
) : (
Private Space
)} {data.favorited && (
Favorited
)}
{data.owner ? ( <>
{data.permissions?.canRead && !data.permissions.canEdit && (
Can Read
)} {data.permissions?.canEdit && (
Can Edit
)}
{data.owner.profileImage ? ( {data.owner.name} ) : (
)} {data.owner.name}
) : null}
); }, document: ({ data }: { data: Memory }) => { // just render the document like normally // and get the name from the url // TODO: This can be improved return (
Document {decodeURIComponent(data.url?.split("/").pop() ?? "")}
); }, }; async function fetchWebsiteMetadata(url: string): Promise { // Check if metadata is already in cache if (metadataCache[url]) { return metadataCache[url]; } // Add http:// if not present if (!url.startsWith("http://") && !url.startsWith("https://")) { url = "http://" + url; } try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout const response = await fetch(`/api/metadata?url=${encodeURIComponent(url)}`, { signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { throw new Error("Failed to fetch metadata"); } const metadata = (await response.json()) as WebsiteMetadata; if (metadata.image) { try { const fac = new FastAverageColor(); const color = await fac.getColorAsync(metadata.image, { algorithm: "dominant", crossOrigin: "anonymous", mode: "speed", }); metadata.dominantColor = color.hex; metadata.isDark = color.isDark; } catch (error) { console.error("Error getting dominant color:", error); // Fallback to a default color if there's an error metadata.dominantColor = "#CFCFCF"; metadata.isDark = false; } } else { metadata.dominantColor = "#CFCFCF"; metadata.isDark = false; } // Store the fetched metadata in cache metadataCache[url] = metadata; return metadata; } catch (error) { console.error("Error fetching metadata:", error); return { title: "Unknown", description: "No description available", image: "", dominantColor: "#CFCFCF", isDark: false, }; } } const WebsiteCard = memo( ({ url, title, description, image, }: { url: string; title?: string | null; description?: string | null; image?: string | null; }) => { // Memoize domain extraction to avoid recalculation const domain = useMemo(() => { try { let formattedUrl = url; if (!formattedUrl.startsWith("http")) { formattedUrl = "http://" + formattedUrl; } return new URL(formattedUrl).hostname.replace(/^www\./, ""); } catch { return url; } }, [url]); // Memoize initial color based on URL const initialColor = useMemo(() => { if (!image) { const hash = url.split("").reduce((acc, char) => { return char.charCodeAt(0) + ((acc << 5) - acc); }, 0); return `hsl(${hash % 360}, 70%, 85%)`; // Slightly lighter base color } return "#f0f0f0"; // Lighter default color }, [url, image]); const [dominantColor, setDominantColor] = useState(initialColor); const [isDark, setIsDark] = useState(false); // Only calculate dominant color when component is in view useEffect(() => { if (image) { const fac = new FastAverageColor(); fac .getColorAsync(image, { algorithm: "dominant", crossOrigin: "anonymous", mode: "speed", }) .then((color) => { setDominantColor(color.hex); setIsDark(color.isDark); }) .catch((error) => { console.error("Error getting dominant color:", error); }); } }, [image]); const displayTitle = title || domain; const displayDescription = description || `Saved from ${domain}`; return (
{image && (
{displayTitle}
)}

{displayTitle}

{displayDescription}

{domain}
); }, ); export function FetchAndRenderContent({ content }: { content: string }) { const [memory, setMemory] = useState(null); const [isLoading, setIsLoading] = useState(false); useEffect(() => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout (async () => { const type = typeDecider(content); if (type !== "page") { const item: Memory = { type: type, content: content, createdAt: new Date(), description: null, id: 0, ogImage: null, title: null, url: null, uuid: "", updatedAt: null, raw: null, userId: 0, isSuccessfullyProcessed: true, errorMessage: null, contentHash: null, }; setMemory(item); return; } try { setIsLoading(true); const metadata = await fetchWebsiteMetadata(content); const item: Memory = { type: type, content: content, createdAt: new Date(), description: metadata?.description ?? null, id: 0, ogImage: metadata?.image ?? null, title: metadata?.title ?? null, url: content, uuid: "", updatedAt: null, raw: null, userId: 0, isSuccessfullyProcessed: true, errorMessage: null, contentHash: null, }; setMemory(item); } catch (error) { // If metadata fetch fails, create memory with basic URL info const item: Memory = { type: type, content: content, createdAt: new Date(), description: null, id: 0, ogImage: null, title: content, url: content, uuid: "", updatedAt: null, raw: null, userId: 0, isSuccessfullyProcessed: false, errorMessage: null, contentHash: null, }; setMemory(item); } finally { setIsLoading(false); clearTimeout(timeoutId); } })(); return () => { clearTimeout(timeoutId); controller.abort(); }; }, [content]); if (isLoading) { return
Loading website metadata...
; } return memory ? : null; } function SharedCard({ data }: { data: Memory }) { const queryClient = useQueryClient(); // Delete mutation const deleteMutation = useMutation({ mutationFn: async (id: number) => { const response = await fetch(`/backend/v1/memories/${id}`, { method: "DELETE", credentials: "include", }); if (!response.ok) { throw new Error("Failed to delete memory"); } return response.json(); }, onMutate: async (id) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ["memories"] }); // Snapshot the previous value const previousMemories = queryClient.getQueryData(["memories"]); // Optimistically remove the memory queryClient.setQueryData(["memories"], (old: any) => { return old?.filter((memory: Memory) => memory.id !== id); }); return { previousMemories }; }, onError: (err, variables, context) => { // Revert the optimistic update queryClient.setQueryData(["memories"], context?.previousMemories); toast.error("Failed to delete memory"); }, onSuccess: () => { toast.success("Memory deleted successfully"); queryClient.invalidateQueries({ queryKey: ["memories"] }); }, }); // Move to space mutation const moveToSpaceMutation = useMutation({ mutationFn: async ({ spaceId, documentId }: { spaceId: string; documentId: string }) => { const response = await fetch("/backend/v1/spaces/addContent", { method: "POST", headers: { "Content-Type": "application/json", }, credentials: "include", body: JSON.stringify({ spaceId, documentId }), }); if (!response.ok) { throw new Error("Failed to move memory"); } return response.json(); }, onError: (err) => { toast.error("Failed to move memory to space"); }, onSuccess: () => { toast.success("Memory moved successfully"); queryClient.invalidateQueries({ queryKey: ["memories"] }); queryClient.invalidateQueries({ queryKey: ["spaces"] }); }, }); // Flatten the data if it's a nested array and get the first item if (Array.isArray(data)) { console.log("weird data here, will try flattening.", data); data = data.flat(Infinity)[0]; } const ContentRenderer = renderContent[data.type as keyof typeof renderContent] || (() => { console.log("SharedCard data", data); return (
Unsupported content type: {typeof data.type === "undefined" ? "undefined" : data.type}
); }); const handleDelete = (e: Event) => { e.preventDefault(); e.stopPropagation(); deleteMutation.mutate(data.id); }; const handleMoveToSpace = (spaceId: string) => { moveToSpaceMutation.mutate({ spaceId, documentId: data.uuid, }); }; return (
{ switch (data.type) { case "note": return "border-primary/30"; case "document": return "border-secondary/30"; default: return "border-accent/30"; } })(), )} > {data.type !== "space" && data.type !== "document" && (
Move to ... handleDelete(e)} asChild>
)}
); } export const SpaceSelector = function SpaceSelector({ contentId, onSelect, }: { contentId: number; onSelect: (spaceId: string) => void; }) { const [search, setSearch] = useState(""); const { data: spacesData, isLoading, error, } = useQuery({ queryKey: ["spaces"], queryFn: fetchSpaces, staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes }); const filteredSpaces = useMemo(() => { if (!spacesData?.spaces) return []; return spacesData.spaces.filter((space) => space.name.toLowerCase().includes(search.toLowerCase()), ); }, [spacesData?.spaces, search]); if (isLoading) { return (
Loading spaces...
); } if (error) { return (
Error: {error instanceof Error ? error.message : "Failed to load spaces"}
); } return ( {filteredSpaces.map((space) => ( onSelect(space.uuid)} >
{space.name}
))} {filteredSpaces.length === 0 && No spaces found.}
); }; const MemoizedSpaceSelector = memo(SpaceSelector); export default SharedCard;