diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bcf3ef255..7a33c0c237 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - アップロードに失敗したときに再試行できるようになりました - アップロード前に画像のクロッピングを行えるようになりました - ファイルサイズのチェックは圧縮後の実際にアップロードされるサイズで行われるようになりました + - ファイルのアップロードを中断できるようになりました - Feat: サーバー初期設定ウィザードが実装されました - 簡単なウィザードに従うだけで、サーバーに最適な設定が適用されます - Feat: Websocket接続を行わずにMisskeyを利用するNo Websocketモードが実装されました(beta) diff --git a/locales/index.d.ts b/locales/index.d.ts index 3643c2e7e9..58e531aac4 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5449,6 +5449,10 @@ export interface Locale extends ILocale { * {x}のミュートを解除 */ "unmuteX": ParameterizedString<"x">; + /** + * 中止 + */ + "abort": string; "_chat": { /** * まだメッセージはありません diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index fe23ce0a3c..d07fad16d6 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1357,6 +1357,7 @@ emojiMute: "絵文字ミュート" emojiUnmute: "絵文字ミュート解除" muteX: "{x}をミュート" unmuteX: "{x}のミュートを解除" +abort: "中止" _chat: noMessagesYet: "まだメッセージはありません" diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index 6b81e353e6..fd4262c17d 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -130,7 +130,7 @@ defineExpose({ } .footer { - padding: 8px 16px; + padding: 12px 16px; overflow: auto; background: var(--MI_THEME-bg); border-top: 1px solid var(--MI_THEME-divider); diff --git a/packages/frontend/src/components/MkUploaderDialog.vue b/packages/frontend/src/components/MkUploaderDialog.vue index 3a83247d4b..b171546854 100644 --- a/packages/frontend/src/components/MkUploaderDialog.vue +++ b/packages/frontend/src/components/MkUploaderDialog.vue @@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
-
-
+
+
- {{ i18n.ts.cancel }} + {{ i18n.ts.abort }} {{ i18n.ts.upload }} {{ i18n.ts.retry }} @@ -96,10 +96,9 @@ import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; import MkButton from '@/components/MkButton.vue'; import bytes from '@/filters/bytes.js'; -import MkSwitch from '@/components/MkSwitch.vue'; import MkSelect from '@/components/MkSelect.vue'; 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 { ensureSignin } from '@/i.js'; @@ -138,7 +137,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const items = ref([] as { +const items = ref<{ id: string; name: string; progress: { max: number; value: number } | null; @@ -147,10 +146,12 @@ const items = ref([] as { uploading: boolean; uploaded: Misskey.entities.DriveFile | null; uploadFailed: boolean; + aborted: boolean; compressedSize?: number | null; compressedImage?: Blob | null; file: File; -}[]); + abort?: (() => void) | null; +}[]>([]); const dialog = useTemplateRef('dialog'); @@ -213,10 +214,23 @@ async function cancel() { }); if (canceled) return; + abortAll(); emit('canceled'); 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() { if (items.value.some(item => item.uploaded == null)) { 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); }, }); + } 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); @@ -266,7 +291,20 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) { async function upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる 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)) { + // アップロード処理途中で値が変わる場合(途中で全キャンセルされたりなど)もあるので、Array filterではなくここでチェック + if (item.aborted) { + continue; + } + item.waiting = true; item.uploadFailed = false; @@ -296,7 +334,7 @@ async function upload() { // エラーハンドリングなどを考慮してシ item.uploading = true; - const driveFile = await uploadFile(item.compressedImage ?? item.file, { + const { filePromise, abort } = uploadFile(item.compressedImage ?? item.file, { name: item.name, folderId: props.folderId, onProgress: (progress) => { @@ -308,16 +346,43 @@ async function upload() { // エラーハンドリングなどを考慮してシ 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 => { item.uploadFailed = true; item.progress = null; - throw err; + if (!(err instanceof UploadAbortedError)) { + throw err; + } }).finally(() => { item.uploading = 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), waiting: false, uploading: false, + aborted: false, uploaded: null, uploadFailed: false, file: markRaw(file), @@ -373,13 +439,6 @@ onMounted(() => { } } -.main { - padding: 12px; -} - -.items { -} - .item { position: relative; border-radius: 10px; diff --git a/packages/frontend/src/utility/drive.ts b/packages/frontend/src/utility/drive.ts index e29b010c81..7ffb85cfda 100644 --- a/packages/frontend/src/utility/drive.ts +++ b/packages/frontend/src/utility/drive.ts @@ -16,12 +16,27 @@ import { instance } from '@/instance.js'; import { globalEvents } from '@/events.js'; import { getProxiedImageUrl } from '@/utility/media-proxy.js'; +type UploadReturnType = { + filePromise: Promise; + abort: () => void; +}; + +export class UploadAbortedError extends Error { + constructor() { + super('Upload aborted'); + } +} + export function uploadFile(file: File | Blob, options: { name?: string; folderId?: string | null; onProgress?: (ctx: { total: number; loaded: number; }) => void; -} = {}): Promise { - return new Promise((resolve, reject) => { +} = {}): UploadReturnType { + const xhr = new XMLHttpRequest(); + const abortController = new AbortController(); + const { signal } = abortController; + + const filePromise = new Promise((resolve, reject) => { if ($i == null) return reject(); 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(); } - const xhr = new XMLHttpRequest(); + signal.addEventListener('abort', () => { + reject(new UploadAbortedError()); + }, { once: true }); + xhr.open('POST', apiUrl + '/drive/files/create', true); xhr.onload = ((ev: ProgressEvent) => { 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) { xhr.upload.onprogress = ev => { - if (ev.lengthComputable) { + if (ev.lengthComputable && options.onProgress != null) { options.onProgress({ total: ev.total, loaded: ev.loaded, @@ -96,11 +114,18 @@ export function uploadFile(file: File | Blob, options: { formData.append('i', $i.token); formData.append('force', 'true'); 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); xhr.send(formData); }); + + const abort = () => { + xhr.abort(); + abortController.abort(); + }; + + return { filePromise, abort }; } export function chooseFileFromPcAndUpload( @@ -126,7 +151,7 @@ export function chooseDriveFile(options: { } = {}): Promise { return new Promise(resolve => { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveFileSelectDialog.vue')), { - multiple: options.multiple, + multiple: options.multiple ?? false, }, { done: 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: { aspectRatio: number | null; }): Promise { - return new Promise(resolve => { + return new Promise((resolve, reject) => { const imgUrl = getProxiedImageUrl(imageDriveFile.url, undefined, true); const image = new Image(); image.src = imgUrl; @@ -215,13 +240,20 @@ export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFi canvas.height = image.height; ctx.drawImage(image, 0, 0); canvas.toBlob(blob => { + if (blob == null) { + reject(); + return; + } + os.cropImageFile(blob, { aspectRatio: options.aspectRatio, }).then(croppedImageFile => { - uploadFile(croppedImageFile, { + const { filePromise } = uploadFile(croppedImageFile, { name: imageDriveFile.name, folderId: imageDriveFile.folderId, - }).then(driveFile => { + }); + + filePromise.then(driveFile => { resolve(driveFile); }); });