mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(app): keep local image previews from disappearing before upload (#1845)
This commit is contained in:
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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user