diff --git a/CHANGELOG.md b/CHANGELOG.md index ce05552486..1ab6756c71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,14 @@ - デフォルト値は「ローカルのコンテンツだけ公開」になっています ### Client +- Feat: ドライブのUIが強化されました + - 複数のファイルをまとめて移動できるようになりました +- Feat: ファイルのアップロードUIが一新されました + - アップロード前にファイル情報を確認できるようになりました + - 圧縮の品質を選択できるようになりました + - アップロードに失敗したときに再試行できるようになりました + - アップロード前に画像のクロッピングを行えるようになりました + - ファイルサイズのチェックは圧縮後の実際にアップロードされるサイズで行われるようになりました - Feat: サーバー初期設定ウィザードが実装されました - 簡単なウィザードに従うだけで、サーバーに最適な設定が適用されます - Feat: Websocket接続を行わずにMisskeyを利用するNo Websocketモードが実装されました(beta) diff --git a/locales/index.d.ts b/locales/index.d.ts index 0ab947fab3..3643c2e7e9 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">; /** * みつける */ @@ -8535,10 +8539,6 @@ export interface Locale extends ILocale { * 入力ボックスの縁取り */ "inputBorder": string; - /** - * ドライブフォルダーの背景 - */ - "driveFolderBg": string; /** * バッジ */ @@ -11463,22 +11463,6 @@ export interface Locale extends ILocale { * ディレクトリをドラッグ・ドロップした時に、ディレクトリ名を"category"に入力します。 */ "directoryToCategoryCaption": string; - /** - * いずれかの方法で登録する絵文字を選択してください。 - */ - "emojiInputAreaCaption": string; - /** - * この枠に画像ファイルまたはディレクトリをドラッグ&ドロップ - */ - "emojiInputAreaList1": string; - /** - * このリンクをクリックしてPCから選択する - */ - "emojiInputAreaList2": string; - /** - * このリンクをクリックしてドライブから選択する - */ - "emojiInputAreaList3": string; /** * リストに表示されている絵文字を新たなカスタム絵文字として登録します。よろしいですか?(負荷を避けるため、一度の操作で登録可能な絵文字は{count}件までです) */ @@ -11912,6 +11896,28 @@ export interface Locale extends ILocale { "text3": string; }; }; + "_uploader": { + /** + * {x}に圧縮 + */ + "compressedToX": ParameterizedString<"x">; + /** + * {x}%節約 + */ + "savedXPercent": ParameterizedString<"x">; + /** + * アップロードされていないファイルがありますが、中止しますか? + */ + "abortConfirm": string; + /** + * アップロードされていないファイルがありますが、完了しますか? + */ + "doneConfirm": string; + /** + * アップロード可能な最大ファイルサイズは{x}です。 + */ + "maxFileSizeIsX": ParameterizedString<"x">; + }; "_clientPerformanceIssueTip": { /** * バッテリー消費が多いと感じたら diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 5462759c9f..fe23ce0a3c 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: "これより過去の履歴はありません" @@ -2237,7 +2238,6 @@ _theme: buttonBg: "ボタンの背景" buttonHoverBg: "ボタンの背景 (ホバー)" inputBorder: "入力ボックスの縁取り" - driveFolderBg: "ドライブフォルダーの背景" badge: "バッジ" messageBg: "チャットの背景" fgHighlighted: "強調された文字" @@ -3056,10 +3056,6 @@ _customEmojisManager: uploadSettingDescription: "この画面で絵文字アップロードを行う際の動作を設定できます。" directoryToCategoryLabel: "ディレクトリ名を\"category\"に入力する" directoryToCategoryCaption: "ディレクトリをドラッグ・ドロップした時に、ディレクトリ名を\"category\"に入力します。" - emojiInputAreaCaption: "いずれかの方法で登録する絵文字を選択してください。" - emojiInputAreaList1: "この枠に画像ファイルまたはディレクトリをドラッグ&ドロップ" - emojiInputAreaList2: "このリンクをクリックしてPCから選択する" - emojiInputAreaList3: "このリンクをクリックしてドライブから選択する" confirmRegisterEmojisDescription: "リストに表示されている絵文字を新たなカスタム絵文字として登録します。よろしいですか?(負荷を避けるため、一度の操作で登録可能な絵文字は{count}件までです)" confirmClearEmojisDescription: "編集内容を破棄し、リストに表示されている絵文字をクリアします。よろしいですか?" confirmUploadEmojisDescription: "ドラッグ&ドロップされた{count}個のファイルをドライブにアップロードします。実行しますか?" @@ -3186,6 +3182,13 @@ _serverSetupWizard: text2: "今後も開発を続けられるように、よろしければぜひカンパをお願いいたします。" text3: "支援者向け特典もあります!" +_uploader: + compressedToX: "{x}に圧縮" + savedXPercent: "{x}%節約" + abortConfirm: "アップロードされていないファイルがありますが、中止しますか?" + doneConfirm: "アップロードされていないファイルがありますが、完了しますか?" + maxFileSizeIsX: "アップロード可能な最大ファイルサイズは{x}です。" + _clientPerformanceIssueTip: title: "バッテリー消費が多いと感じたら" makeSureDisabledAdBlocker: "アドブロッカーを無効にしてください" diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 5f1e373429..0d5ac022aa 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -8,7 +8,7 @@ import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; import sharp from 'sharp'; import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; -import { IsNull } from 'typeorm'; +import { In, IsNull } from 'typeorm'; import { DeleteObjectCommandInput, PutObjectCommandInput, NoSuchKey } from '@aws-sdk/client-s3'; import { DI } from '@/di-symbols.js'; import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js'; @@ -720,6 +720,21 @@ export class DriveService { return fileObj; } + @bindThis + public async moveFiles(fileIds: MiDriveFile['id'][], folderId: MiDriveFolder['id'] | null, userId: MiUser['id']) { + const folder = folderId ? await this.driveFoldersRepository.findOneByOrFail({ + id: folderId, + userId: userId, + }) : null; + + await this.driveFilesRepository.update({ + id: In(fileIds), + userId: userId, + }, { + folderId: folder ? folder.id : null, + }); + } + @bindThis public async deleteFile(file: MiDriveFile, isExpired = false, deleter?: MiUser) { if (file.storedInternal) { diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index bd466b3cad..1fdd000fdf 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -175,6 +175,7 @@ export * as 'drive/files/find' from './endpoints/drive/files/find.js'; export * as 'drive/files/find-by-hash' from './endpoints/drive/files/find-by-hash.js'; export * as 'drive/files/show' from './endpoints/drive/files/show.js'; export * as 'drive/files/update' from './endpoints/drive/files/update.js'; +export * as 'drive/files/move-bulk' from './endpoints/drive/files/move-bulk.js'; export * as 'drive/files/upload-from-url' from './endpoints/drive/files/upload-from-url.js'; export * as 'drive/folders' from './endpoints/drive/folders.js'; export * as 'drive/folders/create' from './endpoints/drive/folders/create.js'; diff --git a/packages/backend/src/server/api/endpoints/drive/files/move-bulk.ts b/packages/backend/src/server/api/endpoints/drive/files/move-bulk.ts new file mode 100644 index 0000000000..c8500895eb --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/move-bulk.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { DriveService } from '@/core/DriveService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['drive'], + + requireCredential: true, + + kind: 'write:drive', + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + fileIds: { type: 'array', uniqueItems: true, minItems: 1, maxItems: 100, items: { type: 'string', format: 'misskey:id' } }, + folderId: { type: 'string', format: 'misskey:id', nullable: true }, + }, + required: ['fileIds'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private driveService: DriveService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.driveService.moveFiles(ps.fileIds, ps.folderId ?? null, me.id); + }); + } +} diff --git a/packages/frontend-embed/@types/global.d.ts b/packages/frontend-embed/@types/global.d.ts index 1025d1bedb..8a067a78ec 100644 --- a/packages/frontend-embed/@types/global.d.ts +++ b/packages/frontend-embed/@types/global.d.ts @@ -10,9 +10,6 @@ declare const _VERSION_: string; declare const _ENV_: string; declare const _DEV_: boolean; declare const _PERF_PREFIX_: string; -declare const _DATA_TRANSFER_DRIVE_FILE_: string; -declare const _DATA_TRANSFER_DRIVE_FOLDER_: string; -declare const _DATA_TRANSFER_DECK_COLUMN_: string; // for dev-mode declare const _LANGS_FULL_: string[][]; diff --git a/packages/frontend-embed/eslint.config.js b/packages/frontend-embed/eslint.config.js index 7805256fd4..2aef311e2e 100644 --- a/packages/frontend-embed/eslint.config.js +++ b/packages/frontend-embed/eslint.config.js @@ -30,9 +30,6 @@ export default [ _VERSION_: false, _ENV_: false, _PERF_PREFIX_: false, - _DATA_TRANSFER_DRIVE_FILE_: false, - _DATA_TRANSFER_DRIVE_FOLDER_: false, - _DATA_TRANSFER_DECK_COLUMN_: false, }, parser, parserOptions: { diff --git a/packages/frontend-shared/@types/global.d.ts b/packages/frontend-shared/@types/global.d.ts index 4b8d679e75..52081d07b3 100644 --- a/packages/frontend-shared/@types/global.d.ts +++ b/packages/frontend-shared/@types/global.d.ts @@ -11,9 +11,6 @@ declare const _VERSION_: string; declare const _ENV_: string; declare const _DEV_: boolean; declare const _PERF_PREFIX_: string; -declare const _DATA_TRANSFER_DRIVE_FILE_: string; -declare const _DATA_TRANSFER_DRIVE_FOLDER_: string; -declare const _DATA_TRANSFER_DECK_COLUMN_: string; // for dev-mode declare const _LANGS_FULL_: string[][]; diff --git a/packages/frontend-shared/eslint.config.js b/packages/frontend-shared/eslint.config.js index ac5c67d0b6..f6fd64153c 100644 --- a/packages/frontend-shared/eslint.config.js +++ b/packages/frontend-shared/eslint.config.js @@ -35,9 +35,6 @@ export default [ _VERSION_: false, _ENV_: false, _PERF_PREFIX_: false, - _DATA_TRANSFER_DRIVE_FILE_: false, - _DATA_TRANSFER_DRIVE_FOLDER_: false, - _DATA_TRANSFER_DECK_COLUMN_: false, }, parser, parserOptions: { diff --git a/packages/frontend-shared/themes/_dark.json5 b/packages/frontend-shared/themes/_dark.json5 index 8ebaf20b64..0d719ef35c 100644 --- a/packages/frontend-shared/themes/_dark.json5 +++ b/packages/frontend-shared/themes/_dark.json5 @@ -61,7 +61,6 @@ switchOnFg: '@accent', inputBorder: 'rgba(255, 255, 255, 0.1)', inputBorderHover: 'rgba(255, 255, 255, 0.2)', - driveFolderBg: ':alpha<0.3<@accent', badge: '#31b1ce', messageBg: '@bg', success: '#86b300', diff --git a/packages/frontend-shared/themes/_light.json5 b/packages/frontend-shared/themes/_light.json5 index 63ad95ff84..51fb697922 100644 --- a/packages/frontend-shared/themes/_light.json5 +++ b/packages/frontend-shared/themes/_light.json5 @@ -61,7 +61,6 @@ switchOnFg: '@fgOnAccent', inputBorder: 'rgba(0, 0, 0, 0.1)', inputBorderHover: 'rgba(0, 0, 0, 0.2)', - driveFolderBg: ':alpha<0.3<@accent', badge: '#31b1ce', messageBg: '@bg', success: '#86b300', diff --git a/packages/frontend-shared/themes/d-astro.json5 b/packages/frontend-shared/themes/d-astro.json5 index 6d34665528..8ddedbbb07 100644 --- a/packages/frontend-shared/themes/d-astro.json5 +++ b/packages/frontend-shared/themes/d-astro.json5 @@ -38,7 +38,6 @@ navIndicator: '@accent', buttonGradateA: '@accent', buttonGradateB: ':hue<-20<@accent', - driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':lighten<3<@fg', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', diff --git a/packages/frontend-shared/themes/d-u0.json5 b/packages/frontend-shared/themes/d-u0.json5 index 4f6c04b906..8ce6d25328 100644 --- a/packages/frontend-shared/themes/d-u0.json5 +++ b/packages/frontend-shared/themes/d-u0.json5 @@ -47,7 +47,6 @@ inputBorder: 'rgba(255, 255, 255, 0.1)', panelBorder: '" solid 1px var(--MI_THEME-divider)', navIndicator: '@indicator', - driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':lighten<3<@fg', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', diff --git a/packages/frontend-shared/themes/l-u0.json5 b/packages/frontend-shared/themes/l-u0.json5 index 35241986df..f685dfbb42 100644 --- a/packages/frontend-shared/themes/l-u0.json5 +++ b/packages/frontend-shared/themes/l-u0.json5 @@ -49,7 +49,6 @@ panelBorder: '" solid 1px var(--MI_THEME-divider)', navIndicator: '@indicator', buttonHoverBg: '#0000001a', - driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':lighten<3<@fg', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', diff --git a/packages/frontend-shared/themes/l-vivid.json5 b/packages/frontend-shared/themes/l-vivid.json5 index 5ad8d60728..487393ea7c 100644 --- a/packages/frontend-shared/themes/l-vivid.json5 +++ b/packages/frontend-shared/themes/l-vivid.json5 @@ -39,7 +39,6 @@ inputBorderHover: 'rgba(0, 0, 0, 0.2)', panelBorder: '" solid 1px var(--MI_THEME-divider)', navIndicator: '@accent', - driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':darken<3<@fg', fgOnWhite: '@accent', panelHeaderBg: ':lighten<3<@panel', diff --git a/packages/frontend/@types/global.d.ts b/packages/frontend/@types/global.d.ts index 1025d1bedb..8a067a78ec 100644 --- a/packages/frontend/@types/global.d.ts +++ b/packages/frontend/@types/global.d.ts @@ -10,9 +10,6 @@ declare const _VERSION_: string; declare const _ENV_: string; declare const _DEV_: boolean; declare const _PERF_PREFIX_: string; -declare const _DATA_TRANSFER_DRIVE_FILE_: string; -declare const _DATA_TRANSFER_DRIVE_FOLDER_: string; -declare const _DATA_TRANSFER_DECK_COLUMN_: string; // for dev-mode declare const _LANGS_FULL_: string[][]; diff --git a/packages/frontend/eslint.config.js b/packages/frontend/eslint.config.js index 1b9a9b68c0..8f835975a8 100644 --- a/packages/frontend/eslint.config.js +++ b/packages/frontend/eslint.config.js @@ -30,9 +30,6 @@ export default [ _VERSION_: false, _ENV_: false, _PERF_PREFIX_: false, - _DATA_TRANSFER_DRIVE_FILE_: false, - _DATA_TRANSFER_DRIVE_FOLDER_: false, - _DATA_TRANSFER_DECK_COLUMN_: false, }, parser, parserOptions: { diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index ba21394cbc..7f592fba79 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')" > - + @@ -35,27 +33,23 @@ import { onMounted, useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import Cropper from 'cropperjs'; import tinycolor from 'tinycolor2'; -import { apiUrl } from '@@/js/config.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import * as os from '@/os.js'; -import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { getProxiedImageUrl } from '@/utility/media-proxy.js'; -import { prefer } from '@/preferences.js'; + +const props = defineProps<{ + imageFile: File | Blob; + aspectRatio: number | null; + uploadFolder?: string | null; +}>(); const emit = defineEmits<{ - (ev: 'ok', cropped: Misskey.entities.DriveFile): void; + (ev: 'ok', cropped: File | Blob): void; (ev: 'cancel'): void; (ev: 'closed'): void; }>(); -const props = defineProps<{ - file: Misskey.entities.DriveFile; - aspectRatio: number; - uploadFolder?: string | null; -}>(); - -const imgUrl = getProxiedImageUrl(props.file.url, undefined, true); +const imgUrl = URL.createObjectURL(props.imageFile); const dialogEl = useTemplateRef('dialogEl'); const imgEl = useTemplateRef('imgEl'); let cropper: Cropper | null = null; @@ -73,31 +67,10 @@ const ok = async () => { const croppedCanvas = await croppedSection?.$toCanvas({ width: widthToRender }); croppedCanvas?.toBlob(blob => { if (!blob) return; - const formData = new FormData(); - formData.append('file', blob); - formData.append('name', `cropped_${props.file.name}`); - formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false'); - if (props.file.comment) { formData.append('comment', props.file.comment);} - formData.append('i', $i!.token); - if (props.uploadFolder) { - formData.append('folderId', props.uploadFolder); - } else if (props.uploadFolder !== null && prefer.s.uploadFolder) { - formData.append('folderId', prefer.s.uploadFolder); - } - - window.fetch(apiUrl + '/drive/files/create', { - method: 'POST', - body: formData, - }) - .then(response => response.json()) - .then(f => { - res(f); - }); + res(blob); }); }); - os.promiseDialog(promise); - const f = await promise; emit('ok', f); @@ -126,8 +99,8 @@ onMounted(() => { const selection = cropper.getCropperSelection()!; selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(); - selection.aspectRatio = props.aspectRatio; - selection.initialAspectRatio = props.aspectRatio; + if (props.aspectRatio != null) selection.aspectRatio = props.aspectRatio; + selection.initialAspectRatio = props.aspectRatio ?? 1; selection.outlined = true; window.setTimeout(() => { diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 70ab60cfae..bbb6a7ea2a 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -8,7 +8,6 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[$style.root, { [$style.isSelected]: isSelected }]" draggable="true" :title="title" - @click="onClick" @contextmenu.stop="onContextmenu" @dragstart="onDragstart" @dragend="onDragend" @@ -46,24 +45,18 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/i.js'; import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js'; -import { deviceKind } from '@/utility/device-kind.js'; -import { useRouter } from '@/router.js'; - -const router = useRouter(); +import { setDragData } from '@/drag-and-drop.js'; const props = withDefaults(defineProps<{ file: Misskey.entities.DriveFile; folder: Misskey.entities.DriveFolder | null; isSelected?: boolean; - selectMode?: boolean; }>(), { isSelected: false, - selectMode: false, }); const emit = defineEmits<{ - (ev: 'chosen', r: Misskey.entities.DriveFile): void; - (ev: 'dragstart'): void; + (ev: 'dragstart', dragEvent: DragEvent): void; (ev: 'dragend'): void; }>(); @@ -71,18 +64,6 @@ const isDragging = ref(false); const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`); -function onClick(ev: MouseEvent) { - if (props.selectMode) { - emit('chosen', props.file); - } else { - if (deviceKind === 'desktop') { - router.push(`/my/drive/file/${props.file.id}`); - } else { - os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); - } - } -} - function onContextmenu(ev: MouseEvent) { os.contextMenu(getDriveFileMenu(props.file, props.folder), ev); } @@ -90,11 +71,11 @@ function onContextmenu(ev: MouseEvent) { function onDragstart(ev: DragEvent) { if (ev.dataTransfer) { ev.dataTransfer.effectAllowed = 'move'; - ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(props.file)); + setDragData(ev, 'driveFiles', [props.file]); } isDragging.value = true; - emit('dragstart'); + emit('dragstart', ev); } function onDragend() { @@ -114,7 +95,7 @@ function onDragend() { &:hover { background: rgba(#000, 0.05); - > .label { + .label { &::before, &::after { background: #0b65a5; @@ -132,7 +113,7 @@ function onDragend() { &:active { background: rgba(#000, 0.1); - > .label { + .label { &::before, &::after { background: #0b588c; @@ -158,19 +139,19 @@ function onDragend() { background: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); } - > .label { + .label { &::before, &::after { display: none; } } - > .name { - color: #fff; + .name { + color: var(--MI_THEME-fgOnAccent); } - > .thumbnail { - color: #fff; + .thumbnail { + color: var(--MI_THEME-fgOnAccent); } } } @@ -240,8 +221,8 @@ function onDragend() { .name { display: block; - margin: 4px 0 0 0; - font-size: 0.8em; + margin: 8px 0 0 0; + font-size: 82%; text-align: center; word-break: break-all; color: var(--MI_THEME-fg); diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 9c72691d21..83472eec3d 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -8,7 +8,6 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[$style.root, { [$style.draghover]: draghover }]" draggable="true" :title="title" - @click="onClick" @contextmenu.stop="onContextmenu" @mouseover="onMouseover" @mouseout="onMouseout" @@ -19,14 +18,13 @@ SPDX-License-Identifier: AGPL-3.0-only @dragstart="onDragstart" @dragend="onDragend" > -

