Skip to content

Commit b488fa2

Browse files
JSKittyclaude
andcommitted
feat: spoiler images with SPOILER_ filename prefix
Add Discord-style spoiler images. Files with names starting with SPOILER_ are hidden behind a blurred thumbhash preview until clicked to reveal. Senders toggle spoiler mode via an eye icon in the file preview overlay. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cec03da commit b488fa2

9 files changed

Lines changed: 455 additions & 37 deletions

File tree

src-tauri/capabilities/default.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"allow-cache-file-bytes",
4242
"allow-get-cached-file-info",
4343
"allow-get-cached-image-preview",
44+
"allow-generate-thumbhash-for-preview",
4445
"allow-start-cached-bytes-compression",
4546
"allow-get-cached-bytes-compression-status",
4647
"allow-send-cached-file",
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Automatically generated - DO NOT EDIT!
2+
3+
[[permission]]
4+
identifier = "allow-generate-thumbhash-for-preview"
5+
description = "Enables the generate_thumbhash_for_preview command without any pre-configured scope."
6+
commands.allow = ["generate_thumbhash_for_preview"]
7+
8+
[[permission]]
9+
identifier = "deny-generate-thumbhash-for-preview"
10+
description = "Denies the generate_thumbhash_for_preview command without any pre-configured scope."
11+
commands.deny = ["generate_thumbhash_for_preview"]

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ pub fn run() {
320320
message::cache_file_bytes,
321321
message::get_cached_file_info,
322322
message::get_cached_image_preview,
323+
message::generate_thumbhash_for_preview,
323324
message::start_cached_bytes_compression,
324325
message::get_cached_bytes_compression_status,
325326
message::send_cached_file,

src-tauri/src/message/files.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,38 @@ pub fn get_cached_image_preview(quality: u32) -> Result<String, String> {
103103
Ok(encoded.to_data_uri())
104104
}
105105

106+
/// Generate a thumbhash data-URL from an image.
107+
/// Tries the JS byte cache first (Android / clipboard paste), then falls back to
108+
/// reading `file_path` from disk (desktop).
109+
#[tauri::command]
110+
pub fn generate_thumbhash_for_preview(file_path: String) -> Result<String, String> {
111+
// 1. Try the JS byte cache
112+
let img = {
113+
let cache = JS_FILE_CACHE.lock().unwrap();
114+
if let Some((bytes, _, _)) = cache.as_ref() {
115+
::image::load_from_memory(bytes).ok()
116+
} else {
117+
None
118+
}
119+
};
120+
121+
// 2. Fall back to reading from file path
122+
let img = match img {
123+
Some(i) => i,
124+
None => {
125+
if file_path.is_empty() {
126+
return Err("No cached file and no file path provided".into());
127+
}
128+
::image::open(&file_path)
129+
.map_err(|e| format!("Failed to open image: {}", e))?
130+
}
131+
};
132+
133+
let thumbhash = util::generate_thumbhash_from_image(&img)
134+
.ok_or_else(|| "Failed to generate thumbhash".to_string())?;
135+
Ok(util::decode_thumbhash_to_base64(&thumbhash))
136+
}
137+
106138
/// Start compression of cached bytes
107139
#[tauri::command]
108140
pub async fn start_cached_bytes_compression() -> Result<(), String> {

src/icons/eye-off.svg

Lines changed: 3 additions & 0 deletions
Loading

src/icons/eye.svg

Lines changed: 4 additions & 0 deletions
Loading

src/js/file-preview.js

Lines changed: 205 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ let pendingZipPath = null; // For folder zip: temp zip path for cleanup
2222
let zipInProgress = false; // For folder zip: compression in progress
2323
let pendingZipUnlisten = null; // For folder zip: unlisten function for zip_progress events
2424
let 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
2728
const 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

Comments
 (0)