enhance(frontend): ファイルのアップロードを中止できるように (#16069)

* enhance(frontend): ファイルのアップロードを中止できるように

* Update Changelog

* fix: ダイアログを閉じたり、中断ボタンが押されたりしたときはその後のアップロードをすべて中止するように

* fix
This commit is contained in:
かっこかり 2025-05-21 21:13:19 +09:00 committed by GitHub
parent e61b5abb05
commit ccf5bd337e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 124 additions and 27 deletions

View File

@ -22,6 +22,7 @@
- アップロードに失敗したときに再試行できるようになりました - アップロードに失敗したときに再試行できるようになりました
- アップロード前に画像のクロッピングを行えるようになりました - アップロード前に画像のクロッピングを行えるようになりました
- ファイルサイズのチェックは圧縮後の実際にアップロードされるサイズで行われるようになりました - ファイルサイズのチェックは圧縮後の実際にアップロードされるサイズで行われるようになりました
- ファイルのアップロードを中断できるようになりました
- Feat: サーバー初期設定ウィザードが実装されました - Feat: サーバー初期設定ウィザードが実装されました
- 簡単なウィザードに従うだけで、サーバーに最適な設定が適用されます - 簡単なウィザードに従うだけで、サーバーに最適な設定が適用されます
- Feat: Websocket接続を行わずにMisskeyを利用するNo Websocketモードが実装されました(beta) - Feat: Websocket接続を行わずにMisskeyを利用するNo Websocketモードが実装されました(beta)

4
locales/index.d.ts vendored
View File

@ -5449,6 +5449,10 @@ export interface Locale extends ILocale {
* {x} * {x}
*/ */
"unmuteX": ParameterizedString<"x">; "unmuteX": ParameterizedString<"x">;
/**
*
*/
"abort": string;
"_chat": { "_chat": {
/** /**
* *

View File

@ -1357,6 +1357,7 @@ emojiMute: "絵文字ミュート"
emojiUnmute: "絵文字ミュート解除" emojiUnmute: "絵文字ミュート解除"
muteX: "{x}をミュート" muteX: "{x}をミュート"
unmuteX: "{x}のミュートを解除" unmuteX: "{x}のミュートを解除"
abort: "中止"
_chat: _chat:
noMessagesYet: "まだメッセージはありません" noMessagesYet: "まだメッセージはありません"

View File

@ -130,7 +130,7 @@ defineExpose({
} }
.footer { .footer {
padding: 8px 16px; padding: 12px 16px;
overflow: auto; overflow: auto;
background: var(--MI_THEME-bg); background: var(--MI_THEME-bg);
border-top: 1px solid var(--MI_THEME-divider); border-top: 1px solid var(--MI_THEME-divider);

View File

@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.root"> <div :class="$style.root">
<div :class="[$style.overallProgress, canRetry ? $style.overallProgressError : null]" :style="{ '--op': `${overallProgress}%` }"></div> <div :class="[$style.overallProgress, canRetry ? $style.overallProgressError : null]" :style="{ '--op': `${overallProgress}%` }"></div>
<div :class="$style.main" class="_gaps_s"> <div class="_gaps_s _spacer">
<div :class="$style.items" class="_gaps_s"> <div class="_gaps_s">
<div <div
v-for="ctx in items" v-for="ctx in items"
:key="ctx.id" :key="ctx.id"
@ -74,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #footer> <template #footer>
<div class="_buttonsCenter"> <div class="_buttonsCenter">
<MkButton v-if="isUploading" rounded @click="cancel()"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton> <MkButton v-if="isUploading" rounded @click="abortWithConfirm()"><i class="ti ti-x"></i> {{ i18n.ts.abort }}</MkButton>
<MkButton v-else-if="!firstUploadAttempted" primary rounded @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</MkButton> <MkButton v-else-if="!firstUploadAttempted" primary rounded @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</MkButton>
<MkButton v-if="canRetry" rounded @click="upload()"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton> <MkButton v-if="canRetry" rounded @click="upload()"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton>
@ -96,10 +96,9 @@ import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import bytes from '@/filters/bytes.js'; import bytes from '@/filters/bytes.js';
import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import { isWebpSupported } from '@/utility/isWebpSupported.js'; import { isWebpSupported } from '@/utility/isWebpSupported.js';
import { uploadFile } from '@/utility/drive.js'; import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { ensureSignin } from '@/i.js'; import { ensureSignin } from '@/i.js';
@ -138,7 +137,7 @@ const emit = defineEmits<{
(ev: 'closed'): void; (ev: 'closed'): void;
}>(); }>();
const items = ref([] as { const items = ref<{
id: string; id: string;
name: string; name: string;
progress: { max: number; value: number } | null; progress: { max: number; value: number } | null;
@ -147,10 +146,12 @@ const items = ref([] as {
uploading: boolean; uploading: boolean;
uploaded: Misskey.entities.DriveFile | null; uploaded: Misskey.entities.DriveFile | null;
uploadFailed: boolean; uploadFailed: boolean;
aborted: boolean;
compressedSize?: number | null; compressedSize?: number | null;
compressedImage?: Blob | null; compressedImage?: Blob | null;
file: File; file: File;
}[]); abort?: (() => void) | null;
}[]>([]);
const dialog = useTemplateRef('dialog'); const dialog = useTemplateRef('dialog');
@ -213,10 +214,23 @@ async function cancel() {
}); });
if (canceled) return; if (canceled) return;
abortAll();
emit('canceled'); emit('canceled');
dialog.value?.close(); dialog.value?.close();
} }
async function abortWithConfirm() {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.ts._uploader.abortConfirm,
okText: i18n.ts.yes,
cancelText: i18n.ts.no,
});
if (canceled) return;
abortAll();
}
async function done() { async function done() {
if (items.value.some(item => item.uploaded == null)) { if (items.value.some(item => item.uploaded == null)) {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
@ -258,6 +272,17 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
items.value.splice(items.value.indexOf(item), 1); items.value.splice(items.value.indexOf(item), 1);
}, },
}); });
} else if (item.uploading) {
menu.push({
icon: 'ti ti-cloud-pause',
text: i18n.ts.abort,
danger: true,
action: () => {
if (item.abort != null) {
item.abort();
}
}
});
} }
os.popupMenu(menu, ev.currentTarget ?? ev.target); os.popupMenu(menu, ev.currentTarget ?? ev.target);
@ -266,7 +291,20 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
async function upload() { // async function upload() { //
firstUploadAttempted.value = true; firstUploadAttempted.value = true;
items.value = items.value.map(item => ({
...item,
aborted: false,
uploadFailed: false,
waiting: false,
uploading: false,
}));
for (const item of items.value.filter(item => item.uploaded == null)) { for (const item of items.value.filter(item => item.uploaded == null)) {
// Array filter
if (item.aborted) {
continue;
}
item.waiting = true; item.waiting = true;
item.uploadFailed = false; item.uploadFailed = false;
@ -296,7 +334,7 @@ async function upload() { // エラーハンドリングなどを考慮してシ
item.uploading = true; item.uploading = true;
const driveFile = await uploadFile(item.compressedImage ?? item.file, { const { filePromise, abort } = uploadFile(item.compressedImage ?? item.file, {
name: item.name, name: item.name,
folderId: props.folderId, folderId: props.folderId,
onProgress: (progress) => { onProgress: (progress) => {
@ -308,16 +346,43 @@ async function upload() { // エラーハンドリングなどを考慮してシ
item.progress.max = progress.total; item.progress.max = progress.total;
} }
}, },
});
item.abort = () => {
item.abort = null;
abort();
item.uploading = false;
item.waiting = false;
item.uploadFailed = true;
};
await filePromise.then((file) => {
item.uploaded = file;
item.abort = null;
}).catch(err => { }).catch(err => {
item.uploadFailed = true; item.uploadFailed = true;
item.progress = null; item.progress = null;
throw err; if (!(err instanceof UploadAbortedError)) {
throw err;
}
}).finally(() => { }).finally(() => {
item.uploading = false; item.uploading = false;
item.waiting = false; item.waiting = false;
}); });
}
}
item.uploaded = driveFile; function abortAll() {
for (const item of items.value) {
if (item.uploaded != null) {
continue;
}
if (item.abort != null) {
item.abort();
}
item.aborted = true;
item.uploadFailed = true;
} }
} }
@ -340,6 +405,7 @@ function initializeFile(file: File) {
thumbnail: window.URL.createObjectURL(file), thumbnail: window.URL.createObjectURL(file),
waiting: false, waiting: false,
uploading: false, uploading: false,
aborted: false,
uploaded: null, uploaded: null,
uploadFailed: false, uploadFailed: false,
file: markRaw(file), file: markRaw(file),
@ -373,13 +439,6 @@ onMounted(() => {
} }
} }
.main {
padding: 12px;
}
.items {
}
.item { .item {
position: relative; position: relative;
border-radius: 10px; border-radius: 10px;

View File

@ -16,12 +16,27 @@ import { instance } from '@/instance.js';
import { globalEvents } from '@/events.js'; import { globalEvents } from '@/events.js';
import { getProxiedImageUrl } from '@/utility/media-proxy.js'; import { getProxiedImageUrl } from '@/utility/media-proxy.js';
type UploadReturnType = {
filePromise: Promise<Misskey.entities.DriveFile>;
abort: () => void;
};
export class UploadAbortedError extends Error {
constructor() {
super('Upload aborted');
}
}
export function uploadFile(file: File | Blob, options: { export function uploadFile(file: File | Blob, options: {
name?: string; name?: string;
folderId?: string | null; folderId?: string | null;
onProgress?: (ctx: { total: number; loaded: number; }) => void; onProgress?: (ctx: { total: number; loaded: number; }) => void;
} = {}): Promise<Misskey.entities.DriveFile> { } = {}): UploadReturnType {
return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest();
const abortController = new AbortController();
const { signal } = abortController;
const filePromise = new Promise<Misskey.entities.DriveFile>((resolve, reject) => {
if ($i == null) return reject(); if ($i == null) return reject();
if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) { if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) {
@ -33,7 +48,10 @@ export function uploadFile(file: File | Blob, options: {
return reject(); return reject();
} }
const xhr = new XMLHttpRequest(); signal.addEventListener('abort', () => {
reject(new UploadAbortedError());
}, { once: true });
xhr.open('POST', apiUrl + '/drive/files/create', true); xhr.open('POST', apiUrl + '/drive/files/create', true);
xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => { xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => {
if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
@ -83,7 +101,7 @@ export function uploadFile(file: File | Blob, options: {
if (options.onProgress) { if (options.onProgress) {
xhr.upload.onprogress = ev => { xhr.upload.onprogress = ev => {
if (ev.lengthComputable) { if (ev.lengthComputable && options.onProgress != null) {
options.onProgress({ options.onProgress({
total: ev.total, total: ev.total,
loaded: ev.loaded, loaded: ev.loaded,
@ -96,11 +114,18 @@ export function uploadFile(file: File | Blob, options: {
formData.append('i', $i.token); formData.append('i', $i.token);
formData.append('force', 'true'); formData.append('force', 'true');
formData.append('file', file); formData.append('file', file);
formData.append('name', options.name ?? file.name ?? 'untitled'); formData.append('name', options.name ?? (file instanceof File ? file.name : 'untitled'));
if (options.folderId) formData.append('folderId', options.folderId); if (options.folderId) formData.append('folderId', options.folderId);
xhr.send(formData); xhr.send(formData);
}); });
const abort = () => {
xhr.abort();
abortController.abort();
};
return { filePromise, abort };
} }
export function chooseFileFromPcAndUpload( export function chooseFileFromPcAndUpload(
@ -126,7 +151,7 @@ export function chooseDriveFile(options: {
} = {}): Promise<Misskey.entities.DriveFile[]> { } = {}): Promise<Misskey.entities.DriveFile[]> {
return new Promise(resolve => { return new Promise(resolve => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveFileSelectDialog.vue')), { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveFileSelectDialog.vue')), {
multiple: options.multiple, multiple: options.multiple ?? false,
}, { }, {
done: files => { done: files => {
if (files) { if (files) {
@ -204,7 +229,7 @@ export function selectFiles(src: HTMLElement | EventTarget | null, label: string
export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFile: Misskey.entities.DriveFile, options: { export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFile: Misskey.entities.DriveFile, options: {
aspectRatio: number | null; aspectRatio: number | null;
}): Promise<Misskey.entities.DriveFile> { }): Promise<Misskey.entities.DriveFile> {
return new Promise(resolve => { return new Promise((resolve, reject) => {
const imgUrl = getProxiedImageUrl(imageDriveFile.url, undefined, true); const imgUrl = getProxiedImageUrl(imageDriveFile.url, undefined, true);
const image = new Image(); const image = new Image();
image.src = imgUrl; image.src = imgUrl;
@ -215,13 +240,20 @@ export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFi
canvas.height = image.height; canvas.height = image.height;
ctx.drawImage(image, 0, 0); ctx.drawImage(image, 0, 0);
canvas.toBlob(blob => { canvas.toBlob(blob => {
if (blob == null) {
reject();
return;
}
os.cropImageFile(blob, { os.cropImageFile(blob, {
aspectRatio: options.aspectRatio, aspectRatio: options.aspectRatio,
}).then(croppedImageFile => { }).then(croppedImageFile => {
uploadFile(croppedImageFile, { const { filePromise } = uploadFile(croppedImageFile, {
name: imageDriveFile.name, name: imageDriveFile.name,
folderId: imageDriveFile.folderId, folderId: imageDriveFile.folderId,
}).then(driveFile => { });
filePromise.then(driveFile => {
resolve(driveFile); resolve(driveFile);
}); });
}); });