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 hasDOM = true;
const LOCAL_MEDIA_PROBE_TIMEOUT_MS = 10_000;
export type UploadStatus = 'pending' | 'uploading' | 'failed' | 'sending';
export type UploadStage = 'idle' | 'queued' | 'uploading' | 'processing' | 'completed' | 'failed' | 'canceled';
@@ -399,6 +400,7 @@ class CloudUploadManager {
this.attachmentIndex.set(att.id, att);
});
this.notifyTextareaListeners(upload.channelId);
desktopCompleteUpload(nonce);
this.setMessageUploadInternal(nonce, null);
this.messageUploadListeners.delete(nonce);
}
@@ -543,10 +545,11 @@ class CloudUploadManager {
const spoiler = file.name.startsWith('SPOILER_');
const canPreviewImage = shouldEagerlyPreviewLocalImage(file);
const canPreviewVideo = shouldEagerlyPreviewLocalVideo(file);
const previewURL = hasDOM && canPreviewImage ? URL.createObjectURL(file) : null;
if (hasDOM) {
try {
if (canPreviewImage) {
const dims = await this.getImageDimensions(file);
if (canPreviewImage && previewURL) {
const dims = await this.getImageDimensions(previewURL, file.name);
width = dims.width;
height = dims.height;
} else if (canPreviewVideo) {
@@ -559,8 +562,6 @@ class CloudUploadManager {
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 attachment: CloudAttachment = {
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;
height: number;
}> {
if (!hasDOM) return {width: 0, height: 0};
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const {naturalWidth: width, naturalHeight: height} = img;
URL.revokeObjectURL(url);
resolve({width, height});
};
img.onerror = (error) => {
URL.revokeObjectURL(url);
reject(error);
};
img.src = url;
});
const img = new Image();
let probeTimeoutId: ReturnType<typeof setTimeout> | null = null;
try {
return await new Promise<{width: number; height: number}>((resolve, reject) => {
probeTimeoutId = setTimeout(() => {
reject(new Error(`Timed out reading image dimensions for ${filename}`));
}, LOCAL_MEDIA_PROBE_TIMEOUT_MS);
img.onload = () => {
const {naturalWidth: width, naturalHeight: height} = img;
resolve({width, height});
};
img.onerror = (error) => {
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<{
@@ -612,52 +624,70 @@ class CloudUploadManager {
thumbnailURL: string | null;
}> {
if (!hasDOM) return {width: 0, height: 0, thumbnailURL: null};
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const video = document.createElement('video');
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
video.addEventListener('loadedmetadata', () => {
const {videoWidth: width, videoHeight: height} = video;
if (!ctx) {
URL.revokeObjectURL(url);
resolve({width, height, thumbnailURL: null});
return;
}
video.currentTime = 0;
const url = URL.createObjectURL(file);
const video = document.createElement('video');
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
let probeTimeoutId: ReturnType<typeof setTimeout> | null = null;
try {
return await new Promise<{width: number; height: number; thumbnailURL: string | null}>((resolve, reject) => {
let settled = false;
let capturing = false;
probeTimeoutId = setTimeout(() => {
if (settled) return;
settled = true;
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', () => {
const {videoWidth: width, videoHeight: height} = video;
if (!ctx) {
URL.revokeObjectURL(url);
resolve({width, height, thumbnailURL: null});
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;
});
} finally {
if (probeTimeoutId !== null) clearTimeout(probeTimeoutId);
video.removeAttribute('src');
video.load();
URL.revokeObjectURL(url);
}
}
}
@@ -3,6 +3,7 @@
import {describe, expect, it} from 'vitest';
import {
getScaledMediaDimensions,
hasRenderableLocalPreview,
LOCAL_MEDIA_PREVIEW_MAX_BYTES,
shouldEagerlyPreviewLocalImage,
shouldEagerlyPreviewLocalVideo,
@@ -31,3 +32,33 @@ describe('Local attachment preview utils', () => {
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)),
};
}
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;
}