diff --git a/apps/web/components/document-cards/file-preview.tsx b/apps/web/components/document-cards/file-preview.tsx index 6013a203..9965d9be 100644 --- a/apps/web/components/document-cards/file-preview.tsx +++ b/apps/web/components/document-cards/file-preview.tsx @@ -1,6 +1,6 @@ "use client" -import { memo, useState } from "react" +import { memo, useCallback, useState } from "react" import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" import type { z } from "zod" import { dmSansClassName } from "@/lib/fonts" @@ -49,6 +49,7 @@ export const FilePreview = memo(function FilePreview({ document: DocumentWithMemories }) { const [imageError, setImageError] = useState(false) + const [retryKey, setRetryKey] = useState(0) const { extension, color } = getFileTypeInfo(document) const type = document.type?.toLowerCase() @@ -58,6 +59,17 @@ export const FilePreview = memo(function FilePreview({ document.url && !imageError + // On first failure, wait briefly then force a re-render with a new key to + // retry the fetch (covers transient R2 timing issues). + // On second failure, give up and show the fallback file icon view. + const handleImageError = useCallback(() => { + if (retryKey === 0) { + setTimeout(() => setRetryKey(1), 500) + return + } + setImageError(true) + }, [retryKey]) + return (
{color && ( @@ -80,10 +92,11 @@ export const FilePreview = memo(function FilePreview({ />
{document.title setImageError(true)} + onError={handleImageError} loading="lazy" />
diff --git a/apps/web/components/document-modal/content/image-preview.tsx b/apps/web/components/document-modal/content/image-preview.tsx index 31d5c1c0..cfa200de 100644 --- a/apps/web/components/document-modal/content/image-preview.tsx +++ b/apps/web/components/document-modal/content/image-preview.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useCallback, useState } from "react" import { cn } from "@lib/utils" interface ImagePreviewProps { @@ -11,6 +11,19 @@ interface ImagePreviewProps { export function ImagePreview({ url, title }: ImagePreviewProps) { const [imageError, setImageError] = useState(false) const [isLoading, setIsLoading] = useState(true) + const [retryKey, setRetryKey] = useState(0) + + // On first failure, wait briefly then force a re-render with a new key to + // retry the fetch (covers transient R2 timing issues). + // On second failure, give up and show the error state. + const handleImageError = useCallback(() => { + if (retryKey === 0) { + setTimeout(() => setRetryKey(1), 500) + return + } + setImageError(true) + setIsLoading(false) + }, [retryKey]) if (imageError || !url) { return ( @@ -38,16 +51,14 @@ export function ImagePreview({ url, title }: ImagePreviewProps) { />
{title { - setImageError(true) - setIsLoading(false) - }} + onError={handleImageError} onLoad={() => setIsLoading(false)} loading="lazy" /> diff --git a/apps/web/components/document-modal/content/pdf.tsx b/apps/web/components/document-modal/content/pdf.tsx index a025cf61..a4b896ac 100644 --- a/apps/web/components/document-modal/content/pdf.tsx +++ b/apps/web/components/document-modal/content/pdf.tsx @@ -1,7 +1,7 @@ "use client" import { Document, Page, pdfjs } from "react-pdf" -import { useState } from "react" +import { useCallback, useState } from "react" import "react-pdf/dist/Page/AnnotationLayer.css" import "react-pdf/dist/Page/TextLayer.css" @@ -19,6 +19,7 @@ export function PdfViewer({ url }: PdfViewerProps) { const [numPages, setNumPages] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [retryKey, setRetryKey] = useState(0) if (!url) { return ( @@ -34,10 +35,24 @@ export function PdfViewer({ url }: PdfViewerProps) { setError(null) } - function onDocumentLoadError(error: Error) { - setError(error.message || "Failed to load PDF") - setLoading(false) - } + // On first failure, wait briefly then force a re-mount of the Document + // component to retry (covers transient R2 timing issues). + // On second failure, give up and show the error state. + const onDocumentLoadError = useCallback( + (err: Error) => { + if (retryKey === 0) { + setTimeout(() => { + setRetryKey(1) + setLoading(true) + setError(null) + }, 500) + return + } + setError(err.message || "Failed to load PDF") + setLoading(false) + }, + [retryKey], + ) return (
@@ -53,6 +68,7 @@ export function PdfViewer({ url }: PdfViewerProps) { )}
{document.url && - !document.url.includes("files.supermemory.ai") && + !isSupermemoryFileUrl(document.url) && (document.title || (!document.url.includes("x.com") && !document.url.includes("twitter.com"))) && ( diff --git a/apps/web/lib/url-helpers.ts b/apps/web/lib/url-helpers.ts index d171a6c2..832b8722 100644 --- a/apps/web/lib/url-helpers.ts +++ b/apps/web/lib/url-helpers.ts @@ -189,6 +189,29 @@ export function toLinkedInProfileUrl(handle: string): string { return `https://linkedin.com/in/${handle.trim()}` } +/** + * Checks if a URL points to a supermemory-hosted file. + * Matches the public bucket domain (files.supermemory.ai) and + * presigned R2 URLs whose hostname ends with `.r2.cloudflarestorage.com`. + * + * Note: The R2 check is intentionally broad — it matches any Cloudflare R2 + * presigned URL, not only supermemory's account. This is acceptable because + * the function is only called on `document.url` values returned by our own + * backend, where all R2 URLs originate from the supermemory bucket. + * If user-supplied external R2 URLs ever appear in this field, tighten the + * check by also validating the account-id subdomain or the bucket path prefix. + */ +export const isSupermemoryFileUrl = (url: string): boolean => { + try { + const parsed = new URL(url) + if (parsed.hostname === "files.supermemory.ai") return true + if (parsed.hostname.endsWith(".r2.cloudflarestorage.com")) return true + return false + } catch { + return false + } +} + /** * Gets the favicon URL for a given URL. */