- - - {{ folder.name }} -

-

+ + + +

{{ folder.name }}
+
{{ i18n.ts.uploadFolder }} -

+
@@ -43,6 +41,9 @@ import { i18n } from '@/i18n.js'; import { claimAchievement } from '@/utility/achievements.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { prefer } from '@/preferences.js'; +import { globalEvents } from '@/events.js'; +import { checkDragDataType, getDragData, setDragData } from '@/drag-and-drop.js'; +import { selectDriveFolder } from '@/utility/drive.js'; const props = withDefaults(defineProps<{ folder: Misskey.entities.DriveFolder; @@ -56,10 +57,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'chosen', v: Misskey.entities.DriveFolder): void; (ev: 'unchose', v: Misskey.entities.DriveFolder): void; - (ev: 'move', v: Misskey.entities.DriveFolder): void; - (ev: 'upload', file: File, folder: Misskey.entities.DriveFolder); - (ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void; - (ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void; + (ev: 'upload', files: File[], folder: Misskey.entities.DriveFolder); (ev: 'dragstart'): void; (ev: 'dragend'): void; }>(); @@ -78,10 +76,6 @@ function checkboxClicked() { } } -function onClick() { - emit('move', props.folder); -} - function onMouseover() { hover.value = true; } @@ -101,10 +95,7 @@ function onDragover(ev: DragEvent) { } const isFile = ev.dataTransfer.items[0].kind === 'file'; - const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; - const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_; - - if (isFile || isDriveFile || isDriveFolder) { + if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) { switch (ev.dataTransfer.effectAllowed) { case 'all': case 'uninitialized': @@ -141,55 +132,64 @@ function onDrop(ev: DragEvent) { // ファイルだったら if (ev.dataTransfer.files.length > 0) { - for (const file of Array.from(ev.dataTransfer.files)) { - emit('upload', file, props.folder); - } + emit('upload', Array.from(ev.dataTransfer.files), props.folder); return; } //#region ドライブのファイル - const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile !== '') { - const file = JSON.parse(driveFile); - emit('removeFile', file.id); - misskeyApi('drive/files/update', { - fileId: file.id, - folderId: props.folder.id, - }); + { + const droppedData = getDragData(ev, 'driveFiles'); + if (droppedData != null) { + misskeyApi('drive/files/move-bulk', { + fileIds: droppedData.map(f => f.id), + folderId: props.folder.id, + }).then(() => { + globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({ + ...x, + folderId: props.folder.id, + folder: props.folder, + }))); + }); + } } //#endregion //#region ドライブのフォルダ - const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); - if (driveFolder != null && driveFolder !== '') { - const folder = JSON.parse(driveFolder); + { + const droppedData = getDragData(ev, 'driveFolders'); + if (droppedData != null) { + const droppedFolder = droppedData[0]; - // 移動先が自分自身ならreject - if (folder.id === props.folder.id) return; + // 移動先が自分自身ならreject + if (droppedFolder.id === props.folder.id) return; - emit('removeFolder', folder.id); - misskeyApi('drive/folders/update', { - folderId: folder.id, - parentId: props.folder.id, - }).then(() => { - // noop - }).catch(err => { - switch (err.code) { - case 'RECURSIVE_NESTING': - claimAchievement('driveFolderCircularReference'); - os.alert({ - type: 'error', - title: i18n.ts.unableToProcess, - text: i18n.ts.circularReferenceFolder, - }); - break; - default: - os.alert({ - type: 'error', - text: i18n.ts.somethingHappened, - }); - } - }); + misskeyApi('drive/folders/update', { + folderId: droppedFolder.id, + parentId: props.folder.id, + }).then(() => { + globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({ + ...x, + parentId: props.folder.id, + parent: props.folder, + }))); + }).catch(err => { + switch (err.code) { + case 'RECURSIVE_NESTING': + claimAchievement('driveFolderCircularReference'); + os.alert({ + type: 'error', + title: i18n.ts.unableToProcess, + text: i18n.ts.circularReferenceFolder, + }); + break; + default: + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + } + }); + } } //#endregion } @@ -198,7 +198,7 @@ function onDragstart(ev: DragEvent) { if (!ev.dataTransfer) return; ev.dataTransfer.effectAllowed = 'move'; - ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(props.folder)); + setDragData(ev, 'driveFolders', [props.folder]); isDragging.value = true; // 親ブラウザに対して、ドラッグが開始されたフラグを立てる @@ -211,10 +211,6 @@ function onDragend() { emit('dragend'); } -function go() { - emit('move', props.folder); -} - function rename() { os.inputText({ title: i18n.ts.renameFolder, @@ -225,17 +221,28 @@ function rename() { misskeyApi('drive/folders/update', { folderId: props.folder.id, name: name, + }).then(() => { + globalEvents.emit('driveFoldersUpdated', [{ + ...props.folder, + name: name, + }]); }); }); } function move() { - os.selectDriveFolder(false).then(folder => { + selectDriveFolder(null).then(folder => { if (folder[0] && folder[0].id === props.folder.id) return; misskeyApi('drive/folders/update', { folderId: props.folder.id, parentId: folder[0] ? folder[0].id : null, + }).then(() => { + globalEvents.emit('driveFoldersUpdated', [{ + ...props.folder, + parentId: folder[0] ? folder[0].id : null, + parent: folder[0] ?? null, + }]); }); }); } @@ -247,6 +254,7 @@ function deleteFolder() { if (prefer.s.uploadFolder === props.folder.id) { prefer.commit('uploadFolder', null); } + globalEvents.emit('driveFoldersDeleted', [props.folder]); }).catch(err => { switch (err.id) { case 'b0fc8a17-963c-405d-bfbc-859a487295e1': @@ -311,10 +319,9 @@ function onContextmenu(ev: MouseEvent) { diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 5114e98494..d07ee2a978 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -71,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ maxTextLength - textLength }}
- +
@@ -120,14 +120,13 @@ import { formatTimeString } from '@/utility/format-time-string.js'; import { Autocomplete } from '@/utility/autocomplete.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { selectFiles } from '@/utility/select-file.js'; +import { selectFiles } from '@/utility/drive.js'; import { store } from '@/store.js'; import MkInfo from '@/components/MkInfo.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import { ensureSignin, notesCount, incNotesCount } from '@/i.js'; import { getAccounts, openAccountMenu as openAccountMenu_ } from '@/accounts.js'; -import { uploadFile } from '@/utility/upload.js'; import { deepClone } from '@/utility/clone.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { miLocalStorage } from '@/local-storage.js'; @@ -138,6 +137,7 @@ import { prefer } from '@/preferences.js'; import { getPluginHandlers } from '@/plugin.js'; import { DI } from '@/di.js'; import { globalEvents } from '@/events.js'; +import { checkDragDataType, getDragData } from '@/drag-and-drop.js'; const $i = ensureSignin(); @@ -459,18 +459,6 @@ function updateFileName(file, name) { files.value[files.value.findIndex(x => x.id === file.id)].name = name; } -function replaceFile(file: Misskey.entities.DriveFile, newFile: Misskey.entities.DriveFile): void { - files.value[files.value.findIndex(x => x.id === file.id)] = newFile; -} - -function upload(file: File, name?: string): void { - if (props.mock) return; - - uploadFile(file, prefer.s.uploadFolder, name).then(res => { - files.value.push(res); - }); -} - function setVisibility() { if (props.channel) { visibility.value = 'public'; @@ -651,16 +639,25 @@ async function onPaste(ev: ClipboardEvent) { if (props.mock) return; if (!ev.clipboardData) return; + let pastedFiles: File[] = []; for (const { item, i } of Array.from(ev.clipboardData.items, (data, x) => ({ item: data, i: x }))) { if (item.kind === 'file') { const file = item.getAsFile(); if (!file) continue; const lio = file.name.lastIndexOf('.'); const ext = lio >= 0 ? file.name.slice(lio) : ''; - const formatted = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; - upload(file, formatted); + const formattedName = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; + const renamedFile = new File([file], formattedName, { type: file.type }); + pastedFiles.push(renamedFile); } } + if (pastedFiles.length > 0) { + ev.preventDefault(); + os.launchUploader(pastedFiles, {}).then(driveFiles => { + files.value.push(...driveFiles); + }); + return; + } const paste = ev.clipboardData.getData('text'); @@ -693,7 +690,9 @@ async function onPaste(ev: ClipboardEvent) { const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0'); const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' }); - upload(file, `${fileName}.txt`); + os.launchUploader([file], {}).then(driveFiles => { + files.value.push(...driveFiles); + }); }); } } @@ -701,8 +700,7 @@ async function onPaste(ev: ClipboardEvent) { function onDragover(ev) { if (!ev.dataTransfer.items[0]) return; const isFile = ev.dataTransfer.items[0].kind === 'file'; - const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; - if (isFile || isDriveFile) { + if (isFile || checkDragDataType(ev, ['driveFiles'])) { ev.preventDefault(); draghover.value = true; switch (ev.dataTransfer.effectAllowed) { @@ -738,16 +736,19 @@ function onDrop(ev: DragEvent): void { // ファイルだったら if (ev.dataTransfer && ev.dataTransfer.files.length > 0) { ev.preventDefault(); - for (const x of Array.from(ev.dataTransfer.files)) upload(x); + os.launchUploader(Array.from(ev.dataTransfer.files), {}).then(driveFiles => { + files.value.push(...driveFiles); + }); return; } //#region ドライブのファイル - const driveFile = ev.dataTransfer?.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile !== '') { - const file = JSON.parse(driveFile); - files.value.push(file); - ev.preventDefault(); + { + const droppedData = getDragData(ev, 'driveFiles'); + if (droppedData != null) { + files.value.push(...droppedData); + ev.preventDefault(); + } } //#endregion } diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index e8404cbd4f..dd594ef7f1 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -43,6 +43,7 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; +import { globalEvents } from '@/events.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -58,7 +59,6 @@ const emit = defineEmits<{ (ev: 'detach', id: string): void; (ev: 'changeSensitive', file: Misskey.entities.DriveFile, isSensitive: boolean): void; (ev: 'changeName', file: Misskey.entities.DriveFile, newName: string): void; - (ev: 'replaceFile', file: Misskey.entities.DriveFile, newFile: Misskey.entities.DriveFile): void; }>(); let menuShowing = false; @@ -82,12 +82,13 @@ async function detachAndDeleteMedia(file: Misskey.entities.DriveFile) { type: 'warning', text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }), }); - if (canceled) return; - os.apiWithDialog('drive/files/delete', { + await os.apiWithDialog('drive/files/delete', { fileId: file.id, }); + + globalEvents.emit('driveFilesDeleted', [file]); } function toggleSensitive(file) { @@ -142,13 +143,6 @@ async function describe(file: Misskey.entities.DriveFile) { }); } -async function crop(file: Misskey.entities.DriveFile): Promise { - if (mock) return; - - const newFile = await os.cropImage(file, { aspectRatio: NaN }); - emit('replaceFile', file, newFile); -} - function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | KeyboardEvent): void { if (menuShowing) return; @@ -172,10 +166,6 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar if (isImage) { menuItems.push({ - text: i18n.ts.cropImage, - icon: 'ti ti-crop', - action: () : void => { crop(file); }, - }, { text: i18n.ts.preview, icon: 'ti ti-photo-search', action: () => { diff --git a/packages/frontend/src/components/MkPreview.vue b/packages/frontend/src/components/MkPreview.vue index d8dfbd1655..6c7bf6be6b 100644 --- a/packages/frontend/src/components/MkPreview.vue +++ b/packages/frontend/src/components/MkPreview.vue @@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only Pleroma
- This is - the button + This is + the button
@@ -36,14 +36,15 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue index 30925b854c..4e96eff82e 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -37,7 +37,6 @@ import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import FormSlot from '@/components/form/slot.vue'; import MkInfo from '@/components/MkInfo.vue'; -import { chooseFileFromPc } from '@/utility/select-file.js'; import * as os from '@/os.js'; import { ensureSignin } from '@/i.js'; @@ -49,7 +48,7 @@ const description = ref($i.description ?? ''); watch(name, () => { os.apiWithDialog('i/update', { // 空文字列をnullにしたいので??は使うな - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + name: name.value || null, }, undefined, { '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191': { @@ -62,36 +61,37 @@ watch(name, () => { watch(description, () => { os.apiWithDialog('i/update', { // 空文字列をnullにしたいので??は使うな - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + description: description.value || null, }); }); -function setAvatar(ev) { - chooseFileFromPc(false).then(async (files) => { - const file = files[0]; +async function setAvatar(ev) { + const files = await os.chooseFileFromPc({ multiple: false }); + const file = files[0]; - let originalOrCropped = file; + let originalOrCropped = file; - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.ts.cropImageAsk, - okText: i18n.ts.cropYes, - cancelText: i18n.ts.cropNo, - }); - - if (!canceled) { - originalOrCropped = await os.cropImage(file, { - aspectRatio: 1, - }); - } - - const i = await os.apiWithDialog('i/update', { - avatarId: originalOrCropped.id, - }); - $i.avatarId = i.avatarId; - $i.avatarUrl = i.avatarUrl; + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.cropImageAsk, + okText: i18n.ts.cropYes, + cancelText: i18n.ts.cropNo, }); + + if (!canceled) { + originalOrCropped = await os.cropImageFile(file, { + aspectRatio: 1, + }); + } + + const driveFile = (await os.launchUploader([originalOrCropped], { multiple: false }))[0]; + + const i = await os.apiWithDialog('i/update', { + avatarId: driveFile.id, + }); + $i.avatarId = i.avatarId; + $i.avatarUrl = i.avatarUrl; } diff --git a/packages/frontend/src/components/global/MkSystemIcon.vue b/packages/frontend/src/components/global/MkSystemIcon.vue index 3454cdc9f2..d2ef0fb2d8 100644 --- a/packages/frontend/src/components/global/MkSystemIcon.vue +++ b/packages/frontend/src/components/global/MkSystemIcon.vue @@ -28,13 +28,17 @@ 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/drag-and-drop.ts b/packages/frontend/src/drag-and-drop.ts new file mode 100644 index 0000000000..3c6f22f24b --- /dev/null +++ b/packages/frontend/src/drag-and-drop.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; + +type DragDataMap = { + driveFiles: Misskey.entities.DriveFile[]; + driveFolders: Misskey.entities.DriveFolder[]; + deckColumn: string; +}; + +// NOTE: dataTransfer の format は大文字小文字区別されないっぽいので toLowerCase が必要 + +export function setDragData( + event: DragEvent, + type: T, + data: DragDataMap[T], +) { + if (event.dataTransfer == null) return; + + event.dataTransfer.setData(`misskey/${type}`.toLowerCase(), JSON.stringify(data)); +} + +export function getDragData( + event: DragEvent, + type: T, +): DragDataMap[T] | null { + if (event.dataTransfer == null) return null; + + const data = event.dataTransfer.getData(`misskey/${type}`.toLowerCase()); + if (data == null || data === '') return null; + + return JSON.parse(data); +} + +export function checkDragDataType( + event: DragEvent, + types: (keyof DragDataMap)[], +): boolean { + if (event.dataTransfer == null) return false; + + const dataType = event.dataTransfer.types[0]; + if (dataType == null || dataType === '') return false; + + return types.some((type) => `misskey/${type}`.toLowerCase() === dataType.toLowerCase()); +} diff --git a/packages/frontend/src/events.ts b/packages/frontend/src/events.ts index 26b1881d15..649561cd75 100644 --- a/packages/frontend/src/events.ts +++ b/packages/frontend/src/events.ts @@ -13,6 +13,11 @@ type Events = { clientNotification: (notification: Misskey.entities.Notification) => void; notePosted: (note: Misskey.entities.Note) => void; noteDeleted: (noteId: Misskey.entities.Note['id']) => void; + driveFileCreated: (file: Misskey.entities.DriveFile) => void; + driveFilesUpdated: (files: Misskey.entities.DriveFile[]) => void; + driveFilesDeleted: (files: Misskey.entities.DriveFile[]) => void; + driveFoldersUpdated: (folders: Misskey.entities.DriveFolder[]) => void; + driveFoldersDeleted: (folders: Misskey.entities.DriveFolder[]) => void; }; export const globalEvents = new EventEmitter(); diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index d891525782..6d49408f26 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -592,38 +592,6 @@ export async function selectUser(opts: { includeSelf?: boolean; localOnly?: bool }); } -export async function selectDriveFile(multiple: boolean): Promise { - return new Promise(resolve => { - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { - type: 'file', - multiple, - }, { - done: files => { - if (files) { - resolve(files); - } - }, - closed: () => dispose(), - }); - }); -} - -export async function selectDriveFolder(multiple: boolean): Promise { - return new Promise(resolve => { - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { - type: 'folder', - multiple, - }, { - done: folders => { - if (folders) { - resolve(folders); - } - }, - closed: () => dispose(), - }); - }); -} - export async function selectRole(params: ComponentProps): Promise< { canceled: true; result: undefined; } | { canceled: false; result: Misskey.entities.Role[] } @@ -655,15 +623,13 @@ export async function pickEmoji(src: HTMLElement, opts: ComponentProps { +export async function cropImageFile(imageFile: File | Blob, options: { + aspectRatio: number | null; +}): Promise { return new Promise(resolve => { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), { - file: image, + imageFile: imageFile, aspectRatio: options.aspectRatio, - uploadFolder: options.uploadFolder, }, { ok: x => { resolve(x); @@ -775,3 +741,52 @@ export function checkExistence(fileData: ArrayBuffer): Promise { }); }); }*/ + +export function chooseFileFromPc( + options: { + multiple?: boolean; + } = {}, +): Promise { + return new Promise((res, rej) => { + const input = window.document.createElement('input'); + input.type = 'file'; + input.multiple = options.multiple ?? false; + input.onchange = () => { + if (!input.files) return res([]); + + res(Array.from(input.files)); + + // 一応廃棄 + (window as any).__misskey_input_ref__ = null; + }; + + // https://qiita.com/fukasawah/items/b9dc732d95d99551013d + // iOS Safari で正常に動かす為のおまじない + (window as any).__misskey_input_ref__ = input; + + input.click(); + }); +} + +export function launchUploader( + files: File[], + options?: { + folderId?: string | null; + multiple?: boolean; + }, +): Promise { + return new Promise((res, rej) => { + if (files.length === 0) return rej(); + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUploaderDialog.vue')), { + files: markRaw(files), + folderId: options?.folderId, + multiple: options?.multiple, + }, { + done: driveFiles => { + if (driveFiles.length === 0) return rej(); + res(driveFiles); + }, + closed: () => dispose(), + }); + }); +} diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue index 0ff251abb8..68c7048ae1 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue @@ -87,7 +87,7 @@ import MkButton from '@/components/MkButton.vue'; import { validators } from '@/components/grid/cell-validators.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import MkPagingButtons from '@/components/MkPagingButtons.vue'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import { copyGridDataToClipboard, removeDataFromGrid } from '@/components/grid/grid-utils.js'; import { useLoading } from '@/composables/use-loading.js'; 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..e1dabe549f 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue @@ -35,20 +35,9 @@ SPDX-License-Identifier: AGPL-3.0-only -
-
- {{ i18n.ts._customEmojisManager._local._register.emojiInputAreaCaption }} -
- +
+ {{ i18n.ts.uplaod }} + {{ i18n.ts.fromDrive }}
@@ -94,8 +83,7 @@ import MkFolder from '@/components/MkFolder.vue'; 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 { chooseDriveFile, chooseFileFromPcAndUpload } from '@/utility/drive.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'; @@ -311,75 +299,21 @@ async function onClearClicked() { } } -async function onDrop(ev: DragEvent) { - isDragOver.value = false; - - const droppedFiles = await extractDroppedItems(ev).then(it => flattenDroppedFiles(it)); - const confirm = await os.confirm({ - type: 'info', - text: i18n.tsx._customEmojisManager._local._register.confirmUploadEmojisDescription({ count: droppedFiles.length }), - }); - if (confirm.canceled) { - return; - } - - const uploadedItems = Array.of<{ droppedFile: DroppedFile, driveFile: Misskey.entities.DriveFile }>(); - try { - uploadedItems.push( - ...await os.promiseDialog( - Promise.all( - droppedFiles.map(async (it) => ({ - droppedFile: it, - driveFile: await uploadFile( - it.file, - selectedFolderId.value, - it.file.name.replace(/\.[^.]+$/, ''), - true, - ), - }), - ), - ), - () => { - }, - () => { - }, - ), - ); - } catch (err) { - // ダイアログは共通部品側で出ているはずなので何もしない - return; - } - - const items = uploadedItems.map(({ droppedFile, driveFile }) => { - const item = fromDriveFile(driveFile); - if (directoryToCategory.value) { - item.category = droppedFile.path - .replace(/^\//, '') - .replace(/\/[^/]+$/, '') - .replace(droppedFile.file.name, ''); - } - return item; - }); - - gridItems.value.push(...items); -} - async function onFileSelectClicked() { - const driveFiles = await chooseFileFromPc( - true, - { - uploadFolder: selectedFolderId.value, - keepOriginal: true, - // 拡張子は消す - nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''), - }, - ); + const driveFiles = await chooseFileFromPcAndUpload({ + multiple: true, + folderId: selectedFolderId.value, + // 拡張子は消す + nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''), + }); gridItems.value.push(...driveFiles.map(fromDriveFile)); } async function onDriveSelectClicked() { - const driveFiles = await chooseFileFromDrive(true); + const driveFiles = await chooseDriveFile({ + multiple: true, + }); gridItems.value.push(...driveFiles.map(fromDriveFile)); } @@ -436,23 +370,6 @@ onMounted(async () => { background-color: var(--MI_THEME-infoWarnBg); } -.uploadBox { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - width: 100%; - height: auto; - border: 0.5px dotted var(--MI_THEME-accentedBg); - border-radius: var(--MI-radius); - background-color: var(--MI_THEME-accentedBg); - box-sizing: border-box; - - &.dragOver { - cursor: copy; - } -} - .gridArea { padding-top: 8px; padding-bottom: 8px; diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue index 009514cdc8..355b5464a1 100644 --- a/packages/frontend/src/pages/channel-editor.vue +++ b/packages/frontend/src/pages/channel-editor.vue @@ -73,7 +73,7 @@ import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkColorInput from '@/components/MkColorInput.vue'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { definePage } from '@/page.js'; diff --git a/packages/frontend/src/pages/chat/room.form.vue b/packages/frontend/src/pages/chat/room.form.vue index 9389b16ce7..7e3be67230 100644 --- a/packages/frontend/src/pages/chat/room.form.vue +++ b/packages/frontend/src/pages/chat/room.form.vue @@ -38,15 +38,15 @@ import { onMounted, watch, ref, shallowRef, computed, nextTick, readonly, onBefo import * as Misskey from 'misskey-js'; //import insertTextAtCursor from 'insert-text-at-cursor'; import { formatTimeString } from '@/utility/format-time-string.js'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.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'; import { Autocomplete } from '@/utility/autocomplete.js'; import { emojiPicker } from '@/utility/emoji-picker.js'; +import { checkDragDataType, getDragData } from '@/drag-and-drop.js'; const props = defineProps<{ user?: Misskey.entities.UserDetailed | null; @@ -84,8 +84,11 @@ async function onPaste(ev: ClipboardEvent) { if (!pastedFile) return; const lio = pastedFile.name.lastIndexOf('.'); const ext = lio >= 0 ? pastedFile.name.slice(lio) : ''; - const formatted = formatTimeString(new Date(pastedFile.lastModified), pastedFileName).replace(/{{number}}/g, '1') + ext; - if (formatted) upload(pastedFile, formatted); + const formattedName = formatTimeString(new Date(pastedFile.lastModified), pastedFileName).replace(/{{number}}/g, '1') + ext; + const renamedFile = new File([pastedFile], formattedName, { type: pastedFile.type }); + os.launchUploader([renamedFile], { multiple: false }).then(driveFiles => { + file.value = driveFiles[0]; + }); } } else { if (items[0].kind === 'file') { @@ -101,8 +104,7 @@ function onDragover(ev: DragEvent) { if (!ev.dataTransfer) return; const isFile = ev.dataTransfer.items[0].kind === 'file'; - const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; - if (isFile || isDriveFile) { + if (isFile || checkDragDataType(ev, ['driveFiles'])) { ev.preventDefault(); switch (ev.dataTransfer.effectAllowed) { case 'all': @@ -129,7 +131,7 @@ function onDrop(ev: DragEvent): void { // ファイルだったら if (ev.dataTransfer.files.length === 1) { ev.preventDefault(); - upload(ev.dataTransfer.files[0]); + os.launchUploader([Array.from(ev.dataTransfer.files)[0]], { multiple: false }); return; } else if (ev.dataTransfer.files.length > 1) { ev.preventDefault(); @@ -141,10 +143,12 @@ function onDrop(ev: DragEvent): void { } //#region ドライブのファイル - const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile !== '') { - file.value = JSON.parse(driveFile); - ev.preventDefault(); + { + const droppedData = getDragData(ev, 'driveFiles'); + if (droppedData != null) { + file.value = droppedData[0]; + ev.preventDefault(); + } } //#endregion } @@ -172,13 +176,11 @@ function chooseFile(ev: MouseEvent) { function onChangeFile() { if (fileEl.value == null || fileEl.value.files == null) return; - if (fileEl.value.files[0]) upload(fileEl.value.files[0]); -} - -function upload(fileToUpload: File, name?: string) { - uploadFile(fileToUpload, prefer.s.uploadFolder, name).then(res => { - file.value = res; - }); + if (fileEl.value.files[0]) { + os.launchUploader(Array.from(fileEl.value.files), { multiple: false }).then(driveFiles => { + file.value = driveFiles[0]; + }); + } } function send() { diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index 46e494e6f6..c2bc621f6a 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -78,7 +78,7 @@ import MkPagination from '@/components/MkPagination.vue'; import MkRemoteEmojiEditDialog from '@/components/MkRemoteEmojiEditDialog.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import FormSplit from '@/components/form/split.vue'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { getProxiedImageUrl } from '@/utility/media-proxy.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/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue index 21be0b18a9..e8ac13c223 100644 --- a/packages/frontend/src/pages/drive.file.info.vue +++ b/packages/frontend/src/pages/drive.file.info.vue @@ -20,9 +20,6 @@ SPDX-License-Identifier: AGPL-3.0-only - @@ -83,6 +80,8 @@ import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { useRouter } from '@/router.js'; +import { selectDriveFolder } from '@/utility/drive.js'; +import { globalEvents } from '@/events.js'; const router = useRouter(); @@ -127,19 +126,10 @@ function postThis() { }); } -function crop() { - if (!file.value) return; - - os.cropImage(file.value, { - aspectRatio: NaN, - uploadFolder: file.value.folderId ?? null, - }); -} - function move() { if (!file.value) return; - os.selectDriveFolder(false).then(folder => { + selectDriveFolder(null).then(folder => { misskeyApi('drive/files/update', { fileId: file.value.id, folderId: folder[0] ? folder[0].id : null, @@ -210,12 +200,14 @@ async function deleteFile() { type: 'warning', text: i18n.tsx.driveFileDeleteConfirm({ name: file.value.name }), }); - if (canceled) return; + await os.apiWithDialog('drive/files/delete', { fileId: file.value.id, }); + globalEvents.emit('driveFilesDeleted', [file.value]); + router.push('/my/drive'); } diff --git a/packages/frontend/src/pages/drive.vue b/packages/frontend/src/pages/drive.vue index bee54f3fd2..38939f9503 100644 --- a/packages/frontend/src/pages/drive.vue +++ b/packages/frontend/src/pages/drive.vue @@ -5,14 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only - - diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue index 2085c73e03..4e79b301e3 100644 --- a/packages/frontend/src/ui/deck/column.vue +++ b/packages/frontend/src/ui/deck/column.vue @@ -51,6 +51,7 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; +import { checkDragDataType, getDragData, setDragData } from '@/drag-and-drop.js'; provide('shouldHeaderThin', true); provide('shouldOmitHeaderTitle', true); @@ -262,7 +263,7 @@ function goTop() { function onDragstart(ev) { ev.dataTransfer.effectAllowed = 'move'; - ev.dataTransfer.setData(_DATA_TRANSFER_DECK_COLUMN_, props.column.id); + setDragData(ev, 'deckColumn', props.column.id); // Chromeのバグで、Dragstartハンドラ内ですぐにDOMを変更する(=リアクティブなプロパティを変更する)とDragが終了してしまう // SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately @@ -281,7 +282,7 @@ function onDragover(ev) { // 自分自身にはドロップさせない ev.dataTransfer.dropEffect = 'none'; } else { - const isDeckColumn = ev.dataTransfer.types[0] === _DATA_TRANSFER_DECK_COLUMN_; + const isDeckColumn = checkDragDataType(ev, ['deckColumn']); ev.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none'; @@ -297,8 +298,8 @@ function onDrop(ev) { draghover.value = false; os.deckGlobalEvents.emit('column.dragEnd'); - const id = ev.dataTransfer.getData(_DATA_TRANSFER_DECK_COLUMN_); - if (id != null && id !== '') { + const id = getDragData(ev, 'deckColumn'); + if (id != null) { swapColumn(props.column.id, id); } } diff --git a/packages/frontend/src/utility/drive.ts b/packages/frontend/src/utility/drive.ts new file mode 100644 index 0000000000..e29b010c81 --- /dev/null +++ b/packages/frontend/src/utility/drive.ts @@ -0,0 +1,246 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineAsyncComponent } from 'vue'; +import * as Misskey from 'misskey-js'; +import { apiUrl } from '@@/js/config.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 { prefer } from '@/preferences.js'; +import { $i } from '@/i.js'; +import { instance } from '@/instance.js'; +import { globalEvents } from '@/events.js'; +import { getProxiedImageUrl } from '@/utility/media-proxy.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) => { + if ($i == null) return reject(); + + 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(); + } + + 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.cannotUploadBecauseInappropriate, + }); + } else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') { + os.alert({ + type: 'error', + 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}`, + }); + } + } else { + os.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); + globalEvents.emit('driveFileCreated', driveFile); + resolve(driveFile); + }) as (ev: ProgressEvent) => any; + + if (options.onProgress) { + xhr.upload.onprogress = ev => { + if (ev.lengthComputable) { + options.onProgress({ + total: ev.total, + loaded: ev.loaded, + }); + } + }; + } + + 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); + }); +} + +export function chooseFileFromPcAndUpload( + options: { + multiple?: boolean; + folderId?: string | null; + } = {}, +): Promise { + return new Promise((res, rej) => { + os.chooseFileFromPc({ multiple: options.multiple }).then(files => { + if (files.length === 0) return; + os.launchUploader(files, { + folderId: options.folderId, + }).then(driveFiles => { + res(driveFiles); + }); + }); + }); +} + +export function chooseDriveFile(options: { + multiple?: boolean; +} = {}): Promise { + return new Promise(resolve => { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveFileSelectDialog.vue')), { + multiple: options.multiple, + }, { + done: files => { + if (files) { + resolve(files); + } + }, + closed: () => dispose(), + }); + }); +} + +export function chooseFileFromUrl(): Promise { + return new Promise((res, rej) => { + os.inputText({ + title: i18n.ts.uploadFromUrl, + type: 'url', + placeholder: i18n.ts.uploadFromUrlDescription, + }).then(({ canceled, result: url }) => { + if (canceled) return; + + const marker = Math.random().toString(); // TODO: UUIDとか使う + + // TODO: no websocketモード対応 + const connection = useStream().useChannel('main'); + connection.on('urlUploadFinished', urlResponse => { + if (urlResponse.marker === marker) { + res(urlResponse.file); + connection.dispose(); + } + }); + + misskeyApi('drive/files/upload-from-url', { + url: url, + folderId: prefer.s.uploadFolder, + marker, + }); + + os.alert({ + title: i18n.ts.uploadFromUrlRequested, + text: i18n.ts.uploadFromUrlMayTakeTime, + }); + }); + }); +} + +function select(src: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise { + return new Promise((res, rej) => { + os.popupMenu([label ? { + text: label, + type: 'label', + } : undefined, { + text: i18n.ts.upload, + icon: 'ti ti-upload', + action: () => chooseFileFromPcAndUpload({ multiple }).then(files => res(files)), + }, { + text: i18n.ts.fromDrive, + icon: 'ti ti-cloud', + action: () => chooseDriveFile({ multiple }).then(files => res(files)), + }, { + text: i18n.ts.fromUrl, + icon: 'ti ti-link', + action: () => chooseFileFromUrl().then(file => res([file])), + }], src); + }); +} + +export function selectFile(src: HTMLElement | EventTarget | null, label: string | null = null): Promise { + return select(src, label, false).then(files => files[0]); +} + +export function selectFiles(src: HTMLElement | EventTarget | null, label: string | null = null): Promise { + return select(src, label, true); +} + +export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFile: Misskey.entities.DriveFile, options: { + aspectRatio: number | null; +}): Promise { + return new Promise(resolve => { + const imgUrl = getProxiedImageUrl(imageDriveFile.url, undefined, true); + const image = new Image(); + image.src = imgUrl; + image.onload = () => { + const canvas = window.document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + canvas.width = image.width; + canvas.height = image.height; + ctx.drawImage(image, 0, 0); + canvas.toBlob(blob => { + os.cropImageFile(blob, { + aspectRatio: options.aspectRatio, + }).then(croppedImageFile => { + uploadFile(croppedImageFile, { + name: imageDriveFile.name, + folderId: imageDriveFile.folderId, + }).then(driveFile => { + resolve(driveFile); + }); + }); + }); + }; + }); +} + +export async function selectDriveFolder(initialFolder: Misskey.entities.DriveFolder['id'] | null): Promise { + return new Promise(resolve => { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveFolderSelectDialog.vue')), { + initialFolder, + }, { + done: folders => { + if (folders) { + resolve(folders); + } + }, + closed: () => dispose(), + }); + }); +} diff --git a/packages/frontend/src/utility/get-drive-file-menu.ts b/packages/frontend/src/utility/get-drive-file-menu.ts index 3c6cbba002..4b4bab3125 100644 --- a/packages/frontend/src/utility/get-drive-file-menu.ts +++ b/packages/frontend/src/utility/get-drive-file-menu.ts @@ -5,12 +5,14 @@ import * as Misskey from 'misskey-js'; import { defineAsyncComponent } from 'vue'; +import { selectDriveFolder } from './drive.js'; import type { MenuItem } from '@/types/menu.js'; import { i18n } from '@/i18n.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { prefer } from '@/preferences.js'; +import { globalEvents } from '@/events.js'; function rename(file: Misskey.entities.DriveFile) { os.inputText({ @@ -42,7 +44,7 @@ function describe(file: Misskey.entities.DriveFile) { } function move(file: Misskey.entities.DriveFile) { - os.selectDriveFolder(false).then(folder => { + selectDriveFolder(null).then(folder => { misskeyApi('drive/files/update', { fileId: file.id, folderId: folder[0] ? folder[0].id : null, @@ -77,11 +79,13 @@ async function deleteFile(file: Misskey.entities.DriveFile) { type: 'warning', text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }), }); - if (canceled) return; - misskeyApi('drive/files/delete', { + + await os.apiWithDialog('drive/files/delete', { fileId: file.id, }); + + globalEvents.emit('driveFilesDeleted', [file]); } export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] { @@ -112,17 +116,6 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss action: () => describe(file), }); - if (isImage) { - menuItems.push({ - text: i18n.ts.cropImage, - icon: 'ti ti-crop', - action: () => os.cropImage(file, { - aspectRatio: NaN, - uploadFolder: folder ? folder.id : folder, - }), - }); - } - menuItems.push({ type: 'divider' }, { text: i18n.ts.createNoteFromTheFile, icon: 'ti ti-pencil', 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 deleted file mode 100644 index 731ef58302..0000000000 --- a/packages/frontend/src/utility/select-file.ts +++ /dev/null @@ -1,128 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { 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) => { - const input = window.document.createElement('input'); - input.type = 'file'; - 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 内でハンドリングされているためアラートダイアログを出したりはしてはいけない - }); - - // 一応廃棄 - (window as any).__misskey_input_ref__ = null; - }; - - // https://qiita.com/fukasawah/items/b9dc732d95d99551013d - // iOS Safari で正常に動かす為のおまじない - (window as any).__misskey_input_ref__ = input; - - input.click(); - }); -} - -export function chooseFileFromDrive(multiple: boolean): Promise { - return new Promise((res, rej) => { - os.selectDriveFile(multiple).then(files => { - res(files); - }); - }); -} - -export function chooseFileFromUrl(): Promise { - return new Promise((res, rej) => { - os.inputText({ - title: i18n.ts.uploadFromUrl, - type: 'url', - placeholder: i18n.ts.uploadFromUrlDescription, - }).then(({ canceled, result: url }) => { - if (canceled) return; - - const marker = Math.random().toString(); // TODO: UUIDとか使う - - const connection = useStream().useChannel('main'); - connection.on('urlUploadFinished', urlResponse => { - if (urlResponse.marker === marker) { - res(urlResponse.file); - connection.dispose(); - } - }); - - misskeyApi('drive/files/upload-from-url', { - url: url, - folderId: prefer.s.uploadFolder, - marker, - }); - - os.alert({ - title: i18n.ts.uploadFromUrlRequested, - text: i18n.ts.uploadFromUrlMayTakeTime, - }); - }); - }); -} - -function select(src: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise { - return new Promise((res, rej) => { - os.popupMenu([label ? { - 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)), - }, { - text: i18n.ts.fromDrive, - icon: 'ti ti-cloud', - action: () => chooseFileFromDrive(multiple).then(files => res(files)), - }, { - text: i18n.ts.fromUrl, - icon: 'ti ti-link', - action: () => chooseFileFromUrl().then(file => res([file])), - }], src); - }); -} - -export function selectFile(src: HTMLElement | EventTarget | null, label: string | null = null): Promise { - return select(src, label, false).then(files => files[0]); -} - -export function selectFiles(src: HTMLElement | EventTarget | null, label: string | null = null): Promise { - return select(src, label, true); -} diff --git a/packages/frontend/src/utility/timeline-date-separate.ts b/packages/frontend/src/utility/timeline-date-separate.ts index 1071a80962..33ddea048b 100644 --- a/packages/frontend/src/utility/timeline-date-separate.ts +++ b/packages/frontend/src/utility/timeline-date-separate.ts @@ -4,7 +4,7 @@ */ import { computed } from 'vue'; -import type { Ref } from 'vue'; +import type { Ref, ShallowRef } from 'vue'; export function getDateText(dateInstance: Date) { const date = dateInstance.getDate(); @@ -12,19 +12,6 @@ export function getDateText(dateInstance: Date) { return `${month.toString()}/${date.toString()}`; } -export type DateSeparetedTimelineItem = { - id: string; - type: 'item'; - data: T; -} | { - id: string; - type: 'date'; - prev: Date; - prevText: string; - next: Date; - nextText: string; -}; - // TODO: いちいちDateインスタンス作成するのは無駄感あるから文字列のまま解析したい export function isSeparatorNeeded( prev: string | null, @@ -56,7 +43,20 @@ export function getSeparatorInfo( }; } -export function makeDateSeparatedTimelineComputedRef(items: Ref) { +export type DateSeparetedTimelineItem = { + id: string; + type: 'item'; + data: T; +} | { + id: string; + type: 'date'; + prev: Date; + prevText: string; + next: Date; + nextText: string; +}; + +export function makeDateSeparatedTimelineComputedRef(items: Ref | ShallowRef) { return computed[]>(() => { const tl: DateSeparetedTimelineItem[] = []; for (let i = 0; i < items.value.length; i++) { @@ -92,3 +92,35 @@ export function makeDateSeparatedTimelineComputedRef = { + date: Date; + items: T[]; +}; + +export function makeDateGroupedTimelineComputedRef(items: Ref | ShallowRef, span: 'day' | 'month' = 'day') { + return computed[]>(() => { + const tl: DateGroupedTimelineItem[] = []; + for (let i = 0; i < items.value.length; i++) { + const item = items.value[i]; + const date = new Date(item.createdAt); + const nextDate = items.value[i + 1] ? new Date(items.value[i + 1].createdAt) : null; + + if (tl.length === 0 || ( + span === 'day' && tl[tl.length - 1].date.getTime() !== date.getTime() + ) || ( + span === 'month' && ( + tl[tl.length - 1].date.getFullYear() !== date.getFullYear() || + tl[tl.length - 1].date.getMonth() !== date.getMonth() + ) + )) { + tl.push({ + date, + items: [], + }); + } + tl[tl.length - 1].items.push(item); + } + return tl; + }); +} diff --git a/packages/frontend/src/utility/upload.ts b/packages/frontend/src/utility/upload.ts deleted file mode 100644 index 03240749e9..0000000000 --- a/packages/frontend/src/utility/upload.ts +++ /dev/null @@ -1,162 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * 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(); - } - - return new Promise((resolve, 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: prefer.s.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', $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); - }); -} 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, - }; -} diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue index 2ccbb7a28f..3fe8cfa7e6 100644 --- a/packages/frontend/src/widgets/WidgetSlideshow.vue +++ b/packages/frontend/src/widgets/WidgetSlideshow.vue @@ -26,6 +26,7 @@ import type { GetFormResultType } from '@/utility/form.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { selectDriveFolder } from '@/utility/drive.js'; const name = 'slideshow'; @@ -93,7 +94,7 @@ const fetch = () => { }; const choose = () => { - os.selectDriveFolder(false).then(folder => { + selectDriveFolder(null).then(folder => { if (folder[0] == null) { return; } diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index aa7bf24174..71c133acc8 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -148,9 +148,6 @@ export function getConfig(): UserConfig { _ENV_: JSON.stringify(process.env.NODE_ENV), _DEV_: process.env.NODE_ENV !== 'production', _PERF_PREFIX_: JSON.stringify('Misskey:'), - _DATA_TRANSFER_DRIVE_FILE_: JSON.stringify('mk_drive_file'), - _DATA_TRANSFER_DRIVE_FOLDER_: JSON.stringify('mk_drive_folder'), - _DATA_TRANSFER_DECK_COLUMN_: JSON.stringify('mk_deck_column'), __VUE_OPTIONS_API__: true, __VUE_PROD_DEVTOOLS__: false, }, diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index f085240f84..a305087fdb 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1247,6 +1247,9 @@ type DriveFilesFindRequest = operations['drive___files___find']['requestBody'][' // @public (undocumented) type DriveFilesFindResponse = operations['drive___files___find']['responses']['200']['content']['application/json']; +// @public (undocumented) +type DriveFilesMoveBulkRequest = operations['drive___files___move-bulk']['requestBody']['content']['application/json']; + // @public (undocumented) type DriveFilesRequest = operations['drive___files']['requestBody']['content']['application/json']; @@ -1732,6 +1735,7 @@ declare namespace entities { DriveFilesFindResponse, DriveFilesFindByHashRequest, DriveFilesFindByHashResponse, + DriveFilesMoveBulkRequest, DriveFilesShowRequest, DriveFilesShowResponse, DriveFilesUpdateRequest, diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 12b51a4ac0..537118b9cd 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -2073,6 +2073,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:drive* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * Show the properties of a drive file. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 13adaa9efd..a108cba7c1 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -282,6 +282,7 @@ import type { DriveFilesFindResponse, DriveFilesFindByHashRequest, DriveFilesFindByHashResponse, + DriveFilesMoveBulkRequest, DriveFilesShowRequest, DriveFilesShowResponse, DriveFilesUpdateRequest, @@ -823,6 +824,7 @@ export type Endpoints = { 'drive/files/delete': { req: DriveFilesDeleteRequest; res: EmptyResponse }; 'drive/files/find': { req: DriveFilesFindRequest; res: DriveFilesFindResponse }; 'drive/files/find-by-hash': { req: DriveFilesFindByHashRequest; res: DriveFilesFindByHashResponse }; + 'drive/files/move-bulk': { req: DriveFilesMoveBulkRequest; res: EmptyResponse }; 'drive/files/show': { req: DriveFilesShowRequest; res: DriveFilesShowResponse }; 'drive/files/update': { req: DriveFilesUpdateRequest; res: DriveFilesUpdateResponse }; 'drive/files/upload-from-url': { req: DriveFilesUploadFromUrlRequest; res: EmptyResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 2030e8ae5d..4b18cda5d8 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -285,6 +285,7 @@ export type DriveFilesFindRequest = operations['drive___files___find']['requestB export type DriveFilesFindResponse = operations['drive___files___find']['responses']['200']['content']['application/json']; export type DriveFilesFindByHashRequest = operations['drive___files___find-by-hash']['requestBody']['content']['application/json']; export type DriveFilesFindByHashResponse = operations['drive___files___find-by-hash']['responses']['200']['content']['application/json']; +export type DriveFilesMoveBulkRequest = operations['drive___files___move-bulk']['requestBody']['content']['application/json']; export type DriveFilesShowRequest = operations['drive___files___show']['requestBody']['content']['application/json']; export type DriveFilesShowResponse = operations['drive___files___show']['responses']['200']['content']['application/json']; export type DriveFilesUpdateRequest = operations['drive___files___update']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 1abc721997..4e2c03c784 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -1799,6 +1799,15 @@ export type paths = { */ post: operations['drive___files___find-by-hash']; }; + '/drive/files/move-bulk': { + /** + * drive/files/move-bulk + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:drive* + */ + post: operations['drive___files___move-bulk']; + }; '/drive/files/show': { /** * drive/files/show @@ -16845,6 +16854,59 @@ export type operations = { }; }; }; + /** + * drive/files/move-bulk + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:drive* + */ + 'drive___files___move-bulk': { + requestBody: { + content: { + 'application/json': { + fileIds: string[]; + /** Format: misskey:id */ + folderId?: string | null; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * drive/files/show * @description Show the properties of a drive file.