@@ -22,6 +22,7 @@ let pendingZipPath = null; // For folder zip: temp zip path for cleanup
2222let zipInProgress = false ; // For folder zip: compression in progress
2323let pendingZipUnlisten = null ; // For folder zip: unlisten function for zip_progress events
2424let filePreviewGeneration = 0 ; // Guards against setTimeout race on rapid close+reopen
25+ let pendingSpoiler = false ; // Spoiler mode: prepends SPOILER_ to filename on send
2526
2627// Image extensions supported by the image crate
2728const SUPPORTED_IMAGE_EXTENSIONS = [ 'png' , 'jpg' , 'jpeg' , 'gif' , 'webp' , 'tiff' , 'tif' , 'ico' ] ;
@@ -45,6 +46,158 @@ function validateImageSrc(src) {
4546 return null ;
4647}
4748
49+ /**
50+ * Create and append a spoiler toggle button to an image container in the file preview.
51+ * Toggles pendingSpoiler state and swaps the preview between real image and thumbhash.
52+ * @param {HTMLElement } container - The .file-preview-image-container element
53+ */
54+ function appendSpoilerToggle ( container , autoActivate = false ) {
55+ const btn = document . createElement ( 'button' ) ;
56+ btn . className = 'spoiler-toggle' ;
57+ btn . type = 'button' ;
58+ // Cache the original src so we can restore on toggle-off
59+ let originalSrc = null ;
60+ let thumbhashSrc = null ;
61+
62+ function activateSpoiler ( ) {
63+ const icon = btn . querySelector ( '.icon' ) ;
64+ const img = container . querySelector ( '.file-preview-image' ) ;
65+ icon . className = 'icon icon-eye-off' ;
66+ btn . title = 'Remove spoiler' ;
67+ if ( img ) {
68+ originalSrc = img . src ;
69+ // Freeze rendered size so thumbhash displays at same dimensions
70+ const rect = img . getBoundingClientRect ( ) ;
71+ if ( rect . width > 0 && rect . height > 0 ) {
72+ img . style . width = rect . width + 'px' ;
73+ img . style . height = rect . height + 'px' ;
74+ } else {
75+ // Image not painted yet (auto-activate) — use natural dimensions
76+ img . width = img . naturalWidth ;
77+ img . height = img . naturalHeight ;
78+ img . style . height = 'auto' ;
79+ }
80+ img . style . maxWidth = 'none' ;
81+ img . style . maxHeight = 'none' ;
82+ img . style . objectFit = 'fill' ;
83+ if ( thumbhashSrc ) {
84+ img . src = thumbhashSrc ;
85+ } else {
86+ invoke ( 'generate_thumbhash_for_preview' , { filePath : pendingFile || '' } )
87+ . then ( dataUrl => {
88+ thumbhashSrc = dataUrl ;
89+ if ( pendingSpoiler ) img . src = dataUrl ;
90+ } )
91+ . catch ( ( ) => {
92+ img . classList . add ( 'spoiler-blur' ) ;
93+ } ) ;
94+ }
95+ // Add "Spoiler" label overlay if the image is large enough
96+ const w = rect . width || img . naturalWidth ;
97+ const h = rect . height || img . naturalHeight ;
98+ if ( w >= 80 && h >= 60 ) {
99+ const overlay = document . createElement ( 'div' ) ;
100+ overlay . className = 'spoiler-overlay' ;
101+ overlay . innerHTML = '<span class="icon icon-eye-off"></span><span class="spoiler-label">Spoiler</span>' ;
102+ container . appendChild ( overlay ) ;
103+ }
104+ }
105+ }
106+
107+ function deactivateSpoiler ( ) {
108+ const icon = btn . querySelector ( '.icon' ) ;
109+ const img = container . querySelector ( '.file-preview-image' ) ;
110+ icon . className = 'icon icon-eye' ;
111+ btn . title = 'Mark as spoiler' ;
112+ if ( img ) {
113+ img . classList . remove ( 'spoiler-blur' ) ;
114+ if ( originalSrc ) img . src = originalSrc ;
115+ img . style . width = '' ;
116+ img . style . height = '' ;
117+ img . style . maxWidth = '' ;
118+ img . style . maxHeight = '' ;
119+ img . style . objectFit = '' ;
120+ img . removeAttribute ( 'width' ) ;
121+ img . removeAttribute ( 'height' ) ;
122+ }
123+ const overlay = container . querySelector ( '.spoiler-overlay' ) ;
124+ if ( overlay ) overlay . remove ( ) ;
125+ }
126+
127+ btn . title = autoActivate ? 'Remove spoiler' : 'Mark as spoiler' ;
128+ btn . innerHTML = autoActivate
129+ ? '<span class="icon icon-eye-off"></span>'
130+ : '<span class="icon icon-eye"></span>' ;
131+
132+ btn . addEventListener ( 'click' , ( e ) => {
133+ e . stopPropagation ( ) ;
134+ pendingSpoiler = ! pendingSpoiler ;
135+ if ( pendingSpoiler ) {
136+ activateSpoiler ( ) ;
137+ } else {
138+ deactivateSpoiler ( ) ;
139+ }
140+ } ) ;
141+ container . appendChild ( btn ) ;
142+
143+ // Auto-activate: set state and fetch thumbhash immediately
144+ if ( autoActivate ) {
145+ pendingSpoiler = true ;
146+ const img = container . querySelector ( '.file-preview-image' ) ;
147+ if ( img ) {
148+ originalSrc = img . src ;
149+ invoke ( 'generate_thumbhash_for_preview' , { filePath : pendingFile || '' } )
150+ . then ( dataUrl => {
151+ thumbhashSrc = dataUrl ;
152+ if ( pendingSpoiler ) {
153+ // Set dimensions from current state
154+ const rect = img . getBoundingClientRect ( ) ;
155+ if ( rect . width > 0 && rect . height > 0 ) {
156+ img . style . width = rect . width + 'px' ;
157+ img . style . height = rect . height + 'px' ;
158+ } else {
159+ img . width = img . naturalWidth ;
160+ img . height = img . naturalHeight ;
161+ img . style . height = 'auto' ;
162+ }
163+ img . style . maxWidth = 'none' ;
164+ img . style . maxHeight = 'none' ;
165+ img . style . objectFit = 'fill' ;
166+ img . src = dataUrl ;
167+ // Add overlay
168+ const w = rect . width || img . naturalWidth ;
169+ const h = rect . height || img . naturalHeight ;
170+ if ( w >= 80 && h >= 60 && ! container . querySelector ( '.spoiler-overlay' ) ) {
171+ const overlay = document . createElement ( 'div' ) ;
172+ overlay . className = 'spoiler-overlay' ;
173+ overlay . innerHTML = '<span class="icon icon-eye-off"></span><span class="spoiler-label">Spoiler</span>' ;
174+ container . appendChild ( overlay ) ;
175+ }
176+ }
177+ } )
178+ . catch ( ( ) => {
179+ if ( pendingSpoiler ) img . classList . add ( 'spoiler-blur' ) ;
180+ } ) ;
181+ }
182+ }
183+ }
184+
185+ /**
186+ * Detect SPOILER_ prefix in filename, auto-enable pendingSpoiler, and return the clean name.
187+ * @param {string } name - The filename (with or without extension)
188+ * @returns {string } The filename with SPOILER_ prefix stripped (if present)
189+ */
190+ function detectAndStripSpoilerPrefix ( name ) {
191+ const stem = getFileStem ( name ) ;
192+ if ( stem . toUpperCase ( ) . startsWith ( 'SPOILER_' ) ) {
193+ pendingSpoiler = true ;
194+ const cleanStem = stem . substring ( 8 ) ; // strip "SPOILER_"
195+ const ext = getFileExtension ( name ) ;
196+ return ext ? `${ cleanStem } .${ ext } ` : cleanStem ;
197+ }
198+ return name ;
199+ }
200+
48201/**
49202 * Format file size in human-readable format
50203 * @param {number } bytes - File size in bytes
@@ -350,6 +503,7 @@ async function openFilePreview(filepath, receiver, replyRef = '') {
350503 pendingFile = filepath ;
351504 pendingFileBytes = null ; // Clear bytes mode since we're using file path
352505 pendingFileObject = null ; // Clear File object since we're using file path
506+ pendingSpoiler = false ; // Reset spoiler toggle for new preview
353507 pendingReceiver = receiver ;
354508 pendingReplyRef = replyRef ;
355509 pendingFileExt = null ; // Will be set after extension is resolved
@@ -411,6 +565,9 @@ async function openFilePreview(filepath, receiver, replyRef = '') {
411565 }
412566 }
413567
568+ // Detect SPOILER_ prefix and strip from display name
569+ fileName = detectAndStripSpoilerPrefix ( fileName ) ;
570+
414571 // Update file name — show stem (editable) + extension badge (read-only)
415572 pendingEditedName = null ;
416573 pendingFileExt = ext ;
@@ -433,7 +590,7 @@ async function openFilePreview(filepath, receiver, replyRef = '') {
433590 } else if ( isImage ) {
434591 // Show image preview
435592 const isAndroid = typeof platformFeatures !== 'undefined' && platformFeatures . os === 'android' ;
436-
593+
437594 const validatedAndroidPreview = validateImageSrc ( androidPreview ) ;
438595 if ( isAndroid && validatedAndroidPreview ) {
439596 // On Android, use the base64 preview we already got from cache_android_file
@@ -457,6 +614,13 @@ async function openFilePreview(filepath, receiver, replyRef = '') {
457614 </div>
458615 ` ;
459616 }
617+ // Add spoiler toggle to whichever image container was created
618+ const imgCont = contentArea . querySelector ( '.file-preview-image-container' ) ;
619+ if ( imgCont ) {
620+ const shouldAutoSpoiler = pendingSpoiler ;
621+ if ( shouldAutoSpoiler ) pendingSpoiler = false ;
622+ appendSpoilerToggle ( imgCont , shouldAutoSpoiler ) ;
623+ }
460624 } else if ( isVideo ) {
461625 const videoSrc = mediaUrl ( filepath ) ;
462626 contentArea . innerHTML = `
@@ -634,6 +798,8 @@ async function openFilePreviewWithFile(file, fileName, ext, receiver, replyRef =
634798 // Store the File object for later use when sending
635799 pendingFileObject = file ;
636800 pendingFileBytes = null ; // Clear bytes mode - we're using File object
801+ pendingSpoiler = false ; // Reset spoiler toggle for new preview
802+ fileName = detectAndStripSpoilerPrefix ( fileName ) ;
637803 pendingFileName = fileName ;
638804 pendingFileExt = ext ;
639805 pendingFile = null ; // Clear file path since we're using File object
@@ -716,6 +882,11 @@ async function openFilePreviewWithFile(file, fileName, ext, receiver, replyRef =
716882 // Read bytes and cache in Rust - this returns a preview
717883 // This approach works on all Android versions (uses base64 data URL from backend)
718884 startFileObjectCacheAndPreview ( file , fileName , ext , contentArea , showCompression ) ;
885+ // Add spoiler toggle to initial loading container (will be re-added after preview loads)
886+ const imgCont2 = contentArea . querySelector ( '.file-preview-image-container' ) ;
887+ if ( imgCont2 ) appendSpoilerToggle ( imgCont2 ) ;
888+ // If pendingSpoiler was set by detectAndStripSpoilerPrefix, startFileObjectCacheAndPreview
889+ // will apply the spoiler state after the preview image loads.
719890 } else if ( isVideo ) {
720891 // For video, show icon on Android (video preview is unreliable on older devices)
721892 contentArea . innerHTML = `
@@ -814,6 +985,8 @@ async function openFilePreviewWithBytes(bytes, fileName, ext, fileSize, receiver
814985 // Mark that we're using bytes mode (no file path)
815986 pendingFileBytes = true ; // Flag to indicate bytes mode
816987 pendingFileObject = null ; // Clear File object since we're using cached bytes
988+ pendingSpoiler = false ; // Reset spoiler toggle for new preview
989+ fileName = detectAndStripSpoilerPrefix ( fileName ) ;
817990 pendingFileName = fileName ;
818991 pendingFileExt = ext ;
819992 pendingFile = null ; // Clear file path since we're using bytes
@@ -859,6 +1032,13 @@ async function openFilePreviewWithBytes(bytes, fileName, ext, fileSize, receiver
8591032 <img src="${ validatedPreview } " class="file-preview-image" alt="Preview">
8601033 </div>
8611034 ` ;
1035+ // Add spoiler toggle for clipboard-pasted images
1036+ const imgCont3 = contentArea . querySelector ( '.file-preview-image-container' ) ;
1037+ if ( imgCont3 ) {
1038+ const shouldAutoSpoiler3 = pendingSpoiler ;
1039+ if ( shouldAutoSpoiler3 ) pendingSpoiler = false ;
1040+ appendSpoilerToggle ( imgCont3 , shouldAutoSpoiler3 ) ;
1041+ }
8621042 } else {
8631043 // Fallback: show image icon if no preview
8641044 contentArea . innerHTML = `
@@ -867,7 +1047,7 @@ async function openFilePreviewWithBytes(bytes, fileName, ext, fileSize, receiver
8671047 </div>
8681048 ` ;
8691049 }
870-
1050+
8711051 // Show compress option for images larger than 25KB (excluding GIFs to preserve animation)
8721052 const MIN_COMPRESS_SIZE = 25 * 1024 ; // 25KB
8731053 if ( ext !== 'gif' && fileSize > MIN_COMPRESS_SIZE ) {
@@ -1021,6 +1201,13 @@ async function startFileObjectCacheAndPreview(file, fileName, ext, contentArea,
10211201 <img src="${ validatedResultPreview } " class="file-preview-image" alt="Preview">
10221202 </div>
10231203 ` ;
1204+ // Re-add spoiler toggle after preview loaded (replaces loading container)
1205+ const imgCont = contentArea . querySelector ( '.file-preview-image-container' ) ;
1206+ if ( imgCont ) {
1207+ const shouldAutoSpoiler = pendingSpoiler ;
1208+ if ( shouldAutoSpoiler ) pendingSpoiler = false ;
1209+ appendSpoilerToggle ( imgCont , shouldAutoSpoiler ) ;
1210+ }
10241211 } else {
10251212 // Fallback to icon
10261213 contentArea . innerHTML = `
@@ -1029,7 +1216,7 @@ async function startFileObjectCacheAndPreview(file, fileName, ext, contentArea,
10291216 </div>
10301217 ` ;
10311218 }
1032-
1219+
10331220 // Start compression if requested
10341221 if ( startCompression ) {
10351222 startCachedBytesCompression ( ) ;
@@ -1383,6 +1570,7 @@ function closeFilePreview() {
13831570 pendingReplyRef = null ;
13841571 compressionInProgress = false ;
13851572 compressionComplete = false ;
1573+ pendingSpoiler = false ;
13861574 pendingZipPath = null ;
13871575 zipInProgress = false ;
13881576
@@ -1419,9 +1607,19 @@ async function sendPreviewedFile() {
14191607 const fileName = pendingFileName ;
14201608 const ext = pendingFileExt ;
14211609 const editedStem = pendingEditedName ;
1422- const nameOverride = editedStem
1423- ? ( ext ? `${ editedStem } .${ ext } ` : editedStem )
1424- : '' ;
1610+ const isSpoiler = pendingSpoiler ;
1611+ // Build nameOverride: if spoiler, always ensure SPOILER_ prefix (requires a name)
1612+ let nameOverride ;
1613+ if ( isSpoiler ) {
1614+ // Need a stem to prefix — use edited name, pending name, file path, or fallback
1615+ const stem = editedStem || ( fileName ? getFileStem ( fileName ) : null ) || ( filePath ? getFileStem ( getFileName ( filePath ) ) : null ) || 'image' ;
1616+ const spoilerStem = stem . toUpperCase ( ) . startsWith ( 'SPOILER_' ) ? stem : `SPOILER_${ stem } ` ;
1617+ nameOverride = ext ? `${ spoilerStem } .${ ext } ` : spoilerStem ;
1618+ } else {
1619+ nameOverride = editedStem
1620+ ? ( ext ? `${ editedStem } .${ ext } ` : editedStem )
1621+ : '' ;
1622+ }
14251623 const usingBytes = ! ! fileBytes ;
14261624 const isZipSend = ! ! pendingZipPath ;
14271625
@@ -1467,6 +1665,7 @@ async function sendPreviewedFile() {
14671665 pendingFileName = null ;
14681666 pendingFileExt = null ;
14691667 pendingEditedName = null ;
1668+ pendingSpoiler = false ;
14701669 pendingReceiver = null ;
14711670 pendingReplyRef = null ;
14721671 compressionInProgress = false ;
0 commit comments