diff --git a/locales/index.d.ts b/locales/index.d.ts index d4ff9a8b03..86987da735 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1210,6 +1210,10 @@ export interface Locale extends ILocale { * アップロードが完了するまで時間がかかる場合があります。 */ "uploadFromUrlMayTakeTime": string; + /** + * {n}個のファイルをアップロード + */ + "uploadNFiles": ParameterizedString<"n">; /** * みつける */ @@ -11898,6 +11902,20 @@ export interface Locale extends ILocale { "text3": string; }; }; + "_uploader": { + /** + * {x}に圧縮 + */ + "compressedToX": ParameterizedString<"x">; + /** + * {x}%節約 + */ + "savedXPercent": ParameterizedString<"x">; + /** + * アップロードされていないファイルがありますが、中止しますか? + */ + "abortConfirm": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 8f1a245f02..58d7367856 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -298,6 +298,7 @@ uploadFromUrl: "URLアップロード" uploadFromUrlDescription: "アップロードしたいファイルのURL" uploadFromUrlRequested: "アップロードをリクエストしました" uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がかかる場合があります。" +uploadNFiles: "{n}個のファイルをアップロード" explore: "みつける" messageRead: "既読" noMoreHistory: "これより過去の履歴はありません" @@ -3181,3 +3182,8 @@ _serverSetupWizard: text1: "Misskeyは有志によって開発されている無料のソフトウェアです。" text2: "今後も開発を続けられるように、よろしければぜひカンパをお願いいたします。" text3: "支援者向け特典もあります!" + +_uploader: + compressedToX: "{x}に圧縮" + savedXPercent: "{x}%節約" + abortConfirm: "アップロードされていないファイルがありますが、中止しますか?" diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index ba21394cbc..99854bc66e 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -15,18 +15,16 @@ SPDX-License-Identifier: AGPL-3.0-only @closed="emit('closed')" > - + diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 3a587bc720..ebea1eb745 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
(null); const hierarchyFolders = ref([]); -const uploadings = uploads; // ドロップされようとしているか const draghover = ref(false); @@ -561,12 +559,6 @@ function getMenu() { menu.push({ text: i18n.ts.addFile, type: 'label', - }, { - text: i18n.ts.upload + ' (' + i18n.ts.compress + ')', - icon: 'ti ti-upload', - action: () => { - chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: false }); - }, }, { text: i18n.ts.upload, icon: 'ti ti-upload', @@ -766,10 +758,6 @@ onBeforeUnmount(() => { opacity: 0.5; pointer-events: none; } - - &.uploading { - height: calc(100% - 38px - 100px); - } } .folders, diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index 19989e375b..6b81e353e6 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -62,6 +66,10 @@ const props = defineProps<{ &.error { color: var(--MI_THEME-error); } + + &.waiting { + color: var(--MI_THEME-accent); + } } .line { @@ -87,6 +95,13 @@ const props = defineProps<{ transform: rotate(-90deg); } +.animCircleWaiting { + stroke-dasharray: var(--l); + stroke-dashoffset: calc(var(--l) / 1.5); + animation: waiting 0.75s linear infinite; + transform-origin: center; +} + .animFade { opacity: 0; animation: fade-in var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards; @@ -104,6 +119,15 @@ const props = defineProps<{ } } +@keyframes waiting { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + @keyframes fade-in { 0% { opacity: 0; diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue index e8e944df32..0f4912ece1 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue @@ -95,7 +95,6 @@ import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { validators } from '@/components/grid/cell-validators.js'; import { chooseFileFromDrive, chooseFileFromPc } from '@/utility/select-file.js'; -import { uploadFile } from '@/utility/upload.js'; import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js'; import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue'; import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js'; diff --git a/packages/frontend/src/pages/chat/room.form.vue b/packages/frontend/src/pages/chat/room.form.vue index 5e84ade08d..cf303301c5 100644 --- a/packages/frontend/src/pages/chat/room.form.vue +++ b/packages/frontend/src/pages/chat/room.form.vue @@ -41,7 +41,6 @@ import { formatTimeString } from '@/utility/format-time-string.js'; import { selectFile } from '@/utility/select-file.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { uploadFile } from '@/utility/upload.js'; import { miLocalStorage } from '@/local-storage.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { prefer } from '@/preferences.js'; diff --git a/packages/frontend/src/pages/debug.vue b/packages/frontend/src/pages/debug.vue index 4a28d513f5..5cd68c2c3a 100644 --- a/packages/frontend/src/pages/debug.vue +++ b/packages/frontend/src/pages/debug.vue @@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only + diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index fcf9fb234d..da20d23cfd 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -65,8 +65,6 @@ SPDX-License-Identifier: AGPL-3.0-only v-on="popup.events" /> - - import('./stream-indicator.vue')); -const XUpload = defineAsyncComponent(() => import('./upload.vue')); const XWidgets = defineAsyncComponent(() => import('./widgets.vue')); const drawerMenuShowing = defineModel('drawerMenuShowing'); diff --git a/packages/frontend/src/ui/_common_/upload.vue b/packages/frontend/src/ui/_common_/upload.vue deleted file mode 100644 index 3e5653e46d..0000000000 --- a/packages/frontend/src/ui/_common_/upload.vue +++ /dev/null @@ -1,134 +0,0 @@ - - - - - - - diff --git a/packages/frontend/src/utility/upload/isWebpSupported.ts b/packages/frontend/src/utility/isWebpSupported.ts similarity index 100% rename from packages/frontend/src/utility/upload/isWebpSupported.ts rename to packages/frontend/src/utility/isWebpSupported.ts diff --git a/packages/frontend/src/utility/select-file.ts b/packages/frontend/src/utility/select-file.ts index 731ef58302..fe8a87f111 100644 --- a/packages/frontend/src/utility/select-file.ts +++ b/packages/frontend/src/utility/select-file.ts @@ -3,25 +3,22 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ref } from 'vue'; +import { defineAsyncComponent, markRaw, ref } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; -import { uploadFile } from '@/utility/upload.js'; import { prefer } from '@/preferences.js'; export function chooseFileFromPc( multiple: boolean, options?: { uploadFolder?: string | null; - keepOriginal?: boolean; nameConverter?: (file: File) => string | undefined; }, ): Promise { const uploadFolder = options?.uploadFolder ?? prefer.s.uploadFolder; - const keepOriginal = options?.keepOriginal ?? false; const nameConverter = options?.nameConverter ?? (() => undefined); return new Promise((res, rej) => { @@ -30,15 +27,15 @@ export function chooseFileFromPc( input.multiple = multiple; input.onchange = () => { if (!input.files) return res([]); - const promises = Array.from( - input.files, - file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal), - ); - Promise.all(promises).then(driveFiles => { - res(driveFiles); - }).catch(err => { - // アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUploadDialog.vue')), { + files: markRaw(Array.from(input.files)), + folderId: uploadFolder, + }, { + done: driveFiles => { + res(driveFiles); + }, + closed: () => dispose(), }); // 一応廃棄 @@ -100,10 +97,6 @@ function select(src: HTMLElement | EventTarget | null, label: string | null, mul text: label, type: 'label', } : undefined, { - text: i18n.ts.upload + ' (' + i18n.ts.compress + ')', - icon: 'ti ti-upload', - action: () => chooseFileFromPc(multiple, { keepOriginal: false }).then(files => res(files)), - }, { text: i18n.ts.upload, icon: 'ti ti-upload', action: () => chooseFileFromPc(multiple, { keepOriginal: true }).then(files => res(files)), diff --git a/packages/frontend/src/utility/upload.ts b/packages/frontend/src/utility/upload.ts index 03240749e9..22735db844 100644 --- a/packages/frontend/src/utility/upload.ts +++ b/packages/frontend/src/utility/upload.ts @@ -3,160 +3,95 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { reactive, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import { v4 as uuid } from 'uuid'; -import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; import { apiUrl } from '@@/js/config.js'; -import { getCompressionConfig } from './upload/compress-config.js'; import { $i } from '@/i.js'; -import { alert } from '@/os.js'; -import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import { prefer } from '@/preferences.js'; - -type Uploading = { - id: string; - name: string; - progressMax: number | undefined; - progressValue: number | undefined; - img: string; -}; -export const uploads = ref([]); - -const mimeTypeMap = { - 'image/webp': 'webp', - 'image/jpeg': 'jpg', - 'image/png': 'png', -} as const; - -export function uploadFile( - file: File, - folder?: string | Misskey.entities.DriveFolder | null, - name?: string, - keepOriginal = false, -): Promise { - if ($i == null) throw new Error('Not logged in'); - - const _folder = typeof folder === 'string' ? folder : folder?.id; - - if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, - }); - return Promise.reject(); - } +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; +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) => { - const id = uuid(); + if ($i == null) return reject(); - const reader = new FileReader(); - reader.onload = async (): Promise => { - const filename = name ?? file.name ?? 'untitled'; - const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : ''; - - const ctx = reactive({ - id, - name: prefer.s.keepOriginalFilename ? filename : id + extension, - progressMax: undefined, - progressValue: undefined, - img: window.URL.createObjectURL(file), + if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, }); + return reject(); + } - uploads.value.push(ctx); - - const config = !keepOriginal ? await getCompressionConfig(file) : undefined; - let resizedImage: Blob | undefined; - if (config) { - try { - const resized = await readAndCompressImage(file, config); - if (resized.size < file.size || file.type === 'image/webp') { - // The compression may not always reduce the file size - // (and WebP is not browser safe yet) - resizedImage = resized; - } - if (_DEV_) { - const saved = ((1 - resized.size / file.size) * 100).toFixed(2); - console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`); - } - - ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name; - } catch (err) { - console.error('Failed to resize image', err); - } - } - - const formData = new FormData(); - formData.append('i', $i!.token); - formData.append('force', 'true'); - formData.append('file', resizedImage ?? file); - formData.append('name', ctx.name); - if (_folder) formData.append('folderId', _folder); - - const xhr = new XMLHttpRequest(); - xhr.open('POST', apiUrl + '/drive/files/create', true); - xhr.onload = ((ev: ProgressEvent) => { - if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { - // TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい - uploads.value = uploads.value.filter(x => x.id !== id); - - if (xhr.status === 413) { - alert({ + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = ((ev: ProgressEvent) => { + if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { + if (xhr.status === 413) { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, + }); + } else if (ev.target?.response) { + const res = JSON.parse(ev.target.response); + if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') { + os.alert({ type: 'error', title: i18n.ts.failedToUpload, - text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, + text: i18n.ts.cannotUploadBecauseInappropriate, }); - } else if (ev.target?.response) { - const res = JSON.parse(ev.target.response); - if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: i18n.ts.cannotUploadBecauseInappropriate, - }); - } else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: i18n.ts.cannotUploadBecauseNoFreeSpace, - }); - } else { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`, - }); - } - } else { - alert({ + } else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') { + os.alert({ type: 'error', - title: 'Failed to upload', - text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseNoFreeSpace, + }); + } else { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`, }); } - - reject(); - return; + } else { + os.alert({ + type: 'error', + title: 'Failed to upload', + text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, + }); } - const driveFile = JSON.parse(ev.target.response); + reject(); + return; + } - resolve(driveFile); - - uploads.value = uploads.value.filter(x => x.id !== id); - }) as (ev: ProgressEvent) => any; + const driveFile = JSON.parse(ev.target.response); + resolve(driveFile); + }) as (ev: ProgressEvent) => any; + if (options.onProgress) { xhr.upload.onprogress = ev => { if (ev.lengthComputable) { - ctx.progressMax = ev.total; - ctx.progressValue = ev.loaded; + options.onProgress({ + total: ev.total, + loaded: ev.loaded, + }); } }; + } - xhr.send(formData); - }; - reader.readAsArrayBuffer(file); + const formData = new FormData(); + formData.append('i', $i.token); + formData.append('force', 'true'); + formData.append('file', file); + formData.append('name', options.name ?? file.name ?? 'untitled'); + if (options.folderId) formData.append('folderId', options.folderId); + + xhr.send(formData); }); } diff --git a/packages/frontend/src/utility/upload/compress-config.ts b/packages/frontend/src/utility/upload/compress-config.ts deleted file mode 100644 index 3046b7f518..0000000000 --- a/packages/frontend/src/utility/upload/compress-config.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import isAnimated from 'is-file-animated'; -import { isWebpSupported } from './isWebpSupported.js'; -import type { BrowserImageResizerConfigWithConvertedOutput } from '@misskey-dev/browser-image-resizer'; - -const compressTypeMap = { - 'image/jpeg': { quality: 0.90, mimeType: 'image/webp' }, - 'image/png': { quality: 1, mimeType: 'image/webp' }, - 'image/webp': { quality: 0.90, mimeType: 'image/webp' }, - 'image/svg+xml': { quality: 1, mimeType: 'image/webp' }, -} as const; - -const compressTypeMapFallback = { - 'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' }, - 'image/png': { quality: 1, mimeType: 'image/png' }, - 'image/webp': { quality: 0.85, mimeType: 'image/jpeg' }, - 'image/svg+xml': { quality: 1, mimeType: 'image/png' }, -} as const; - -export async function getCompressionConfig(file: File): Promise { - const imgConfig = (isWebpSupported() ? compressTypeMap : compressTypeMapFallback)[file.type]; - if (!imgConfig || await isAnimated(file)) { - return; - } - - return { - maxWidth: 2048, - maxHeight: 2048, - debug: true, - ...imgConfig, - }; -}