diff --git a/packages/frontend/src/_boot_.ts b/packages/frontend/src/_boot_.ts index 13a97e433c..b579eb944d 100644 --- a/packages/frontend/src/_boot_.ts +++ b/packages/frontend/src/_boot_.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import 'reflect-metadata'; + // https://vitejs.dev/config/build-options.html#build-modulepreload import 'vite/modulepreload-polyfill'; diff --git a/packages/frontend/src/services/AccountService.ts b/packages/frontend/src/services/AccountService.ts new file mode 100644 index 0000000000..b96584cb88 --- /dev/null +++ b/packages/frontend/src/services/AccountService.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { inject, injectable, container } from 'tsyringe'; +import * as Misskey from 'misskey-js'; +import { defineAsyncComponent, reactive, ref } from 'vue'; +import { miLocalStorage } from '@/local-storage.js'; + +type Account = Misskey.entities.MeDetailed & { token: string }; + +const accountData = miLocalStorage.getItem('account'); + +const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null; + +@injectable() +export class AccountService { + constructor( + ) {} + + public readonly i = $i; +} diff --git a/packages/frontend/src/services/ServerMetadataService.ts b/packages/frontend/src/services/ServerMetadataService.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/frontend/src/services/UploaderService.ts b/packages/frontend/src/services/UploaderService.ts new file mode 100644 index 0000000000..4e5dcd200a --- /dev/null +++ b/packages/frontend/src/services/UploaderService.ts @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { inject, injectable, container } from 'tsyringe'; +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 { getCompressionConfig } from './upload/compress-config.js'; +import { AccountService } from './AccountService.js'; +import { defaultStore } from '@/store.js'; +import { apiUrl } from '@/config.js'; +import { alert } from '@/os.js'; +import { i18n } from '@/i18n.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; + +@injectable() +export class Uploader { + constructor( + @inject('AccountService') private accountService: AccountService, + @inject('ServerMetadataService') private serverMetadataService: ServerMetadataService, + ) {} + + public uploadFile( + file: File, + folder?: any, + name?: string, + keepOriginal: boolean = defaultStore.state.keepOriginalUploading, + ): Promise { + if (this.accountService.i == null) throw new Error('Not logged in'); + + if (folder && typeof folder === 'object') folder = folder.id; + + return fetchServerMetadata().then((serverMetadata) => new Promise((resolve, reject) => { + if (file.size > serverMetadata.maxFileSize) { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, + }); + return reject(); + } + + const id = uuid(); + + 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: defaultStore.state.keepOriginalFilename ? filename : id + extension, + progressMax: undefined, + progressValue: undefined, + img: window.URL.createObjectURL(file), + }); + + 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', this.accountService.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({ + 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') { + 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({ + type: 'error', + title: 'Failed to upload', + text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, + }); + } + + reject(); + return; + } + + const driveFile = JSON.parse(ev.target.response); + + resolve(driveFile); + + uploads.value = uploads.value.filter(x => x.id !== id); + }) as (ev: ProgressEvent) => any; + + xhr.upload.onprogress = ev => { + if (ev.lengthComputable) { + ctx.progressMax = ev.total; + ctx.progressValue = ev.loaded; + } + }; + + xhr.send(formData); + }; + reader.readAsArrayBuffer(file); + })); + } +}