fix(app): keep local image previews from disappearing before upload (#1845)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:29:49 +02:00
1 parent c285854b71
commit aa1c636cc2
3 files changed
+139 -64

No files matched your search

@@ -17,6 +17,7 @@ import {filter, map, timeout as rxTimeout, take} from 'rxjs/operators';
const logger = new Logger('CloudUpload'); const logger = new Logger('CloudUpload');
const hasDOM = true; const hasDOM = true;
const LOCAL_MEDIA_PROBE_TIMEOUT_MS = 10_000;
export type UploadStatus = 'pending' | 'uploading' | 'failed' | 'sending'; export type UploadStatus = 'pending' | 'uploading' | 'failed' | 'sending';
export type UploadStage = 'idle' | 'queued' | 'uploading' | 'processing' | 'completed' | 'failed' | 'canceled'; export type UploadStage = 'idle' | 'queued' | 'uploading' | 'processing' | 'completed' | 'failed' | 'canceled';
@@ -399,6 +400,7 @@ class CloudUploadManager {
this.attachmentIndex.set(att.id, att); this.attachmentIndex.set(att.id, att);
}); });
this.notifyTextareaListeners(upload.channelId); this.notifyTextareaListeners(upload.channelId);
desktopCompleteUpload(nonce);
this.setMessageUploadInternal(nonce, null); this.setMessageUploadInternal(nonce, null);
this.messageUploadListeners.delete(nonce); this.messageUploadListeners.delete(nonce);
} }
@@ -543,10 +545,11 @@ class CloudUploadManager {
const spoiler = file.name.startsWith('SPOILER_'); const spoiler = file.name.startsWith('SPOILER_');
const canPreviewImage = shouldEagerlyPreviewLocalImage(file); const canPreviewImage = shouldEagerlyPreviewLocalImage(file);
const canPreviewVideo = shouldEagerlyPreviewLocalVideo(file); const canPreviewVideo = shouldEagerlyPreviewLocalVideo(file);
const previewURL = hasDOM && canPreviewImage ? URL.createObjectURL(file) : null;
if (hasDOM) { if (hasDOM) {
try { try {
if (canPreviewImage) { if (canPreviewImage && previewURL) {
const dims = await this.getImageDimensions(file); const dims = await this.getImageDimensions(previewURL, file.name);
width = dims.width; width = dims.width;
height = dims.height; height = dims.height;
} else if (canPreviewVideo) { } else if (canPreviewVideo) {
@@ -559,8 +562,6 @@ class CloudUploadManager {
logger.warn('Error getting file data:', error); logger.warn('Error getting file data:', error);
} }
} }
const isPreviewableMedia = canPreviewImage || canPreviewVideo || (file.type.startsWith('audio/') && hasDOM);
const previewURL = hasDOM && isPreviewableMedia ? URL.createObjectURL(file) : null;
const flags = spoiler ? MessageAttachmentFlags.IS_SPOILER : 0; const flags = spoiler ? MessageAttachmentFlags.IS_SPOILER : 0;
const attachment: CloudAttachment = { const attachment: CloudAttachment = {
id: this.nextAttachmentId++, id: this.nextAttachmentId++,
@@ -585,25 +586,36 @@ class CloudUploadManager {
); );
} }
private async getImageDimensions(file: File): Promise<{ private async getImageDimensions(
sourceURL: string,
filename: string,
): Promise<{
width: number; width: number;
height: number; height: number;
}> { }> {
if (!hasDOM) return {width: 0, height: 0}; if (!hasDOM) return {width: 0, height: 0};
return new Promise((resolve, reject) => { const img = new Image();
const url = URL.createObjectURL(file); let probeTimeoutId: ReturnType<typeof setTimeout> | null = null;
const img = new Image(); try {
img.onload = () => { return await new Promise<{width: number; height: number}>((resolve, reject) => {
const {naturalWidth: width, naturalHeight: height} = img; probeTimeoutId = setTimeout(() => {
URL.revokeObjectURL(url); reject(new Error(`Timed out reading image dimensions for ${filename}`));
resolve({width, height}); }, LOCAL_MEDIA_PROBE_TIMEOUT_MS);
}; img.onload = () => {
img.onerror = (error) => { const {naturalWidth: width, naturalHeight: height} = img;
URL.revokeObjectURL(url); resolve({width, height});
reject(error); };
}; img.onerror = (error) => {
img.src = url; reject(error);
}); };
img.src = sourceURL;
});
} finally {
if (probeTimeoutId !== null) clearTimeout(probeTimeoutId);
img.onload = null;
img.onerror = null;
img.removeAttribute('src');
}
} }
private async getVideoData(file: File): Promise<{ private async getVideoData(file: File): Promise<{
@@ -612,52 +624,70 @@ class CloudUploadManager {
thumbnailURL: string | null; thumbnailURL: string | null;
}> { }> {
if (!hasDOM) return {width: 0, height: 0, thumbnailURL: null}; if (!hasDOM) return {width: 0, height: 0, thumbnailURL: null};
return new Promise((resolve, reject) => { const url = URL.createObjectURL(file);
const url = URL.createObjectURL(file); const video = document.createElement('video');
const video = document.createElement('video'); const canvas = document.createElement('canvas');
const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d'); let probeTimeoutId: ReturnType<typeof setTimeout> | null = null;
video.addEventListener('loadedmetadata', () => { try {
const {videoWidth: width, videoHeight: height} = video; return await new Promise<{width: number; height: number; thumbnailURL: string | null}>((resolve, reject) => {
if (!ctx) { let settled = false;
URL.revokeObjectURL(url); let capturing = false;
resolve({width, height, thumbnailURL: null}); probeTimeoutId = setTimeout(() => {
return; if (settled) return;
} settled = true;
video.currentTime = 0; reject(new Error(`Timed out reading video data for ${file.name}`));
}, LOCAL_MEDIA_PROBE_TIMEOUT_MS);
const finishWithoutThumbnail = () => {
if (settled) return;
settled = true;
resolve({width: video.videoWidth, height: video.videoHeight, thumbnailURL: null});
};
const drawThumbnailFrame = () => {
if (settled || capturing || !ctx) return;
const {videoWidth: width, videoHeight: height} = video;
const thumbnailDimensions = getScaledMediaDimensions(width, height);
if (thumbnailDimensions.width === 0 || thumbnailDimensions.height === 0) return;
capturing = true;
canvas.width = thumbnailDimensions.width;
canvas.height = thumbnailDimensions.height;
ctx.drawImage(video, 0, 0, thumbnailDimensions.width, thumbnailDimensions.height);
canvas.toBlob(
(blob) => {
if (settled) return;
settled = true;
resolve({width, height, thumbnailURL: blob ? URL.createObjectURL(blob) : null});
},
'image/jpeg',
0.8,
);
};
video.addEventListener('loadedmetadata', () => {
const thumbnailDimensions = ctx
? getScaledMediaDimensions(video.videoWidth, video.videoHeight)
: {width: 0, height: 0};
if (thumbnailDimensions.width === 0 || thumbnailDimensions.height === 0) {
finishWithoutThumbnail();
return;
}
video.currentTime = 0;
});
video.addEventListener('loadeddata', drawThumbnailFrame);
video.addEventListener('seeked', drawThumbnailFrame);
video.addEventListener('error', (e) => {
if (settled) return;
settled = true;
reject(e);
});
video.muted = true;
video.src = url;
}); });
video.addEventListener('seeked', () => { } finally {
const {videoWidth: width, videoHeight: height} = video; if (probeTimeoutId !== null) clearTimeout(probeTimeoutId);
if (!ctx) { video.removeAttribute('src');
URL.revokeObjectURL(url); video.load();
resolve({width, height, thumbnailURL: null}); URL.revokeObjectURL(url);
return; }
}
const thumbnailDimensions = getScaledMediaDimensions(width, height);
if (thumbnailDimensions.width === 0 || thumbnailDimensions.height === 0) {
URL.revokeObjectURL(url);
resolve({width, height, thumbnailURL: null});
return;
}
canvas.width = thumbnailDimensions.width;
canvas.height = thumbnailDimensions.height;
ctx.drawImage(video, 0, 0, thumbnailDimensions.width, thumbnailDimensions.height);
canvas.toBlob(
(blob) => {
URL.revokeObjectURL(url);
const thumbnailURL = blob ? URL.createObjectURL(blob) : null;
resolve({width, height, thumbnailURL});
},
'image/jpeg',
0.8,
);
});
video.addEventListener('error', (e) => {
URL.revokeObjectURL(url);
reject(e);
});
video.src = url;
});
} }
} }
@@ -3,6 +3,7 @@
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import { import {
getScaledMediaDimensions, getScaledMediaDimensions,
hasRenderableLocalPreview,
LOCAL_MEDIA_PREVIEW_MAX_BYTES, LOCAL_MEDIA_PREVIEW_MAX_BYTES,
shouldEagerlyPreviewLocalImage, shouldEagerlyPreviewLocalImage,
shouldEagerlyPreviewLocalVideo, shouldEagerlyPreviewLocalVideo,
@@ -31,3 +32,33 @@ describe('Local attachment preview utils', () => {
expect(getScaledMediaDimensions(320, 240, 640)).toEqual({width: 320, height: 240}); expect(getScaledMediaDimensions(320, 240, 640)).toEqual({width: 320, height: 240});
}); });
}); });
describe('hasRenderableLocalPreview', () => {
const videoFile = {name: 'clip.mov', size: 1024, type: 'video/quicktime'};
const imageFile = {name: 'photo.jpg', size: 1024, type: 'image/jpeg'};
const otherFile = {name: 'notes.txt', size: 1024, type: 'text/plain'};
it('treats a video with a generated thumbnail as renderable media', () => {
expect(hasRenderableLocalPreview({file: videoFile, previewURL: 'blob:preview', thumbnailURL: 'blob:thumb'})).toBe(
true,
);
});
it('does not treat a video whose thumbnail probe failed as renderable media', () => {
expect(hasRenderableLocalPreview({file: videoFile, previewURL: 'blob:preview', thumbnailURL: null})).toBe(false);
});
it('treats an image with a preview URL as renderable media', () => {
expect(hasRenderableLocalPreview({file: imageFile, previewURL: 'blob:preview', thumbnailURL: null})).toBe(true);
});
it('does not treat an image without a preview URL as renderable media', () => {
expect(hasRenderableLocalPreview({file: imageFile, previewURL: null, thumbnailURL: null})).toBe(false);
});
it('does not treat a non-media file as renderable media', () => {
expect(hasRenderableLocalPreview({file: otherFile, previewURL: 'blob:preview', thumbnailURL: 'blob:thumb'})).toBe(
false,
);
});
});
@@ -37,3 +37,17 @@ export function getScaledMediaDimensions(
height: Math.max(1, Math.round(height * scale)), height: Math.max(1, Math.round(height * scale)),
}; };
} }
export function hasRenderableLocalPreview(attachment: {
file: LocalPreviewFile;
previewURL: string | null;
thumbnailURL: string | null;
}): boolean {
if (attachment.file.type.startsWith('video/')) {
return attachment.thumbnailURL !== null;
}
if (isEmbeddableImageFile(attachment.file)) {
return attachment.previewURL !== null;
}
return false;
}