From 8c2ab25e5f2040fcbc81bc2a02a279fed40e1c11 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 9 May 2025 17:40:08 +0900 Subject: Feat: No websocket mode (#15851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * wip * wip * wip * Update MkTimeline.vue * wip * wip * wip * Update MkTimeline.vue * Update use-pagination.ts * wip * wip * Update MkTimeline.vue * Update MkTimeline.vue * wip * wip * Update MkTimeline.vue * Update MkTimeline.vue * Update MkTimeline.vue * wip * Update use-pagination.ts * wip * Update use-pagination.ts * Update MkNotifications.vue * Update MkNotifications.vue * wip * wip * wip * Update use-note-capture.ts * Update use-note-capture.ts * Update use-note-capture.ts * wip * wip * wip * wip * Update MkNoteDetailed.vue * wip * wip * Update MkTimeline.vue * wip * fix * Update MkTimeline.vue * wip * test * Revert "test" This reverts commit 3375619396c54dcda5e564eb1da444c2391208c9. * Update use-pagination.ts * test * Revert "test" This reverts commit 42c53c830e28485d2fb49061fa7cdeee31bc6a22. * test * Revert "test" This reverts commit c4f8cda4aa1cec9d1eb97557145f3ad3d2d0e469. * Update style.scss * Update MkTimeline.vue * Update MkTimeline.vue * Update MkTimeline.vue * ✌️ * Update MkTimeline.vue * wip * wip * test * Update MkPullToRefresh.vue * Update MkPullToRefresh.vue * Update MkPullToRefresh.vue * Update MkPullToRefresh.vue * Update MkTimeline.vue * wip * tweak navbar * wip * wip * wip * wip * wip * wip * wip * Update home.vue * wip * refactor * wip * wip * Update note.vue * Update navbar.vue * Update MkPullToRefresh.vue * Update MkPullToRefresh.vue * Update MkPullToRefresh.vue * wip * Update MkStreamingNotificationsTimeline.vue * Update use-pagination.ts * wip * improve perf * wip * Update MkNotesTimeline.vue * wip * megre * Update use-pagination.ts * Update use-pagination.ts * Update MkStreamingNotesTimeline.vue * Update use-pagination.ts * Update CHANGELOG.md * Update CHANGELOG.md * Update CHANGELOG.md --- packages/backend/src/server/api/endpoint-list.ts | 1 + .../api/endpoints/notes/show-partial-bulk.ts | 47 ++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts (limited to 'packages/backend/src/server/api') diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index e5170aa2dc..bd466b3cad 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -323,6 +323,7 @@ export * as 'notes/replies' from './endpoints/notes/replies.js'; export * as 'notes/search' from './endpoints/notes/search.js'; export * as 'notes/search-by-tag' from './endpoints/notes/search-by-tag.js'; export * as 'notes/show' from './endpoints/notes/show.js'; +export * as 'notes/show-partial-bulk' from './endpoints/notes/show-partial-bulk.js'; export * as 'notes/state' from './endpoints/notes/state.js'; export * as 'notes/thread-muting/create' from './endpoints/notes/thread-muting/create.js'; export * as 'notes/thread-muting/delete' from './endpoints/notes/thread-muting/delete.js'; diff --git a/packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts b/packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts new file mode 100644 index 0000000000..87b368e17e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: false, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + }, + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteIds: { type: 'array', items: { type: 'string', format: 'misskey:id' }, maxItems: 100, minItems: 1 }, + }, + required: ['noteIds'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private noteEntityService: NoteEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.noteEntityService.fetchDiffs(ps.noteIds); + }); + } +} -- cgit v1.2.3-freya From ebf291084f9490f0345a488592e2b4cd2ffb9d91 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 9 May 2025 17:44:35 +0900 Subject: Feat: UGCの公開範囲設定機能 (#15938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * Update CHANGELOG.md * wip * wip * Update show.ts --- CHANGELOG.md | 5 ++++- locales/index.d.ts | 26 ++++++++++++++++++++++ locales/ja-JP.yml | 8 +++++++ ...eUserGeneratedContentsForNonLoggedInVisitors.js | 16 +++++++++++++ packages/backend/src/models/Meta.ts | 6 +++++ .../backend/src/server/api/endpoints/admin/meta.ts | 6 +++++ .../src/server/api/endpoints/admin/update-meta.ts | 8 +++++++ .../backend/src/server/api/endpoints/notes/show.ts | 15 ++++++++++++- .../backend/src/server/api/endpoints/users/show.ts | 17 +++++++++++++- .../backend/src/server/web/ClientServerService.ts | 15 +++++++++++-- packages/frontend/src/pages/admin/moderation.vue | 24 +++++++++++++++++++- packages/misskey-js/src/autogen/types.ts | 4 ++++ 12 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 packages/backend/migration/1746330901644-visibleUserGeneratedContentsForNonLoggedInVisitors.js (limited to 'packages/backend/src/server/api') diff --git a/CHANGELOG.md b/CHANGELOG.md index efde2ed192..dd5ede9be7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,10 @@ ## Unreleased ### General -- +- Feat: 非ログインでサーバーを閲覧された際に、サーバー内のコンテンツを非公開にすることができるようになりました + - モデレーションが行き届きにくい不適切なリモートコンテンツなどが、自サーバー経由で図らずもインターネットに公開されてしまうことによるトラブル防止などに役立ちます + - 「全て公開(今までの挙動)」「ローカルのコンテンツだけ公開(=サーバー内で受信されたリモートのコンテンツは公開しない)」「何も公開しない」から選択できます + - デフォルト値は「ローカルのコンテンツだけ公開」になっています ### Client - Feat: Websocket接続を行わずにMisskeyを利用するNo Websocketモードが実装されました(beta) diff --git a/locales/index.d.ts b/locales/index.d.ts index 2b92a0ae7c..e10506bfe3 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -6412,6 +6412,32 @@ export interface Locale extends ILocale { * 脆弱性などの理由で、サーバーのソフトウェアの名前及びバージョンの範囲を指定して配信を停止できます。このバージョン情報はサーバーが提供したものであり、信頼性は保証されません。バージョン指定には semver の範囲指定が使用できますが、>= 2024.3.1 と指定すると 2024.3.1-custom.0 のようなカスタムバージョンが含まれないため、>= 2024.3.1-0 のように prerelease の指定を行うことを推奨します。 */ "deliverSuspendedSoftwareDescription": string; + /** + * 非利用者に対するユーザー作成コンテンツの公開範囲 + */ + "userGeneratedContentsVisibilityForVisitor": string; + /** + * モデレーションが行き届きにくい不適切なリモートコンテンツなどが、自サーバー経由で図らずもインターネットに公開されてしまうことによるトラブル防止などに役立ちます。 + */ + "userGeneratedContentsVisibilityForVisitor_description": string; + /** + * サーバーで受信したリモートのコンテンツを含め、サーバー内の全てのコンテンツを無条件でインターネットに公開することはリスクが伴います。特に、分散型の特性を知らない閲覧者にとっては、リモートのコンテンツであってもサーバー内で作成されたコンテンツであると誤って認識してしまう可能性があるため、注意が必要です。 + */ + "userGeneratedContentsVisibilityForVisitor_description2": string; + "_userGeneratedContentsVisibilityForVisitor": { + /** + * 全て公開 + */ + "all": string; + /** + * ローカルコンテンツのみ公開し、リモートコンテンツは非公開 + */ + "localOnly": string; + /** + * 全て非公開 + */ + "none": string; + }; }; "_accountMigration": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 6c5306dfa3..aeaff7e875 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1629,6 +1629,14 @@ _serverSettings: thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。" deliverSuspendedSoftware: "配信停止中のソフトウェア" deliverSuspendedSoftwareDescription: "脆弱性などの理由で、サーバーのソフトウェアの名前及びバージョンの範囲を指定して配信を停止できます。このバージョン情報はサーバーが提供したものであり、信頼性は保証されません。バージョン指定には semver の範囲指定が使用できますが、>= 2024.3.1 と指定すると 2024.3.1-custom.0 のようなカスタムバージョンが含まれないため、>= 2024.3.1-0 のように prerelease の指定を行うことを推奨します。" + userGeneratedContentsVisibilityForVisitor: "非利用者に対するユーザー作成コンテンツの公開範囲" + userGeneratedContentsVisibilityForVisitor_description: "モデレーションが行き届きにくい不適切なリモートコンテンツなどが、自サーバー経由で図らずもインターネットに公開されてしまうことによるトラブル防止などに役立ちます。" + userGeneratedContentsVisibilityForVisitor_description2: "サーバーで受信したリモートのコンテンツを含め、サーバー内の全てのコンテンツを無条件でインターネットに公開することはリスクが伴います。特に、分散型の特性を知らない閲覧者にとっては、リモートのコンテンツであってもサーバー内で作成されたコンテンツであると誤って認識してしまう可能性があるため、注意が必要です。" + + _userGeneratedContentsVisibilityForVisitor: + all: "全て公開" + localOnly: "ローカルコンテンツのみ公開し、リモートコンテンツは非公開" + none: "全て非公開" _accountMigration: moveFrom: "別のアカウントからこのアカウントに移行" diff --git a/packages/backend/migration/1746330901644-visibleUserGeneratedContentsForNonLoggedInVisitors.js b/packages/backend/migration/1746330901644-visibleUserGeneratedContentsForNonLoggedInVisitors.js new file mode 100644 index 0000000000..115698a420 --- /dev/null +++ b/packages/backend/migration/1746330901644-visibleUserGeneratedContentsForNonLoggedInVisitors.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class VisibleUserGeneratedContentsForNonLoggedInVisitors1746330901644 { + name = 'VisibleUserGeneratedContentsForNonLoggedInVisitors1746330901644' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "ugcVisibilityForVisitor" character varying(128) NOT NULL DEFAULT 'local'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "ugcVisibilityForVisitor"`); + } +} diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 46f3b2e3c0..95d19c8075 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -659,6 +659,12 @@ export class MiMeta { }) public federationHosts: string[]; + @Column('varchar', { + length: 128, + default: 'local', + }) + public ugcVisibilityForVisitor: 'all' | 'local' | 'none'; + @Column('varchar', { length: 64, nullable: true, diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 4a106e7175..cb48a1bc95 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -546,6 +546,11 @@ export const meta = { }, }, }, + ugcVisibilityForVisitor: { + type: 'string', + enum: ['all', 'local', 'none'], + optional: false, nullable: false, + }, }, }, } as const; @@ -691,6 +696,7 @@ export default class extends Endpoint { // eslint- federation: instance.federation, federationHosts: instance.federationHosts, deliverSuspendedSoftware: instance.deliverSuspendedSoftware, + ugcVisibilityForVisitor: instance.ugcVisibilityForVisitor, }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 31eeaa5e38..fc6b890b69 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -196,6 +196,10 @@ export const paramDef = { required: ['software', 'versionRange'], }, }, + ugcVisibilityForVisitor: { + type: 'string', + enum: ['all', 'local', 'none'], + }, }, required: [], } as const; @@ -690,6 +694,10 @@ export default class extends Endpoint { // eslint- set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase()); } + if (ps.ugcVisibilityForVisitor !== undefined) { + set.ugcVisibilityForVisitor = ps.ugcVisibilityForVisitor; + } + const before = await this.metaService.fetch(true); await this.metaService.update(set); diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts index 11839bce36..b93c73b0c5 100644 --- a/packages/backend/src/server/api/endpoints/notes/show.ts +++ b/packages/backend/src/server/api/endpoints/notes/show.ts @@ -3,10 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { DI } from '@/di-symbols.js'; +import { MiMeta } from '@/models/Meta.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -46,6 +48,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + private noteEntityService: NoteEntityService, private getterService: GetterService, ) { @@ -59,6 +64,14 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.signinRequired); } + if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) { + throw new ApiError(meta.errors.signinRequired); + } + + if (this.serverSettings.ugcVisibilityForVisitor === 'local' && note.userHost != null && me == null) { + throw new ApiError(meta.errors.signinRequired); + } + return await this.noteEntityService.pack(note, me, { detail: true, }); diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 062326e28d..431869d47f 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -5,7 +5,7 @@ import { In, IsNull } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; +import type { MiMeta, UsersRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -82,6 +82,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -92,6 +95,10 @@ export default class extends Endpoint { // eslint- private apiLoggerService: ApiLoggerService, ) { super(meta, paramDef, async (ps, me, _1, _2, _3, ip) => { + if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) { + throw new ApiError(meta.errors.noSuchUser); + } + let user; const isModerator = await this.roleService.isModerator(me); @@ -123,6 +130,10 @@ export default class extends Endpoint { // eslint- } else { // Lookup user if (typeof ps.host === 'string' && typeof ps.username === 'string') { + if (this.serverSettings.ugcVisibilityForVisitor === 'local' && me == null) { + throw new ApiError(meta.errors.noSuchUser); + } + user = await this.remoteUserResolveService.resolveUser(ps.username, ps.host).catch(err => { this.apiLoggerService.logger.warn(`failed to resolve remote user: ${err}`); throw new ApiError(meta.errors.failedToResolveRemoteUser); @@ -139,6 +150,10 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchUser); } + if (this.serverSettings.ugcVisibilityForVisitor === 'local' && user.host != null && me == null) { + throw new ApiError(meta.errors.noSuchUser); + } + if (user.host == null) { if (me == null && ip != null) { this.perUserPvChart.commitByVisitor(user, ip); diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 30a911088e..9a33d27d86 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -513,7 +513,12 @@ export class ClientServerService { vary(reply.raw, 'Accept'); - if (user != null) { + if ( + user != null && ( + this.meta.ugcVisibilityForVisitor === 'all' || + (this.meta.ugcVisibilityForVisitor === 'local' && user.host == null) + ) + ) { const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); const me = profile.fields ? profile.fields @@ -577,7 +582,13 @@ export class ClientServerService { relations: ['user'], }); - if (note && !note.user!.requireSigninToViewContents) { + if ( + note && + !note.user!.requireSigninToViewContents && + (this.meta.ugcVisibilityForVisitor === 'all' || + (this.meta.ugcVisibilityForVisitor === 'local' && note.userHost == null) + ) + ) { const _note = await this.noteEntityService.pack(note); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId }); reply.header('Cache-Control', 'public, max-age=15'); diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 2157b4ca14..819f229c10 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -17,9 +17,20 @@ SPDX-License-Identifier: AGPL-3.0-only - + + + + + + + + + {{ i18n.ts.serverRules }} @@ -137,9 +148,11 @@ import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import FormLink from '@/components/form/link.vue'; import MkFolder from '@/components/MkFolder.vue'; +import MkSelect from '@/components/MkSelect.vue'; const enableRegistration = ref(false); const emailRequiredForSignup = ref(false); +const ugcVisibilityForVisitor = ref('all'); const sensitiveWords = ref(''); const prohibitedWords = ref(''); const prohibitedWordsForNameOfUser = ref(''); @@ -153,6 +166,7 @@ async function init() { const meta = await misskeyApi('admin/meta'); enableRegistration.value = !meta.disableRegistration; emailRequiredForSignup.value = meta.emailRequiredForSignup; + ugcVisibilityForVisitor.value = meta.ugcVisibilityForVisitor; sensitiveWords.value = meta.sensitiveWords.join('\n'); prohibitedWords.value = meta.prohibitedWords.join('\n'); prohibitedWordsForNameOfUser.value = meta.prohibitedWordsForNameOfUser.join('\n'); @@ -189,6 +203,14 @@ function onChange_emailRequiredForSignup(value: boolean) { }); } +function onChange_ugcVisibilityForVisitor(value: string) { + os.apiWithDialog('admin/update-meta', { + ugcVisibilityForVisitor: value, + }).then(() => { + fetchInstance(true); + }); +} + function save_preservedUsernames() { os.apiWithDialog('admin/update-meta', { preservedUsernames: preservedUsernames.value.split('\n'), diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 6db66477f7..4a71dd5465 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -8780,6 +8780,8 @@ export type operations = { software: string; versionRange: string; }[]; + /** @enum {string} */ + ugcVisibilityForVisitor: 'all' | 'local' | 'none'; }; }; }; @@ -11450,6 +11452,8 @@ export type operations = { software: string; versionRange: string; }[]; + /** @enum {string} */ + ugcVisibilityForVisitor?: 'all' | 'local' | 'none'; }; }; }; -- cgit v1.2.3-freya From 96bc042d67a6175e0d899b7f7ce7d13de3a0dbf2 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 9 May 2025 19:41:54 +0900 Subject: Feat: サーバー初期設定ウィザード (#15954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * wip * Update welcome.setup.vue * wip * wip * wip * wip * Update MkServerSetupWizard.vue * Update MkServerSetupWizard.vue * wip * wip * wip * Update types.ts * wip * wip * Update CHANGELOG.md --- CHANGELOG.md | 2 + cypress.config.ts | 5 - cypress/e2e/basic.cy.ts | 9 + cypress/plugins/index.js | 22 -- locales/index.d.ts | 176 +++++++++- locales/ja-JP.yml | 47 ++- package.json | 2 +- .../migration/1746422049376-singleUserMode.js | 16 + packages/backend/src/models/Meta.ts | 5 + .../backend/src/server/api/endpoints/admin/meta.ts | 5 + .../src/server/api/endpoints/admin/update-meta.ts | 5 + packages/frontend/src/components/MkRadio.vue | 8 +- packages/frontend/src/components/MkRadios.vue | 19 +- .../src/components/MkServerSetupWizard.vue | 356 +++++++++++++++++++++ packages/frontend/src/os.ts | 28 +- packages/frontend/src/pages/welcome.setup.vue | 178 +++++++++-- packages/misskey-js/src/autogen/types.ts | 2 + 17 files changed, 803 insertions(+), 82 deletions(-) delete mode 100644 cypress/plugins/index.js create mode 100644 packages/backend/migration/1746422049376-singleUserMode.js create mode 100644 packages/frontend/src/components/MkServerSetupWizard.vue (limited to 'packages/backend/src/server/api') diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c364eea29..1da3ae6120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - デフォルト値は「ローカルのコンテンツだけ公開」になっています ### Client +- Feat: サーバー初期設定ウィザードが実装されました + - 簡単なウィザードに従うだけで、サーバーに最適な設定が適用されます - Feat: Websocket接続を行わずにMisskeyを利用するNo Websocketモードが実装されました(beta) - サーバーのパフォーマンス向上に寄与することが期待されます - 何らの理由によりWebsocket接続が行えない環境でも快適に利用可能です diff --git a/cypress.config.ts b/cypress.config.ts index e390c41a54..361acaf6e5 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -2,11 +2,6 @@ import { defineConfig } from 'cypress' export default defineConfig({ e2e: { - // We've imported your old cypress plugins here. - // You may want to clean this up later by importing these. - setupNodeEvents(on, config) { - return require('./cypress/plugins/index.js')(on, config) - }, baseUrl: 'http://localhost:61812', }, }) diff --git a/cypress/e2e/basic.cy.ts b/cypress/e2e/basic.cy.ts index 6471f96504..bd4021d2e3 100644 --- a/cypress/e2e/basic.cy.ts +++ b/cypress/e2e/basic.cy.ts @@ -31,6 +31,15 @@ describe('Before setup instance', () => { // なぜか動かない //cy.wait('@signup').should('have.property', 'response.statusCode'); cy.wait('@signup'); + + cy.intercept('POST', '/api/admin/update-meta').as('update-meta'); + + cy.get('[data-cy-next]').click(); + cy.get('[data-cy-next]').click(); + cy.get('[data-cy-server-name] input').type('Testskey'); + cy.get('[data-cy-server-setup-wizard-apply]').click(); + + cy.wait('@update-meta'); }); }); diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js deleted file mode 100644 index 59b2bab6e4..0000000000 --- a/cypress/plugins/index.js +++ /dev/null @@ -1,22 +0,0 @@ -/// -// *********************************************************** -// This example plugins/index.js can be used to load plugins -// -// You can change the location of this file or turn off loading -// the plugins file with the 'pluginsFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/plugins-guide -// *********************************************************** - -// This function is called when a project is opened or re-opened (e.g. due to -// the project's config changing) - -/** - * @type {Cypress.PluginConfig} - */ -// eslint-disable-next-line no-unused-vars -module.exports = (on, config) => { - // `on` is used to hook into various events Cypress emits - // `config` is the resolved Cypress config -} diff --git a/locales/index.d.ts b/locales/index.d.ts index e10506bfe3..6bd5c8c3d7 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1022,10 +1022,6 @@ export interface Locale extends ILocale { * これ以上ピン留めできません */ "pinLimitExceeded": string; - /** - * Misskeyのインストールが完了しました!管理者アカウントを作成しましょう。 - */ - "intro": string; /** * 完了 */ @@ -5413,6 +5409,10 @@ export interface Locale extends ILocale { * スクロールして閉じる */ "scrollToClose": string; + /** + * アドバイス + */ + "advice": string; /** * リアルタイムモード */ @@ -6412,6 +6412,14 @@ export interface Locale extends ILocale { * 脆弱性などの理由で、サーバーのソフトウェアの名前及びバージョンの範囲を指定して配信を停止できます。このバージョン情報はサーバーが提供したものであり、信頼性は保証されません。バージョン指定には semver の範囲指定が使用できますが、>= 2024.3.1 と指定すると 2024.3.1-custom.0 のようなカスタムバージョンが含まれないため、>= 2024.3.1-0 のように prerelease の指定を行うことを推奨します。 */ "deliverSuspendedSoftwareDescription": string; + /** + * お一人様モード + */ + "singleUserMode": string; + /** + * このサーバーを利用するのが自分だけの場合、このモードを有効にすることで動作が最適化されます。 + */ + "singleUserMode_description": string; /** * 非利用者に対するユーザー作成コンテンツの公開範囲 */ @@ -11670,6 +11678,166 @@ export interface Locale extends ILocale { */ "serverHostPlaceholder": string; }; + "_serverSetupWizard": { + /** + * Misskeyのインストールが完了しました! + */ + "installCompleted": string; + /** + * まずは、管理者アカウントを作成しましょう。 + */ + "firstCreateAccount": string; + /** + * 管理者アカウントが作成されました! + */ + "accountCreated": string; + /** + * サーバーの設定 + */ + "serverSetting": string; + /** + * このウィザードで簡単に最適なサーバーの設定が行えます。 + */ + "youCanEasilyConfigureOptimalServerSettingsWithThisWizard": string; + /** + * ここでの設定は、あとからでも変更できます。 + */ + "settingsYouMakeHereCanBeChangedLater": string; + /** + * Misskeyをどのように使いますか? + */ + "howWillYouUseMisskey": string; + "_use": { + /** + * お一人様サーバー + */ + "single": string; + /** + * 自分専用のサーバーとして、一人で使う + */ + "single_description": string; + /** + * お一人様サーバーとして運用する場合でも、アカウントは必要に応じて複数作成可能です。 + */ + "single_youCanCreateMultipleAccounts": string; + /** + * グループサーバー + */ + "group": string; + /** + * 信頼できる他の利用者を招待して、複数人で使う + */ + "group_description": string; + /** + * オープンサーバー + */ + "open": string; + /** + * 不特定多数の利用者を受け入れる運営を行う + */ + "open_description": string; + }; + /** + * 不特定多数の利用者を受け入れることはリスクが伴います。トラブルに対処できるよう、確実なモデレーション体制で運営することを推奨します。 + */ + "openServerAdvice": string; + /** + * 自サーバーがスパムの踏み台にならないように、reCAPTCHAといったアンチボット機能を有効にするなど、セキュリティについても細心の注意が必要です。 + */ + "openServerAntiSpamAdvice": string; + /** + * どれくらいの人数を想定していますか? + */ + "howManyUsersDoYouExpect": string; + "_scale": { + /** + * 100人以下 (小規模) + */ + "small": string; + /** + * 100人以上1000人以下 (中規模) + */ + "medium": string; + /** + * 1000人以上 (大規模) + */ + "large": string; + }; + /** + * 大規模なサーバーでは、ロードバランシングやデータベースのレプリケーションなど、高度なインフラストラクチャーの知識が必要になる場合があります。 + */ + "largeScaleServerAdvice": string; + /** + * Fediverseと接続しますか? + */ + "doYouConnectToFediverse": string; + /** + * 分散型サーバーで構成されるネットワーク(Fediverse)に接続すると、他のサーバーと相互にコンテンツのやり取りが可能です。 + */ + "doYouConnectToFediverse_description1": string; + /** + * Fediverseと接続することは「連合」とも呼ばれます。 + */ + "doYouConnectToFediverse_description2": string; + /** + * 連合可能なサーバーの指定など、高度な設定も後ほど可能です。 + */ + "youCanConfigureMoreFederationSettingsLater": string; + /** + * 管理者情報 + */ + "adminInfo": string; + /** + * 問い合わせを受け付けるために使用される管理者情報を設定します。 + */ + "adminInfo_description": string; + /** + * オープンサーバー、または連合がオンの場合は必ず入力が必要です。 + */ + "adminInfo_mustBeFilled": string; + /** + * 以下の設定が推奨されます + */ + "followingSettingsAreRecommended": string; + /** + * この設定を適用 + */ + "applyTheseSettings": string; + /** + * 設定をスキップ + */ + "skipSettings": string; + /** + * 設定が完了しました! + */ + "settingsCompleted": string; + /** + * お疲れ様でした。準備が整ったので、さっそくサーバーの使用を開始できます。 + */ + "settingsCompleted_description": string; + /** + * 詳細なサーバー設定は、「コントロールパネル」から行えます。 + */ + "settingsCompleted_description2": string; + /** + * 寄付のお願い + */ + "donationRequest": string; + "_donationRequest": { + /** + * Misskeyは有志によって開発されている無料のソフトウェアです。 + */ + "text1": string; + /** + * 今後も開発を続けられるように、よろしければぜひカンパをお願いいたします。 + */ + "text2": string; + /** + * 支援者向け特典もあります! + */ + "text3": string; + }; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index aeaff7e875..6c73285295 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -251,7 +251,6 @@ noUsers: "ユーザーはいません" editProfile: "プロフィールを編集" noteDeleteConfirm: "このノートを削除しますか?" pinLimitExceeded: "これ以上ピン留めできません" -intro: "Misskeyのインストールが完了しました!管理者アカウントを作成しましょう。" done: "完了" processing: "処理中" preview: "プレビュー" @@ -1348,6 +1347,7 @@ goToDeck: "デッキへ戻る" federationJobs: "連合ジョブ" driveAboutTip: "ドライブでは、過去にアップロードしたファイルの一覧が表示されます。
\nノートに添付する際に再利用したり、あとで投稿するファイルを予めアップロードしておくこともできます。
\nファイルを削除すると、今までそのファイルを使用した全ての場所(ノート、ページ、アバター、バナー等)からも見えなくなるので注意してください。
\nフォルダを作って整理することもできます。" scrollToClose: "スクロールして閉じる" +advice: "アドバイス" realtimeMode: "リアルタイムモード" turnItOn: "オンにする" turnItOff: "オフにする" @@ -1629,6 +1629,8 @@ _serverSettings: thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。" deliverSuspendedSoftware: "配信停止中のソフトウェア" deliverSuspendedSoftwareDescription: "脆弱性などの理由で、サーバーのソフトウェアの名前及びバージョンの範囲を指定して配信を停止できます。このバージョン情報はサーバーが提供したものであり、信頼性は保証されません。バージョン指定には semver の範囲指定が使用できますが、>= 2024.3.1 と指定すると 2024.3.1-custom.0 のようなカスタムバージョンが含まれないため、>= 2024.3.1-0 のように prerelease の指定を行うことを推奨します。" + singleUserMode: "お一人様モード" + singleUserMode_description: "このサーバーを利用するのが自分だけの場合、このモードを有効にすることで動作が最適化されます。" userGeneratedContentsVisibilityForVisitor: "非利用者に対するユーザー作成コンテンツの公開範囲" userGeneratedContentsVisibilityForVisitor_description: "モデレーションが行き届きにくい不適切なリモートコンテンツなどが、自サーバー経由で図らずもインターネットに公開されてしまうことによるトラブル防止などに役立ちます。" userGeneratedContentsVisibilityForVisitor_description2: "サーバーで受信したリモートのコンテンツを含め、サーバー内の全てのコンテンツを無条件でインターネットに公開することはリスクが伴います。特に、分散型の特性を知らない閲覧者にとっては、リモートのコンテンツであってもサーバー内で作成されたコンテンツであると誤って認識してしまう可能性があるため、注意が必要です。" @@ -3121,3 +3123,46 @@ _search: pleaseEnterServerHost: "サーバーのホストを入力してください" pleaseSelectUser: "ユーザーを選択してください" serverHostPlaceholder: "例: misskey.example.com" + +_serverSetupWizard: + installCompleted: "Misskeyのインストールが完了しました!" + firstCreateAccount: "まずは、管理者アカウントを作成しましょう。" + accountCreated: "管理者アカウントが作成されました!" + serverSetting: "サーバーの設定" + youCanEasilyConfigureOptimalServerSettingsWithThisWizard: "このウィザードで簡単に最適なサーバーの設定が行えます。" + settingsYouMakeHereCanBeChangedLater: "ここでの設定は、あとからでも変更できます。" + howWillYouUseMisskey: "Misskeyをどのように使いますか?" + _use: + single: "お一人様サーバー" + single_description: "自分専用のサーバーとして、一人で使う" + single_youCanCreateMultipleAccounts: "お一人様サーバーとして運用する場合でも、アカウントは必要に応じて複数作成可能です。" + group: "グループサーバー" + group_description: "信頼できる他の利用者を招待して、複数人で使う" + open: "オープンサーバー" + open_description: "不特定多数の利用者を受け入れる運営を行う" + openServerAdvice: "不特定多数の利用者を受け入れることはリスクが伴います。トラブルに対処できるよう、確実なモデレーション体制で運営することを推奨します。" + openServerAntiSpamAdvice: "自サーバーがスパムの踏み台にならないように、reCAPTCHAといったアンチボット機能を有効にするなど、セキュリティについても細心の注意が必要です。" + howManyUsersDoYouExpect: "どれくらいの人数を想定していますか?" + _scale: + small: "100人以下 (小規模)" + medium: "100人以上1000人以下 (中規模)" + large: "1000人以上 (大規模)" + largeScaleServerAdvice: "大規模なサーバーでは、ロードバランシングやデータベースのレプリケーションなど、高度なインフラストラクチャーの知識が必要になる場合があります。" + doYouConnectToFediverse: "Fediverseと接続しますか?" + doYouConnectToFediverse_description1: "分散型サーバーで構成されるネットワーク(Fediverse)に接続すると、他のサーバーと相互にコンテンツのやり取りが可能です。" + doYouConnectToFediverse_description2: "Fediverseと接続することは「連合」とも呼ばれます。" + youCanConfigureMoreFederationSettingsLater: "連合可能なサーバーの指定など、高度な設定も後ほど可能です。" + adminInfo: "管理者情報" + adminInfo_description: "問い合わせを受け付けるために使用される管理者情報を設定します。" + adminInfo_mustBeFilled: "オープンサーバー、または連合がオンの場合は必ず入力が必要です。" + followingSettingsAreRecommended: "以下の設定が推奨されます" + applyTheseSettings: "この設定を適用" + skipSettings: "設定をスキップ" + settingsCompleted: "設定が完了しました!" + settingsCompleted_description: "お疲れ様でした。準備が整ったので、さっそくサーバーの使用を開始できます。" + settingsCompleted_description2: "詳細なサーバー設定は、「コントロールパネル」から行えます。" + donationRequest: "寄付のお願い" + _donationRequest: + text1: "Misskeyは有志によって開発されている無料のソフトウェアです。" + text2: "今後も開発を続けられるように、よろしければぜひカンパをお願いいたします。" + text3: "支援者向け特典もあります!" diff --git a/package.json b/package.json index ff08cbe97b..ff5f685d3f 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "watch": "pnpm dev", "dev": "node scripts/dev.mjs", "lint": "pnpm -r lint", - "cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts", + "cy:open": "pnpm cypress open --config-file=cypress.config.ts", "cy:run": "pnpm cypress run", "e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run", "e2e-dev-container": "ncp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run", diff --git a/packages/backend/migration/1746422049376-singleUserMode.js b/packages/backend/migration/1746422049376-singleUserMode.js new file mode 100644 index 0000000000..9a79d46d5b --- /dev/null +++ b/packages/backend/migration/1746422049376-singleUserMode.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SingleUserMode1746422049376 { + name = 'SingleUserMode1746422049376' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "singleUserMode" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "singleUserMode"`); + } +} diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 95d19c8075..e3625b247c 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -675,6 +675,11 @@ export class MiMeta { default: [], }) public deliverSuspendedSoftware: SoftwareSuspension[]; + + @Column('boolean', { + default: false, + }) + public singleUserMode: boolean; } export type SoftwareSuspension = { diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index cb48a1bc95..cd36985485 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -546,6 +546,10 @@ export const meta = { }, }, }, + singleUserMode: { + type: 'boolean', + optional: false, nullable: false, + }, ugcVisibilityForVisitor: { type: 'string', enum: ['all', 'local', 'none'], @@ -696,6 +700,7 @@ export default class extends Endpoint { // eslint- federation: instance.federation, federationHosts: instance.federationHosts, deliverSuspendedSoftware: instance.deliverSuspendedSoftware, + singleUserMode: instance.singleUserMode, ugcVisibilityForVisitor: instance.ugcVisibilityForVisitor, }; }); diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index fc6b890b69..a96fbd759c 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -196,6 +196,7 @@ export const paramDef = { required: ['software', 'versionRange'], }, }, + singleUserMode: { type: 'boolean' }, ugcVisibilityForVisitor: { type: 'string', enum: ['all', 'local', 'none'], @@ -694,6 +695,10 @@ export default class extends Endpoint { // eslint- set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase()); } + if (ps.singleUserMode !== undefined) { + set.singleUserMode = ps.singleUserMode; + } + if (ps.ugcVisibilityForVisitor !== undefined) { set.ugcVisibilityForVisitor = ps.ugcVisibilityForVisitor; } diff --git a/packages/frontend/src/components/MkRadio.vue b/packages/frontend/src/components/MkRadio.vue index f16c8f6c2a..a7d77dd118 100644 --- a/packages/frontend/src/components/MkRadio.vue +++ b/packages/frontend/src/components/MkRadio.vue @@ -48,7 +48,8 @@ function toggle(): void { diff --git a/packages/frontend/src/components/MkServerSetupWizard.vue b/packages/frontend/src/components/MkServerSetupWizard.vue new file mode 100644 index 0000000000..65e0d6d9de --- /dev/null +++ b/packages/frontend/src/components/MkServerSetupWizard.vue @@ -0,0 +1,356 @@ + + + + + + + diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 813b49635d..d891525782 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -547,18 +547,24 @@ export function success(): Promise { }); } -export function waiting(text?: string | null): Promise { - return new Promise(resolve => { - const showing = ref(true); - const { dispose } = popup(MkWaitingDialog, { - success: false, - showing: showing, - text, - }, { - done: () => resolve(), - closed: () => dispose(), - }); +export function waiting(text?: string | null): () => void { + window.document.body.setAttribute('inert', 'true'); + + const showing = ref(true); + const { dispose } = popup(MkWaitingDialog, { + success: false, + showing: showing, + text, + }, { + closed: () => { + window.document.body.removeAttribute('inert'); + dispose(); + }, }); + + return () => { + showing.value = false; + }; } export function form(title: string, f: F): Promise<{ canceled: true, result?: undefined } | { canceled?: false, result: GetFormResultType }> { diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue index 69a654595a..675e82a71d 100644 --- a/packages/frontend/src/pages/welcome.setup.vue +++ b/packages/frontend/src/pages/welcome.setup.vue @@ -6,39 +6,126 @@ SPDX-License-Identifier: AGPL-3.0-only 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, + 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, }); + } - if (!canceled) { - originalOrCropped = await os.cropImage(file, { - aspectRatio: 1, - }); - } + const driveFile = (await os.launchUploader([originalOrCropped], { multiple: false }))[0]; - const i = await os.apiWithDialog('i/update', { - avatarId: originalOrCropped.id, - }); - $i.avatarId = i.avatarId; - $i.avatarUrl = i.avatarUrl; + 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/isWebpSupported.ts b/packages/frontend/src/utility/isWebpSupported.ts new file mode 100644 index 0000000000..affd81fd57 --- /dev/null +++ b/packages/frontend/src/utility/isWebpSupported.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +let isWebpSupportedCache: boolean | undefined; +export function isWebpSupported() { + if (isWebpSupportedCache === undefined) { + const canvas = window.document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + isWebpSupportedCache = canvas.toDataURL('image/webp').startsWith('data:image/webp'); + } + return isWebpSupportedCache; +} 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/utility/upload/isWebpSupported.ts b/packages/frontend/src/utility/upload/isWebpSupported.ts deleted file mode 100644 index affd81fd57..0000000000 --- a/packages/frontend/src/utility/upload/isWebpSupported.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -let isWebpSupportedCache: boolean | undefined; -export function isWebpSupported() { - if (isWebpSupportedCache === undefined) { - const canvas = window.document.createElement('canvas'); - canvas.width = 1; - canvas.height = 1; - isWebpSupportedCache = canvas.toDataURL('image/webp').startsWith('data:image/webp'); - } - return isWebpSupportedCache; -} 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. -- cgit v1.2.3-freya From 000ed1f51f061118cc2e9d6d08822ea96c66a86a Mon Sep 17 00:00:00 2001 From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Thu, 22 May 2025 12:06:07 +0900 Subject: fix(frontend): ジョブキューインスペクタの型エラー解消 (#16020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(frontend): ジョブキューインスペクタの型エラー解消 * fix * fix * fix * fix --- packages/backend/src/core/QueueService.ts | 5 +- packages/backend/src/misc/json-schema.ts | 8 +- packages/backend/src/models/json-schema/queue.ts | 107 +++++++++++++++++++ .../src/server/api/endpoints/admin/queue/jobs.ts | 10 +- .../api/endpoints/admin/queue/queue-stats.ts | 113 ++++++++++++++++++++- .../src/server/api/endpoints/admin/queue/queues.ts | 42 +++++++- .../server/api/endpoints/admin/queue/show-job.ts | 7 +- packages/frontend/src/components/MkTl.vue | 36 +++---- .../frontend/src/pages/admin/job-queue.chart.vue | 2 + .../frontend/src/pages/admin/job-queue.job.vue | 40 +++++--- packages/frontend/src/pages/admin/job-queue.vue | 69 ++++++------- packages/misskey-js/etc/misskey-js.api.md | 27 +++++ packages/misskey-js/src/autogen/endpoint.ts | 12 ++- packages/misskey-js/src/autogen/entities.ts | 4 + packages/misskey-js/src/autogen/models.ts | 2 + packages/misskey-js/src/autogen/types.ts | 101 +++++++++++++++--- packages/misskey-js/src/consts.ts | 12 +++ packages/misskey-js/src/index.ts | 1 + 18 files changed, 502 insertions(+), 96 deletions(-) (limited to 'packages/backend/src/server/api') diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index a1e806816b..04bbc7e38a 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -39,6 +39,7 @@ import type { } from './QueueModule.js'; import type httpSignature from '@peertube/http-signature'; import type * as Bull from 'bullmq'; +import type { Packed } from '@/misc/json-schema.js'; export const QUEUE_TYPES = [ 'system', @@ -774,13 +775,13 @@ export class QueueService { } @bindThis - private packJobData(job: Bull.Job) { + private packJobData(job: Bull.Job): Packed<'QueueJob'> { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition const stacktrace = job.stacktrace ? job.stacktrace.filter(Boolean) : []; stacktrace.reverse(); return { - id: job.id, + id: job.id!, name: job.name, data: job.data, opts: job.opts, diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index e4eb10efca..23f6b692a7 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -31,7 +31,11 @@ import { packedChannelSchema } from '@/models/json-schema/channel.js'; import { packedAntennaSchema } from '@/models/json-schema/antenna.js'; import { packedClipSchema } from '@/models/json-schema/clip.js'; import { packedFederationInstanceSchema } from '@/models/json-schema/federation-instance.js'; -import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; +import { + packedQueueCountSchema, + packedQueueMetricsSchema, + packedQueueJobSchema, +} from '@/models/json-schema/queue.js'; import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js'; import { packedEmojiDetailedAdminSchema, @@ -100,6 +104,8 @@ export const refs = { PageBlock: packedPageBlockSchema, Channel: packedChannelSchema, QueueCount: packedQueueCountSchema, + QueueMetrics: packedQueueMetricsSchema, + QueueJob: packedQueueJobSchema, Antenna: packedAntennaSchema, Clip: packedClipSchema, FederationInstance: packedFederationInstanceSchema, diff --git a/packages/backend/src/models/json-schema/queue.ts b/packages/backend/src/models/json-schema/queue.ts index 2ecf5c831f..dad0cf57f6 100644 --- a/packages/backend/src/models/json-schema/queue.ts +++ b/packages/backend/src/models/json-schema/queue.ts @@ -28,3 +28,110 @@ export const packedQueueCountSchema = { }, }, } as const; + +// Bull.Metrics +export const packedQueueMetricsSchema = { + type: 'object', + properties: { + meta: { + type: 'object', + optional: false, nullable: false, + properties: { + count: { + type: 'number', + optional: false, nullable: false, + }, + prevTS: { + type: 'number', + optional: false, nullable: false, + }, + prevCount: { + type: 'number', + optional: false, nullable: false, + }, + }, + }, + data: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'number', + optional: false, nullable: false, + }, + }, + count: { + type: 'number', + optional: false, nullable: false, + }, + }, +} as const; + +export const packedQueueJobSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + data: { + type: 'object', + optional: false, nullable: false, + }, + opts: { + type: 'object', + optional: false, nullable: false, + }, + timestamp: { + type: 'number', + optional: false, nullable: false, + }, + processedOn: { + type: 'number', + optional: true, nullable: false, + }, + processedBy: { + type: 'string', + optional: true, nullable: false, + }, + finishedOn: { + type: 'number', + optional: true, nullable: false, + }, + progress: { + type: 'object', + optional: false, nullable: false, + }, + attempts: { + type: 'number', + optional: false, nullable: false, + }, + delay: { + type: 'number', + optional: false, nullable: false, + }, + failedReason: { + type: 'string', + optional: false, nullable: false, + }, + stacktrace: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, + returnValue: { + type: 'object', + optional: false, nullable: false, + }, + isFailed: { + type: 'boolean', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts index 79731c9786..155f2c4000 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/jobs.ts @@ -5,7 +5,6 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; export const meta = { @@ -14,6 +13,15 @@ export const meta = { requireCredential: true, requireModerator: true, kind: 'read:admin:queue', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + optional: false, nullable: false, + ref: 'QueueJob', + }, + }, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts index 10ce48332a..0098160165 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/queue-stats.ts @@ -5,7 +5,6 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; export const meta = { @@ -14,6 +13,118 @@ export const meta = { requireCredential: true, requireModerator: true, kind: 'read:admin:queue', + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + name: { + type: 'string', + optional: false, nullable: false, + enum: QUEUE_TYPES, + }, + qualifiedName: { + type: 'string', + optional: false, nullable: false, + }, + counts: { + type: 'object', + optional: false, nullable: false, + additionalProperties: { + type: 'number', + }, + }, + isPaused: { + type: 'boolean', + optional: false, nullable: false, + }, + metrics: { + type: 'object', + optional: false, nullable: false, + properties: { + completed: { + optional: false, nullable: false, + ref: 'QueueMetrics', + }, + failed: { + optional: false, nullable: false, + ref: 'QueueMetrics', + }, + }, + }, + db: { + type: 'object', + optional: false, nullable: false, + properties: { + version: { + type: 'string', + optional: false, nullable: false, + }, + mode: { + type: 'string', + optional: false, nullable: false, + enum: ['cluster', 'standalone', 'sentinel'], + }, + runId: { + type: 'string', + optional: false, nullable: false, + }, + processId: { + type: 'string', + optional: false, nullable: false, + }, + port: { + type: 'number', + optional: false, nullable: false, + }, + os: { + type: 'string', + optional: false, nullable: false, + }, + uptime: { + type: 'number', + optional: false, nullable: false, + }, + memory: { + type: 'object', + optional: false, nullable: false, + properties: { + total: { + type: 'number', + optional: false, nullable: false, + }, + used: { + type: 'number', + optional: false, nullable: false, + }, + fragmentationRatio: { + type: 'number', + optional: false, nullable: false, + }, + peak: { + type: 'number', + optional: false, nullable: false, + }, + }, + }, + clients: { + type: 'object', + optional: false, nullable: false, + properties: { + blocked: { + type: 'number', + optional: false, nullable: false, + }, + connected: { + type: 'number', + optional: false, nullable: false, + }, + }, + }, + }, + } + }, + }, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/admin/queue/queues.ts b/packages/backend/src/server/api/endpoints/admin/queue/queues.ts index 3a38275f60..8d27e38c84 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/queues.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/queues.ts @@ -5,7 +5,6 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; export const meta = { @@ -14,6 +13,47 @@ export const meta = { requireCredential: true, requireModerator: true, kind: 'read:admin:queue', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + name: { + type: 'string', + optional: false, nullable: false, + enum: QUEUE_TYPES, + }, + counts: { + type: 'object', + optional: false, nullable: false, + additionalProperties: { + type: 'number', + }, + }, + isPaused: { + type: 'boolean', + optional: false, nullable: false, + }, + metrics: { + type: 'object', + optional: false, nullable: false, + properties: { + completed: { + optional: false, nullable: false, + ref: 'QueueMetrics', + }, + failed: { + optional: false, nullable: false, + ref: 'QueueMetrics', + }, + }, + }, + }, + }, + }, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts b/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts index 63747b5540..1735c22674 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/show-job.ts @@ -5,7 +5,6 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js'; export const meta = { @@ -14,6 +13,11 @@ export const meta = { requireCredential: true, requireModerator: true, kind: 'read:admin:queue', + + res: { + optional: false, nullable: false, + ref: 'QueueJob', + }, } as const; export const paramDef = { @@ -28,7 +32,6 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - private moderationLogService: ModerationLogService, private queueService: QueueService, ) { super(meta, paramDef, async (ps, me) => { diff --git a/packages/frontend/src/components/MkTl.vue b/packages/frontend/src/components/MkTl.vue index 95cc4d2a2a..30bf5389be 100644 --- a/packages/frontend/src/components/MkTl.vue +++ b/packages/frontend/src/components/MkTl.vue @@ -21,15 +21,19 @@ SPDX-License-Identifier: AGPL-3.0-only
- + +