11import type { EnvelopeItem } from "@sentry/core" ;
22import { ReactComponent as Download } from "@spotlight/ui/assets/download.svg" ;
3- import { base64Decode } from "@spotlight/ui/lib/base64" ;
4- import { type ReactNode , useCallback , useEffect , useState } from "react" ;
3+ import { base64Decode , safeAtob } from "@spotlight/ui/lib/base64" ;
4+ import { type ReactNode , useEffect , useMemo } from "react" ;
55import JsonViewer from "../../shared/JsonViewer" ;
66import { CodeViewer } from "./CodeViewer" ;
77import { inferExtension } from "./contentType" ;
@@ -20,53 +20,60 @@ export default function Attachment({
2020 attachment : string ;
2121 expanded ?: boolean ;
2222} ) {
23- const [ downloadUrl , setDownloadUrl ] = useState < string | null > ( null ) ;
2423 const extension = inferExtension ( header . content_type as string | null , header . type as string | null ) ;
2524 const name = ( header . filename as string ) || `untitled.${ extension } ` ;
2625
27- const createDownloadUrl = useCallback ( ( ) => {
28- const blob = new Blob (
29- [
30- IMAGE_CONTENT_TYPES . has ( header . content_type as string ) || VIDEO_CONTENT_TYPES . has ( header . content_type as string )
31- ? ( base64Decode ( attachment ) . buffer as BlobPart )
32- : extension === "bin"
33- ? atob ( attachment )
34- : attachment ,
35- ] ,
36- { type : ( header . content_type as string ) || "application/octet-stream" } ,
37- ) ;
38- const url = URL . createObjectURL ( blob ) ;
39- setDownloadUrl ( current => {
40- if ( current ) {
41- URL . revokeObjectURL ( current ) ;
42- }
43- return url ;
44- } ) ;
45- return url ;
46- } , [ attachment , extension , header . content_type ] ) ;
47-
48- useEffect ( ( ) => {
26+ // Create download URL for binary content types
27+ // Returns: string (success), null (decode error)
28+ const downloadUrl = useMemo ( ( ) => {
4929 if ( ! expanded ) {
50- return ;
30+ return undefined ; // Not needed yet
5131 }
52- if ( ! downloadUrl ) {
53- createDownloadUrl ( ) ;
32+
33+ const contentType = header . content_type as string ;
34+ let blobData : BlobPart ;
35+
36+ if ( IMAGE_CONTENT_TYPES . has ( contentType ) || VIDEO_CONTENT_TYPES . has ( contentType ) ) {
37+ const decoded = base64Decode ( attachment ) ;
38+ if ( ! decoded ) {
39+ return null ; // Decode error
40+ }
41+ blobData = decoded . buffer as BlobPart ;
42+ } else if ( extension === "bin" ) {
43+ const decoded = safeAtob ( attachment ) ;
44+ if ( decoded === null ) {
45+ return null ; // Decode error
46+ }
47+ blobData = decoded ;
48+ } else {
49+ return undefined ; // Not a binary type, no blob URL needed
5450 }
55- } , [ expanded , downloadUrl , createDownloadUrl ] ) ;
5651
57- useEffect (
58- ( ) => ( ) => {
52+ const blob = new Blob ( [ blobData ] , { type : contentType || "application/octet-stream" } ) ;
53+ return URL . createObjectURL ( blob ) ;
54+ } , [ expanded , attachment , extension , header . content_type ] ) ;
55+
56+ // Cleanup blob URL on unmount or when URL changes
57+ useEffect ( ( ) => {
58+ return ( ) => {
5959 if ( downloadUrl ) {
6060 URL . revokeObjectURL ( downloadUrl ) ;
6161 }
62- } ,
63- [ downloadUrl ] ,
64- ) ;
62+ } ;
63+ } , [ downloadUrl ] ) ;
64+
65+ const decodeError = downloadUrl === null ;
6566
6667 let content : ReactNode = null ;
6768
6869 if ( expanded ) {
69- if ( header . content_type === "text/plain" || header . content_type === "text/csv" ) {
70+ if ( decodeError ) {
71+ content = (
72+ < pre className = "text-destructive-400 whitespace-pre-wrap break-words font-mono text-sm rounded-sm bg-primary-900 p-2" >
73+ Failed to decode attachment data. The base64 data may be corrupted or invalid.
74+ </ pre >
75+ ) ;
76+ } else if ( header . content_type === "text/plain" || header . content_type === "text/csv" ) {
7077 content = (
7178 < pre className = "text-primary-300 whitespace-pre-wrap break-words font-mono text-sm rounded-sm bg-primary-900 p-2" >
7279 { attachment }
0 commit comments