diff --git a/.config/docker_example.env b/.config/docker_example.env index 7a0261524b..4fe8e76b78 100644 --- a/.config/docker_example.env +++ b/.config/docker_example.env @@ -2,3 +2,4 @@ POSTGRES_PASSWORD=example-misskey-pass POSTGRES_USER=example-misskey-user POSTGRES_DB=misskey +DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}" diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c5755315fc..d4678ec5e0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -17,7 +17,7 @@ updates: directory: "/" schedule: interval: daily - open-pull-requests-limit: 5 + open-pull-requests-limit: 10 # List dependencies required to be updated together, sharing the same version numbers. # Those who simply have the common owner (e.g. @fastify) don't need to be listed. groups: diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e1ac6a78..474fcad674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,14 +14,21 @@ ## 202x.x.x (Unreleased) +### General +- Feat: [mCaptcha](https://github.com/mCaptcha/mCaptcha)のサポートを追加 +- Fix: リストライムラインの「リノートを表示」が正しく機能しない問題を修正 + ### Client +- Feat: 新しいゲームを追加 - Enhance: ハッシュタグ入力時に、本文の末尾の行に何も書かれていない場合は新たにスペースを追加しないように +- Fix: ネイティブモードの絵文字がモノクロにならないように - Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正 - Enhance: チャンネルノートのピン留めをノートのメニューからできるよ ### Server - Enhance: 連合先のレートリミットに引っかかった際にリトライするようになりました - Enhance: ActivityPub Deliver queueでBodyを事前処理するように (#12916) +- Enhance: クリップをエクスポートできるように ## 2023.12.2 diff --git a/docker-compose_example.yml b/docker-compose_example.yml index 60ba4dc8ca..5cebbe4164 100644 --- a/docker-compose_example.yml +++ b/docker-compose_example.yml @@ -7,6 +7,7 @@ services: links: - db - redis +# - mcaptcha # - meilisearch depends_on: db: @@ -48,6 +49,36 @@ services: interval: 5s retries: 20 +# mcaptcha: +# restart: always +# image: mcaptcha/mcaptcha:latest +# networks: +# internal_network: +# external_network: +# aliases: +# - localhost +# ports: +# - 7493:7493 +# env_file: +# - .config/docker.env +# environment: +# PORT: 7493 +# MCAPTCHA_redis_URL: "redis://mcaptcha_redis/" +# depends_on: +# db: +# condition: service_healthy +# mcaptcha_redis: +# condition: service_healthy +# +# mcaptcha_redis: +# image: mcaptcha/cache:latest +# networks: +# - internal_network +# healthcheck: +# test: "redis-cli ping" +# interval: 5s +# retries: 20 + # meilisearch: # restart: always # image: getmeili/meilisearch:v1.3.4 diff --git a/locales/index.d.ts b/locales/index.d.ts index 3937784153..7c73caaac9 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -382,6 +382,11 @@ export interface Locale { "enableHcaptcha": string; "hcaptchaSiteKey": string; "hcaptchaSecretKey": string; + "mcaptcha": string; + "enableMcaptcha": string; + "mcaptchaSiteKey": string; + "mcaptchaSecretKey": string; + "mcaptchaInstanceUrl": string; "recaptcha": string; "enableRecaptcha": string; "recaptchaSiteKey": string; @@ -1187,6 +1192,7 @@ export interface Locale { "decorate": string; "addMfmFunction": string; "enableQuickAddMfmFunction": string; + "bubbleGame": string; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; @@ -1651,6 +1657,15 @@ export interface Locale { "title": string; "description": string; }; + "_bubbleGameExplodingHead": { + "title": string; + "description": string; + }; + "_bubbleGameDoubleExplodingHead": { + "title": string; + "description": string; + "flavor": string; + }; }; }; "_role": { @@ -2251,6 +2266,7 @@ export interface Locale { "_exportOrImport": { "allNotes": string; "favoritedNotes": string; + "clips": string; "followingList": string; "muteList": string; "blockingList": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 77f9a9ec0f..55ff3201f0 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -379,6 +379,11 @@ hcaptcha: "hCaptcha" enableHcaptcha: "hCaptchaを有効にする" hcaptchaSiteKey: "サイトキー" hcaptchaSecretKey: "シークレットキー" +mcaptcha: "mCaptcha" +enableMcaptcha: "mCaptchaを有効にする" +mcaptchaSiteKey: "サイトキー" +mcaptchaSecretKey: "シークレットキー" +mcaptchaInstanceUrl: "mCaptchaのインスタンスのURL" recaptcha: "reCAPTCHA" enableRecaptcha: "reCAPTCHAを有効にする" recaptchaSiteKey: "サイトキー" @@ -1184,6 +1189,7 @@ seasonalScreenEffect: "季節に応じた画面の演出" decorate: "デコる" addMfmFunction: "装飾を追加" enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する" +bubbleGame: "バブルゲーム" _announcement: forExistingUsers: "既存ユーザーのみ" @@ -1562,6 +1568,13 @@ _achievements: _tutorialCompleted: title: "Misskey初心者講座 修了証" description: "チュートリアルを完了した" + _bubbleGameExplodingHead: + title: "🤯" + description: "バブルゲームで最も大きいモノを出した" + _bubbleGameDoubleExplodingHead: + title: "ダブル🤯" + description: "バブルゲームで最も大きいモノを2つ同時に出した" + flavor: "これくらいの おべんとばこに 🤯 🤯 ちょっとつめて" _role: new: "ロールの作成" @@ -2154,6 +2167,7 @@ _profile: _exportOrImport: allNotes: "全てのノート" favoritedNotes: "お気に入りにしたノート" + clips: "クリップ" followingList: "フォロー" muteList: "ミュート" blockingList: "ブロック" diff --git a/packages/backend/migration/1704373210054-support-mcaptcha.js b/packages/backend/migration/1704373210054-support-mcaptcha.js new file mode 100644 index 0000000000..ce42b90716 --- /dev/null +++ b/packages/backend/migration/1704373210054-support-mcaptcha.js @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SupportMcaptcha1704373210054 { + name = 'SupportMcaptcha1704373210054' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableMcaptcha" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "meta" ADD "mcaptchaSitekey" character varying(1024)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "mcaptchaSecretKey" character varying(1024)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "mcaptchaInstanceUrl" character varying(1024)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "mcaptchaInstanceUrl"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "mcaptchaSecretKey"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "mcaptchaSitekey"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableMcaptcha"`); + } +} diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 3e9d19f825..c83845b94c 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { setTimeout } from 'node:timers/promises'; import { Global, Inject, Module } from '@nestjs/common'; import * as Redis from 'ioredis'; import { DataSource } from 'typeorm'; @@ -12,6 +11,7 @@ import { DI } from './di-symbols.js'; import { Config, loadConfig } from './config.js'; import { createPostgresDataSource } from './postgres.js'; import { RepositoryModule } from './models/RepositoryModule.js'; +import { allSettled } from './misc/promise-tracker.js'; import type { Provider, OnApplicationShutdown } from '@nestjs/common'; const $config: Provider = { @@ -33,7 +33,7 @@ const $meilisearch: Provider = { useFactory: (config: Config) => { if (config.meilisearch) { return new MeiliSearch({ - host: `${config.meilisearch.ssl ? 'https' : 'http' }://${config.meilisearch.host}:${config.meilisearch.port}`, + host: `${config.meilisearch.ssl ? 'https' : 'http'}://${config.meilisearch.host}:${config.meilisearch.port}`, apiKey: config.meilisearch.apiKey, }); } else { @@ -91,17 +91,12 @@ export class GlobalModule implements OnApplicationShutdown { @Inject(DI.redisForPub) private redisForPub: Redis.Redis, @Inject(DI.redisForSub) private redisForSub: Redis.Redis, @Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis, - ) {} + ) { } public async dispose(): Promise { - if (process.env.NODE_ENV === 'test') { - // XXX: - // Shutting down the existing connections causes errors on Jest as - // Misskey has asynchronous postgres/redis connections that are not - // awaited. - // Let's wait for some random time for them to finish. - await setTimeout(5000); - } + // Wait for all potential DB queries + await allSettled(); + // And then disconnect from DB await Promise.all([ this.db.destroy(), this.redisClient.disconnect(), diff --git a/packages/backend/src/core/AchievementService.ts b/packages/backend/src/core/AchievementService.ts index 88fc033859..a28b68ee86 100644 --- a/packages/backend/src/core/AchievementService.ts +++ b/packages/backend/src/core/AchievementService.ts @@ -87,6 +87,8 @@ export const ACHIEVEMENT_TYPES = [ 'brainDiver', 'smashTestNotificationButton', 'tutorialCompleted', + 'bubbleGameExplodingHead', + 'bubbleGameDoubleExplodingHead', ] as const; @Injectable() diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts index f64196f4fc..6c5ee4835d 100644 --- a/packages/backend/src/core/CaptchaService.ts +++ b/packages/backend/src/core/CaptchaService.ts @@ -73,6 +73,37 @@ export class CaptchaService { } } + // https://codeberg.org/Gusted/mCaptcha/src/branch/main/mcaptcha.go + @bindThis + public async verifyMcaptcha(secret: string, siteKey: string, instanceHost: string, response: string | null | undefined): Promise { + if (response == null) { + throw new Error('mcaptcha-failed: no response provided'); + } + + const endpointUrl = new URL('/api/v1/pow/siteverify', instanceHost); + const result = await this.httpRequestService.send(endpointUrl.toString(), { + method: 'POST', + body: JSON.stringify({ + key: siteKey, + secret: secret, + token: response, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (result.status !== 200) { + throw new Error('mcaptcha-failed: mcaptcha didn\'t return 200 OK'); + } + + const resp = (await result.json()) as { valid: boolean }; + + if (!resp.valid) { + throw new Error('mcaptcha-request-failed'); + } + } + @bindThis public async verifyTurnstile(secret: string, response: string | null | undefined): Promise { if (response == null) { diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index ed8d51df16..97fb80ab39 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -58,6 +58,7 @@ import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { isReply } from '@/misc/is-reply.js'; +import { trackPromise } from '@/misc/promise-tracker.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -676,7 +677,7 @@ export class NoteCreateService implements OnApplicationShutdown { this.relayService.deliverToRelays(user, noteActivity); } - dm.execute(); + trackPromise(dm.execute()); })(); } //#endregion diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts index 03c1735e04..c73cf76592 100644 --- a/packages/backend/src/core/NoteReadService.ts +++ b/packages/backend/src/core/NoteReadService.ts @@ -14,6 +14,7 @@ import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; +import { trackPromise } from '@/misc/promise-tracker.js'; @Injectable() export class NoteReadService implements OnApplicationShutdown { @@ -107,7 +108,7 @@ export class NoteReadService implements OnApplicationShutdown { // TODO: ↓まとめてクエリしたい - this.noteUnreadsRepository.countBy({ + trackPromise(this.noteUnreadsRepository.countBy({ userId: userId, isMentioned: true, }).then(mentionsCount => { @@ -115,9 +116,9 @@ export class NoteReadService implements OnApplicationShutdown { // 全て既読になったイベントを発行 this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions'); } - }); + })); - this.noteUnreadsRepository.countBy({ + trackPromise(this.noteUnreadsRepository.countBy({ userId: userId, isSpecified: true, }).then(specifiedCount => { @@ -125,7 +126,7 @@ export class NoteReadService implements OnApplicationShutdown { // 全て既読になったイベントを発行 this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes'); } - }); + })); } } diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index ad7be83e5b..765fcae063 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -20,6 +20,7 @@ import { CacheService } from '@/core/CacheService.js'; import type { Config } from '@/config.js'; import { UserListService } from '@/core/UserListService.js'; import type { FilterUnionByProperty } from '@/types.js'; +import { trackPromise } from '@/misc/promise-tracker.js'; @Injectable() export class NotificationService implements OnApplicationShutdown { @@ -74,7 +75,18 @@ export class NotificationService implements OnApplicationShutdown { } @bindThis - public async createNotification( + public createNotification( + notifieeId: MiUser['id'], + type: T, + data: Omit, 'type' | 'id' | 'createdAt' | 'notifierId'>, + notifierId?: MiUser['id'] | null, + ) { + trackPromise( + this.#createNotificationInternal(notifieeId, type, data, notifierId), + ); + } + + async #createNotificationInternal( notifieeId: MiUser['id'], type: T, data: Omit, 'type' | 'id' | 'createdAt' | 'notifierId'>, diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index 4444dc9787..20a53ff282 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -3,12 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { setTimeout } from 'node:timers/promises'; import { Inject, Module, OnApplicationShutdown } from '@nestjs/common'; import * as Bull from 'bullmq'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { QUEUE, baseQueueOptions } from '@/queue/const.js'; +import { allSettled } from '@/misc/promise-tracker.js'; import type { Provider } from '@nestjs/common'; import type { DeliverJobData, InboxJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData } from '../queue/types.js'; @@ -106,14 +106,9 @@ export class QueueModule implements OnApplicationShutdown { ) {} public async dispose(): Promise { - if (process.env.NODE_ENV === 'test') { - // XXX: - // Shutting down the existing connections causes errors on Jest as - // Misskey has asynchronous postgres/redis connections that are not - // awaited. - // Let's wait for some random time for them to finish. - await setTimeout(5000); - } + // Wait for all potential queue jobs + await allSettled(); + // And then close all queues await Promise.all([ this.systemQueue.close(), this.endedPollNotificationQueue.close(), diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 4f99dee64e..dc3f248da4 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -182,6 +182,16 @@ export class QueueService { }); } + @bindThis + public createExportClipsJob(user: ThinUser) { + return this.dbQueue.add('exportClips', { + user: { id: user.id }, + }, { + removeOnComplete: true, + removeOnFail: true, + }); + } + @bindThis public createExportFavoritesJob(user: ThinUser) { return this.dbQueue.add('exportFavorites', { diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 3ca12551b1..2e8f76fa8a 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -28,6 +28,7 @@ import { UserBlockingService } from '@/core/UserBlockingService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { RoleService } from '@/core/RoleService.js'; import { FeaturedService } from '@/core/FeaturedService.js'; +import { trackPromise } from '@/misc/promise-tracker.js'; const FALLBACK = '❤'; const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16; @@ -268,7 +269,7 @@ export class ReactionService { } } - dm.execute(); + trackPromise(dm.execute()); } //#endregion } @@ -316,7 +317,7 @@ export class ReactionService { dm.addDirectRecipe(reactee as MiRemoteUser); } dm.addFollowersRecipe(); - dm.execute(); + trackPromise(dm.execute()); } //#endregion } diff --git a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts index 81003bcf1c..d7414e9c99 100644 --- a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts +++ b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts @@ -144,7 +144,7 @@ class DeliverManager { } // deliver - this.queueService.deliverMany(this.actor, this.activity, inboxes); + await this.queueService.deliverMany(this.actor, this.activity, inboxes); } } diff --git a/packages/backend/src/daemons/ServerStatsService.ts b/packages/backend/src/daemons/ServerStatsService.ts index c5ef9b2fa3..4c55acea5a 100644 --- a/packages/backend/src/daemons/ServerStatsService.ts +++ b/packages/backend/src/daemons/ServerStatsService.ts @@ -37,7 +37,7 @@ export class ServerStatsService implements OnApplicationShutdown { const log = [] as any[]; ev.on('requestServerStatsLog', x => { - ev.emit(`serverStatsLog:${x.id}`, log.slice(0, x.length ?? 50)); + ev.emit(`serverStatsLog:${x.id}`, log.slice(0, x.length)); }); const tick = async () => { diff --git a/packages/backend/src/misc/promise-tracker.ts b/packages/backend/src/misc/promise-tracker.ts new file mode 100644 index 0000000000..c7166c6de9 --- /dev/null +++ b/packages/backend/src/misc/promise-tracker.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const promiseRefs: Set>> = new Set(); + +/** + * This tracks promises that other modules decided not to wait for, + * and makes sure they are all settled before fully closing down the server. + */ +export function trackPromise(promise: Promise) { + if (process.env.NODE_ENV !== 'test') { + return; + } + const ref = new WeakRef(promise); + promiseRefs.add(ref); + promise.finally(() => promiseRefs.delete(ref)); +} + +export async function allSettled(): Promise { + await Promise.allSettled([...promiseRefs].map(r => r.deref())); +} diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index f5a75ed28a..3265e85dd7 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -191,6 +191,29 @@ export class MiMeta { }) public hcaptchaSecretKey: string | null; + @Column('boolean', { + default: false, + }) + public enableMcaptcha: boolean; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public mcaptchaSitekey: string | null; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public mcaptchaSecretKey: string | null; + + @Column('varchar', { + length: 1024, + nullable: true, + }) + public mcaptchaInstanceUrl: string | null; + @Column('boolean', { default: false, }) @@ -467,7 +490,7 @@ export class MiMeta { nullable: true, }) public truemailInstance: string | null; - + @Column('varchar', { length: 1024, nullable: true, diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index e6327002c5..9c52c7d76a 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -24,6 +24,7 @@ import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmo import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js'; import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js'; import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js'; +import { ExportClipsProcessorService } from './processors/ExportClipsProcessorService.js'; import { ExportUserListsProcessorService } from './processors/ExportUserListsProcessorService.js'; import { ExportAntennasProcessorService } from './processors/ExportAntennasProcessorService.js'; import { ImportBlockingProcessorService } from './processors/ImportBlockingProcessorService.js'; @@ -53,6 +54,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor DeleteDriveFilesProcessorService, ExportCustomEmojisProcessorService, ExportNotesProcessorService, + ExportClipsProcessorService, ExportFavoritesProcessorService, ExportFollowingProcessorService, ExportMutingProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index b872dd65f7..bcc1a69f80 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -16,6 +16,7 @@ import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js'; import { ExportCustomEmojisProcessorService } from './processors/ExportCustomEmojisProcessorService.js'; import { ExportNotesProcessorService } from './processors/ExportNotesProcessorService.js'; +import { ExportClipsProcessorService } from './processors/ExportClipsProcessorService.js'; import { ExportFollowingProcessorService } from './processors/ExportFollowingProcessorService.js'; import { ExportMutingProcessorService } from './processors/ExportMutingProcessorService.js'; import { ExportBlockingProcessorService } from './processors/ExportBlockingProcessorService.js'; @@ -91,6 +92,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService, private exportCustomEmojisProcessorService: ExportCustomEmojisProcessorService, private exportNotesProcessorService: ExportNotesProcessorService, + private exportClipsProcessorService: ExportClipsProcessorService, private exportFavoritesProcessorService: ExportFavoritesProcessorService, private exportFollowingProcessorService: ExportFollowingProcessorService, private exportMutingProcessorService: ExportMutingProcessorService, @@ -164,6 +166,7 @@ export class QueueProcessorService implements OnApplicationShutdown { case 'deleteDriveFiles': return this.deleteDriveFilesProcessorService.process(job); case 'exportCustomEmojis': return this.exportCustomEmojisProcessorService.process(job); case 'exportNotes': return this.exportNotesProcessorService.process(job); + case 'exportClips': return this.exportClipsProcessorService.process(job); case 'exportFavorites': return this.exportFavoritesProcessorService.process(job); case 'exportFollowing': return this.exportFollowingProcessorService.process(job); case 'exportMuting': return this.exportMutingProcessorService.process(job); diff --git a/packages/backend/src/queue/processors/ExportClipsProcessorService.ts b/packages/backend/src/queue/processors/ExportClipsProcessorService.ts new file mode 100644 index 0000000000..5221497bd3 --- /dev/null +++ b/packages/backend/src/queue/processors/ExportClipsProcessorService.ts @@ -0,0 +1,206 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import { Writable } from 'node:stream'; +import { Inject, Injectable, StreamableFile } from '@nestjs/common'; +import { MoreThan } from 'typeorm'; +import { format as dateFormat } from 'date-fns'; +import { DI } from '@/di-symbols.js'; +import type { ClipNotesRepository, ClipsRepository, MiClip, MiClipNote, MiUser, NotesRepository, PollsRepository, UsersRepository } from '@/models/_.js'; +import type Logger from '@/logger.js'; +import { DriveService } from '@/core/DriveService.js'; +import { createTemp } from '@/misc/create-temp.js'; +import type { MiPoll } from '@/models/Poll.js'; +import type { MiNote } from '@/models/Note.js'; +import { bindThis } from '@/decorators.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { Packed } from '@/misc/json-schema.js'; +import { IdService } from '@/core/IdService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type * as Bull from 'bullmq'; +import type { DbJobDataWithUser } from '../types.js'; + +@Injectable() +export class ExportClipsProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.pollsRepository) + private pollsRepository: PollsRepository, + + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, + + @Inject(DI.clipNotesRepository) + private clipNotesRepository: ClipNotesRepository, + + private driveService: DriveService, + private queueLoggerService: QueueLoggerService, + private idService: IdService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('export-clips'); + } + + @bindThis + public async process(job: Bull.Job): Promise { + this.logger.info(`Exporting clips of ${job.data.user.id} ...`); + + const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); + if (user == null) { + return; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + this.logger.info(`Temp file is ${path}`); + + try { + const stream = Writable.toWeb(fs.createWriteStream(path, { flags: 'a' })); + const writer = stream.getWriter(); + writer.closed.catch(this.logger.error); + + await writer.write('['); + + await this.processClips(writer, user, job); + + await writer.write(']'); + await writer.close(); + + this.logger.succ(`Exported to: ${path}`); + + const fileName = 'clips-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); + + this.logger.succ(`Exported to: ${driveFile.id}`); + } finally { + cleanup(); + } + } + + async processClips(writer: WritableStreamDefaultWriter, user: MiUser, job: Bull.Job) { + let exportedClipsCount = 0; + let cursor: MiClip['id'] | null = null; + + while (true) { + const clips = await this.clipsRepository.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }); + + if (clips.length === 0) { + job.updateProgress(100); + break; + } + + cursor = clips.at(-1)?.id ?? null; + + for (const clip of clips) { + // Stringify but remove the last `]}` + const content = JSON.stringify(this.serializeClip(clip)).slice(0, -2); + const isFirst = exportedClipsCount === 0; + await writer.write(isFirst ? content : ',\n' + content); + + await this.processClipNotes(writer, clip.id); + + await writer.write(']}'); + exportedClipsCount++; + } + + const total = await this.clipsRepository.countBy({ + userId: user.id, + }); + + job.updateProgress(exportedClipsCount / total); + } + } + + async processClipNotes(writer: WritableStreamDefaultWriter, clipId: string): Promise { + let exportedClipNotesCount = 0; + let cursor: MiClipNote['id'] | null = null; + + while (true) { + const clipNotes = await this.clipNotesRepository.find({ + where: { + clipId, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + relations: ['note', 'note.user'], + }) as (MiClipNote & { note: MiNote & { user: MiUser } })[]; + + if (clipNotes.length === 0) { + break; + } + + cursor = clipNotes.at(-1)?.id ?? null; + + for (const clipNote of clipNotes) { + let poll: MiPoll | undefined; + if (clipNote.note.hasPoll) { + poll = await this.pollsRepository.findOneByOrFail({ noteId: clipNote.note.id }); + } + const content = JSON.stringify(this.serializeClipNote(clipNote, poll)); + const isFirst = exportedClipNotesCount === 0; + await writer.write(isFirst ? content : ',\n' + content); + + exportedClipNotesCount++; + } + } + } + + private serializeClip(clip: MiClip): Record { + return { + id: clip.id, + name: clip.name, + description: clip.description, + lastClippedAt: clip.lastClippedAt?.toISOString(), + clipNotes: [], + }; + } + + private serializeClipNote(clip: MiClipNote & { note: MiNote & { user: MiUser } }, poll: MiPoll | undefined): Record { + return { + id: clip.id, + createdAt: this.idService.parse(clip.id).date.toISOString(), + note: { + id: clip.note.id, + text: clip.note.text, + createdAt: this.idService.parse(clip.note.id).date.toISOString(), + fileIds: clip.note.fileIds, + replyId: clip.note.replyId, + renoteId: clip.note.renoteId, + poll: poll, + cw: clip.note.cw, + visibility: clip.note.visibility, + visibleUserIds: clip.note.visibleUserIds, + localOnly: clip.note.localOnly, + reactionAcceptance: clip.note.reactionAcceptance, + uri: clip.note.uri, + url: clip.note.url, + user: { + id: clip.note.user.id, + name: clip.note.user.name, + username: clip.note.user.username, + host: clip.note.user.host, + uri: clip.note.user.uri, + }, + }, + }; + } +} diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 86a64d7121..a3a9805444 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -208,6 +208,7 @@ import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js'; import * as ep___i_exportFollowing from './endpoints/i/export-following.js'; import * as ep___i_exportMute from './endpoints/i/export-mute.js'; import * as ep___i_exportNotes from './endpoints/i/export-notes.js'; +import * as ep___i_exportClips from './endpoints/i/export-clips.js'; import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js'; import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js'; import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js'; @@ -569,6 +570,7 @@ const $i_exportBlocking: Provider = { provide: 'ep:i/export-blocking', useClass: const $i_exportFollowing: Provider = { provide: 'ep:i/export-following', useClass: ep___i_exportFollowing.default }; const $i_exportMute: Provider = { provide: 'ep:i/export-mute', useClass: ep___i_exportMute.default }; const $i_exportNotes: Provider = { provide: 'ep:i/export-notes', useClass: ep___i_exportNotes.default }; +const $i_exportClips: Provider = { provide: 'ep:i/export-clips', useClass: ep___i_exportClips.default }; const $i_exportFavorites: Provider = { provide: 'ep:i/export-favorites', useClass: ep___i_exportFavorites.default }; const $i_exportUserLists: Provider = { provide: 'ep:i/export-user-lists', useClass: ep___i_exportUserLists.default }; const $i_exportAntennas: Provider = { provide: 'ep:i/export-antennas', useClass: ep___i_exportAntennas.default }; @@ -934,6 +936,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_exportFollowing, $i_exportMute, $i_exportNotes, + $i_exportClips, $i_exportFavorites, $i_exportUserLists, $i_exportAntennas, @@ -1293,6 +1296,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_exportFollowing, $i_exportMute, $i_exportNotes, + $i_exportClips, $i_exportFavorites, $i_exportUserLists, $i_exportAntennas, diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index 753984ef52..6b4d9d9f70 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -65,6 +65,7 @@ export class SignupApiService { 'hcaptcha-response'?: string; 'g-recaptcha-response'?: string; 'turnstile-response'?: string; + 'm-captcha-response'?: string; } }>, reply: FastifyReply, @@ -82,6 +83,12 @@ export class SignupApiService { }); } + if (instance.enableMcaptcha && instance.mcaptchaSecretKey && instance.mcaptchaSitekey && instance.mcaptchaInstanceUrl) { + await this.captchaService.verifyMcaptcha(instance.mcaptchaSecretKey, instance.mcaptchaSitekey, instance.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + if (instance.enableRecaptcha && instance.recaptchaSecretKey) { await this.captchaService.verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { throw new FastifyReplyError(400, err); diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 41232091c6..bd8aa4af72 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -3,8 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { Schema } from '@/misc/json-schema.js'; import { permissions } from 'misskey-js'; +import type { Schema } from '@/misc/json-schema.js'; import { RolePolicies } from '@/core/RoleService.js'; import * as ep___admin_meta from './endpoints/admin/meta.js'; @@ -209,6 +209,7 @@ import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js'; import * as ep___i_exportFollowing from './endpoints/i/export-following.js'; import * as ep___i_exportMute from './endpoints/i/export-mute.js'; import * as ep___i_exportNotes from './endpoints/i/export-notes.js'; +import * as ep___i_exportClips from './endpoints/i/export-clips.js'; import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js'; import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js'; import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js'; @@ -568,6 +569,7 @@ const eps = [ ['i/export-following', ep___i_exportFollowing], ['i/export-mute', ep___i_exportMute], ['i/export-notes', ep___i_exportNotes], + ['i/export-clips', ep___i_exportClips], ['i/export-favorites', ep___i_exportFavorites], ['i/export-user-lists', ep___i_exportUserLists], ['i/export-antennas', ep___i_exportAntennas], diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 281f6c484c..0627c5055c 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -41,6 +41,18 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + enableMcaptcha: { + type: 'boolean', + optional: false, nullable: false, + }, + mcaptchaSiteKey: { + type: 'string', + optional: false, nullable: true, + }, + mcaptchaInstanceUrl: { + type: 'string', + optional: false, nullable: true, + }, enableRecaptcha: { type: 'boolean', optional: false, nullable: false, @@ -163,6 +175,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + mcaptchaSecretKey: { + type: 'string', + optional: false, nullable: true, + }, recaptchaSecretKey: { type: 'string', optional: false, nullable: true, @@ -468,6 +484,9 @@ export default class extends Endpoint { // eslint- emailRequiredForSignup: instance.emailRequiredForSignup, enableHcaptcha: instance.enableHcaptcha, hcaptchaSiteKey: instance.hcaptchaSiteKey, + enableMcaptcha: instance.enableMcaptcha, + mcaptchaSiteKey: instance.mcaptchaSitekey, + mcaptchaInstanceUrl: instance.mcaptchaInstanceUrl, enableRecaptcha: instance.enableRecaptcha, recaptchaSiteKey: instance.recaptchaSiteKey, enableTurnstile: instance.enableTurnstile, @@ -498,6 +517,7 @@ export default class extends Endpoint { // eslint- sensitiveWords: instance.sensitiveWords, preservedUsernames: instance.preservedUsernames, hcaptchaSecretKey: instance.hcaptchaSecretKey, + mcaptchaSecretKey: instance.mcaptchaSecretKey, recaptchaSecretKey: instance.recaptchaSecretKey, turnstileSecretKey: instance.turnstileSecretKey, sensitiveMediaDetection: instance.sensitiveMediaDetection, 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 3a6426435d..d76d3dfeea 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -63,6 +63,10 @@ export const paramDef = { enableHcaptcha: { type: 'boolean' }, hcaptchaSiteKey: { type: 'string', nullable: true }, hcaptchaSecretKey: { type: 'string', nullable: true }, + enableMcaptcha: { type: 'boolean' }, + mcaptchaSiteKey: { type: 'string', nullable: true }, + mcaptchaInstanceUrl: { type: 'string', nullable: true }, + mcaptchaSecretKey: { type: 'string', nullable: true }, enableRecaptcha: { type: 'boolean' }, recaptchaSiteKey: { type: 'string', nullable: true }, recaptchaSecretKey: { type: 'string', nullable: true }, @@ -269,6 +273,22 @@ export default class extends Endpoint { // eslint- set.hcaptchaSecretKey = ps.hcaptchaSecretKey; } + if (ps.enableMcaptcha !== undefined) { + set.enableMcaptcha = ps.enableMcaptcha; + } + + if (ps.mcaptchaSiteKey !== undefined) { + set.mcaptchaSitekey = ps.mcaptchaSiteKey; + } + + if (ps.mcaptchaInstanceUrl !== undefined) { + set.mcaptchaInstanceUrl = ps.mcaptchaInstanceUrl; + } + + if (ps.mcaptchaSecretKey !== undefined) { + set.mcaptchaSecretKey = ps.mcaptchaSecretKey; + } + if (ps.enableRecaptcha !== undefined) { set.enableRecaptcha = ps.enableRecaptcha; } @@ -472,7 +492,7 @@ export default class extends Endpoint { // eslint- set.verifymailAuthKey = ps.verifymailAuthKey; } } - + if (ps.enableTruemailApi !== undefined) { set.enableTruemailApi = ps.enableTruemailApi; } diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index 0bf2688b4a..7293c2e39b 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -14,6 +14,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { IdService } from '@/core/IdService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { trackPromise } from '@/misc/promise-tracker.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -92,7 +93,7 @@ export default class extends Endpoint { // eslint- antenna.isActive = true; antenna.lastUsedAt = new Date(); - this.antennasRepository.update(antenna.id, antenna); + trackPromise(this.antennasRepository.update(antenna.id, antenna)); if (needPublishEvent) { this.globalEventService.publishInternalEvent('antennaUpdated', antenna); diff --git a/packages/backend/src/server/api/endpoints/i/export-clips.ts b/packages/backend/src/server/api/endpoints/i/export-clips.ts new file mode 100644 index 0000000000..9435a2b23c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-clips.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueueService } from '@/core/QueueService.js'; + +export const meta = { + secure: true, + requireCredential: true, + limit: { + duration: ms('1day'), + max: 1, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + this.queueService.createExportClipsJob(me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index f7c2962bc2..529e82678d 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -108,6 +108,18 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + enableMcaptcha: { + type: 'boolean', + optional: false, nullable: false, + }, + mcaptchaSiteKey: { + type: 'string', + optional: false, nullable: true, + }, + mcaptchaInstanceUrl: { + type: 'string', + optional: false, nullable: true, + }, enableRecaptcha: { type: 'boolean', optional: false, nullable: false, @@ -351,6 +363,9 @@ export default class extends Endpoint { // eslint- emailRequiredForSignup: instance.emailRequiredForSignup, enableHcaptcha: instance.enableHcaptcha, hcaptchaSiteKey: instance.hcaptchaSiteKey, + enableMcaptcha: instance.enableMcaptcha, + mcaptchaSiteKey: instance.mcaptchaSitekey, + mcaptchaInstanceUrl: instance.mcaptchaInstanceUrl, enableRecaptcha: instance.enableRecaptcha, recaptchaSiteKey: instance.recaptchaSiteKey, enableTurnstile: instance.enableTurnstile, diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 909b5a5e03..e0245814c4 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -21,6 +21,7 @@ class UserListChannel extends Channel { private membershipsMap: Record | undefined> = {}; private listUsersClock: NodeJS.Timeout; private withFiles: boolean; + private withRenotes: boolean; constructor( private userListsRepository: UserListsRepository, @@ -39,6 +40,7 @@ class UserListChannel extends Channel { public async init(params: any) { this.listId = params.listId as string; this.withFiles = params.withFiles ?? false; + this.withRenotes = params.withRenotes ?? true; // Check existence and owner const listExist = await this.userListsRepository.exist({ @@ -104,6 +106,8 @@ class UserListChannel extends Channel { } } + if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する diff --git a/packages/backend/test/e2e/exports.ts b/packages/backend/test/e2e/exports.ts new file mode 100644 index 0000000000..9686f2b7fd --- /dev/null +++ b/packages/backend/test/e2e/exports.ts @@ -0,0 +1,194 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { signup, api, startServer, startJobQueue, port, post } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; +import type * as misskey from 'misskey-js'; + +describe('export-clips', () => { + let app: INestApplicationContext; + let alice: misskey.entities.SignupResponse; + let bob: misskey.entities.SignupResponse; + + // XXX: Any better way to get the result? + async function pollFirstDriveFile() { + while (true) { + const files = (await api('/drive/files', {}, alice)).body; + if (!files.length) { + await new Promise(r => setTimeout(r, 100)); + continue; + } + if (files.length > 1) { + throw new Error('Too many files?'); + } + const file = (await api('/drive/files/show', { fileId: files[0].id }, alice)).body; + const res = await fetch(new URL(new URL(file.url).pathname, `http://127.0.0.1:${port}`)); + return await res.json(); + } + } + + beforeAll(async () => { + app = await startServer(); + await startJobQueue(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + }, 1000 * 60 * 2); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + // Clean all clips and files of alice + const clips = (await api('/clips/list', {}, alice)).body; + for (const clip of clips) { + const res = await api('/clips/delete', { clipId: clip.id }, alice); + if (res.status !== 204) { + throw new Error('Failed to delete clip'); + } + } + const files = (await api('/drive/files', {}, alice)).body; + for (const file of files) { + const res = await api('/drive/files/delete', { fileId: file.id }, alice); + if (res.status !== 204) { + throw new Error('Failed to delete file'); + } + } + }); + + test('basic export', async () => { + let res = await api('/clips/create', { + name: 'foo', + description: 'bar', + }, alice); + assert.strictEqual(res.status, 200); + + res = await api('/i/export-clips', {}, alice); + assert.strictEqual(res.status, 204); + + const exported = await pollFirstDriveFile(); + assert.strictEqual(exported[0].name, 'foo'); + assert.strictEqual(exported[0].description, 'bar'); + assert.strictEqual(exported[0].clipNotes.length, 0); + }); + + test('export with notes', async () => { + let res = await api('/clips/create', { + name: 'foo', + description: 'bar', + }, alice); + assert.strictEqual(res.status, 200); + const clip = res.body; + + const note1 = await post(alice, { + text: 'baz1', + }); + + const note2 = await post(alice, { + text: 'baz2', + poll: { + choices: ['sakura', 'izumi', 'ako'], + }, + }); + + for (const note of [note1, note2]) { + res = await api('/clips/add-note', { + clipId: clip.id, + noteId: note.id, + }, alice); + assert.strictEqual(res.status, 204); + } + + res = await api('/i/export-clips', {}, alice); + assert.strictEqual(res.status, 204); + + const exported = await pollFirstDriveFile(); + assert.strictEqual(exported[0].name, 'foo'); + assert.strictEqual(exported[0].description, 'bar'); + assert.strictEqual(exported[0].clipNotes.length, 2); + assert.strictEqual(exported[0].clipNotes[0].note.text, 'baz1'); + assert.strictEqual(exported[0].clipNotes[1].note.text, 'baz2'); + assert.deepStrictEqual(exported[0].clipNotes[1].note.poll.choices[0], 'sakura'); + }); + + test('multiple clips', async () => { + let res = await api('/clips/create', { + name: 'kawaii', + description: 'kawaii', + }, alice); + assert.strictEqual(res.status, 200); + const clip1 = res.body; + + res = await api('/clips/create', { + name: 'yuri', + description: 'yuri', + }, alice); + assert.strictEqual(res.status, 200); + const clip2 = res.body; + + const note1 = await post(alice, { + text: 'baz1', + }); + + const note2 = await post(alice, { + text: 'baz2', + }); + + res = await api('/clips/add-note', { + clipId: clip1.id, + noteId: note1.id, + }, alice); + assert.strictEqual(res.status, 204); + + res = await api('/clips/add-note', { + clipId: clip2.id, + noteId: note2.id, + }, alice); + assert.strictEqual(res.status, 204); + + res = await api('/i/export-clips', {}, alice); + assert.strictEqual(res.status, 204); + + const exported = await pollFirstDriveFile(); + assert.strictEqual(exported[0].name, 'kawaii'); + assert.strictEqual(exported[0].clipNotes.length, 1); + assert.strictEqual(exported[0].clipNotes[0].note.text, 'baz1'); + assert.strictEqual(exported[1].name, 'yuri'); + assert.strictEqual(exported[1].clipNotes.length, 1); + assert.strictEqual(exported[1].clipNotes[0].note.text, 'baz2'); + }); + + test('Clipping other user\'s note', async () => { + let res = await api('/clips/create', { + name: 'kawaii', + description: 'kawaii', + }, alice); + assert.strictEqual(res.status, 200); + const clip = res.body; + + const note = await post(bob, { + text: 'baz', + visibility: 'followers', + }); + + res = await api('/clips/add-note', { + clipId: clip.id, + noteId: note.id, + }, alice); + assert.strictEqual(res.status, 204); + + res = await api('/i/export-clips', {}, alice); + assert.strictEqual(res.status, 204); + + const exported = await pollFirstDriveFile(); + assert.strictEqual(exported[0].name, 'kawaii'); + assert.strictEqual(exported[0].clipNotes.length, 1); + assert.strictEqual(exported[0].clipNotes[0].note.text, 'baz'); + assert.strictEqual(exported[0].clipNotes[0].note.user.username, 'bob'); + }); +}); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 46b8ea9cdd..7c9428d476 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -17,7 +17,7 @@ import { entities } from '../src/postgres.js'; import { loadConfig } from '../src/config.js'; import type * as misskey from 'misskey-js'; -export { server as startServer } from '@/boot/common.js'; +export { server as startServer, jobQueue as startJobQueue } from '@/boot/common.js'; interface UserToken { token: string; diff --git a/packages/frontend/assets/drop-and-fusion/bgm_1.mp3 b/packages/frontend/assets/drop-and-fusion/bgm_1.mp3 new file mode 100644 index 0000000000..cafc34ad9c Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/bgm_1.mp3 differ diff --git a/packages/frontend/assets/drop-and-fusion/bubble2.mp3 b/packages/frontend/assets/drop-and-fusion/bubble2.mp3 new file mode 100644 index 0000000000..8b4f8df6e9 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/bubble2.mp3 differ diff --git a/packages/frontend/assets/drop-and-fusion/cold_face.png b/packages/frontend/assets/drop-and-fusion/cold_face.png new file mode 100644 index 0000000000..f5f53e9efc Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/cold_face.png differ diff --git a/packages/frontend/assets/drop-and-fusion/drop-arrow.svg b/packages/frontend/assets/drop-and-fusion/drop-arrow.svg new file mode 100644 index 0000000000..f98bb8a1ac Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/drop-arrow.svg differ diff --git a/packages/frontend/assets/drop-and-fusion/dropper.png b/packages/frontend/assets/drop-and-fusion/dropper.png new file mode 100644 index 0000000000..f4300aa5c0 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/dropper.png differ diff --git a/packages/frontend/assets/drop-and-fusion/exploding_head.png b/packages/frontend/assets/drop-and-fusion/exploding_head.png new file mode 100644 index 0000000000..e8ec5182c8 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/exploding_head.png differ diff --git a/packages/frontend/assets/drop-and-fusion/face_with_open_mouth.png b/packages/frontend/assets/drop-and-fusion/face_with_open_mouth.png new file mode 100644 index 0000000000..c523020f62 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/face_with_open_mouth.png differ diff --git a/packages/frontend/assets/drop-and-fusion/face_with_symbols_on_mouth.png b/packages/frontend/assets/drop-and-fusion/face_with_symbols_on_mouth.png new file mode 100644 index 0000000000..db9e839c84 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/face_with_symbols_on_mouth.png differ diff --git a/packages/frontend/assets/drop-and-fusion/frame-dark.svg b/packages/frontend/assets/drop-and-fusion/frame-dark.svg new file mode 100644 index 0000000000..3fa7c0da81 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/frame-dark.svg differ diff --git a/packages/frontend/assets/drop-and-fusion/frame-light.svg b/packages/frontend/assets/drop-and-fusion/frame-light.svg new file mode 100644 index 0000000000..6052ccbaa0 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/frame-light.svg differ diff --git a/packages/frontend/assets/drop-and-fusion/gameover.png b/packages/frontend/assets/drop-and-fusion/gameover.png new file mode 100644 index 0000000000..8b622577ca Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/gameover.png differ diff --git a/packages/frontend/assets/drop-and-fusion/grinning_squinting_face.png b/packages/frontend/assets/drop-and-fusion/grinning_squinting_face.png new file mode 100644 index 0000000000..fd72d749a1 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/grinning_squinting_face.png differ diff --git a/packages/frontend/assets/drop-and-fusion/heart_suit.png b/packages/frontend/assets/drop-and-fusion/heart_suit.png new file mode 100644 index 0000000000..b0105f8582 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/heart_suit.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_1.png b/packages/frontend/assets/drop-and-fusion/keycap_1.png new file mode 100644 index 0000000000..d672f2854a Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_1.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_10.png b/packages/frontend/assets/drop-and-fusion/keycap_10.png new file mode 100644 index 0000000000..32cf193540 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_10.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_2.png b/packages/frontend/assets/drop-and-fusion/keycap_2.png new file mode 100644 index 0000000000..81c3f58e6e Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_2.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_3.png b/packages/frontend/assets/drop-and-fusion/keycap_3.png new file mode 100644 index 0000000000..424d8c123d Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_3.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_4.png b/packages/frontend/assets/drop-and-fusion/keycap_4.png new file mode 100644 index 0000000000..ea6ae50531 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_4.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_5.png b/packages/frontend/assets/drop-and-fusion/keycap_5.png new file mode 100644 index 0000000000..ad435da69a Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_5.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_6.png b/packages/frontend/assets/drop-and-fusion/keycap_6.png new file mode 100644 index 0000000000..70c9522b43 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_6.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_7.png b/packages/frontend/assets/drop-and-fusion/keycap_7.png new file mode 100644 index 0000000000..5a24307487 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_7.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_8.png b/packages/frontend/assets/drop-and-fusion/keycap_8.png new file mode 100644 index 0000000000..9689d8ecfb Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_8.png differ diff --git a/packages/frontend/assets/drop-and-fusion/keycap_9.png b/packages/frontend/assets/drop-and-fusion/keycap_9.png new file mode 100644 index 0000000000..ac3f638841 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/keycap_9.png differ diff --git a/packages/frontend/assets/drop-and-fusion/logo.png b/packages/frontend/assets/drop-and-fusion/logo.png new file mode 100644 index 0000000000..c6725bea88 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/logo.png differ diff --git a/packages/frontend/assets/drop-and-fusion/pleading_face.png b/packages/frontend/assets/drop-and-fusion/pleading_face.png new file mode 100644 index 0000000000..42f58d411c Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/pleading_face.png differ diff --git a/packages/frontend/assets/drop-and-fusion/poi1.mp3 b/packages/frontend/assets/drop-and-fusion/poi1.mp3 new file mode 100644 index 0000000000..59dae90965 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/poi1.mp3 differ diff --git a/packages/frontend/assets/drop-and-fusion/poi2.mp3 b/packages/frontend/assets/drop-and-fusion/poi2.mp3 new file mode 100644 index 0000000000..a65c653891 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/poi2.mp3 differ diff --git a/packages/frontend/assets/drop-and-fusion/smiling_face_with_hearts.png b/packages/frontend/assets/drop-and-fusion/smiling_face_with_hearts.png new file mode 100644 index 0000000000..416ef0410a Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/smiling_face_with_hearts.png differ diff --git a/packages/frontend/assets/drop-and-fusion/smiling_face_with_sunglasses.png b/packages/frontend/assets/drop-and-fusion/smiling_face_with_sunglasses.png new file mode 100644 index 0000000000..c0f72254c2 Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/smiling_face_with_sunglasses.png differ diff --git a/packages/frontend/assets/drop-and-fusion/zany_face.png b/packages/frontend/assets/drop-and-fusion/zany_face.png new file mode 100644 index 0000000000..f14f9db20b Binary files /dev/null and b/packages/frontend/assets/drop-and-fusion/zany_face.png differ diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 864779fd9d..9ef18a56a7 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "watch": "vite", - "dev": "vite --config vite.config.local-dev.ts", + "dev": "vite --config vite.config.local-dev.ts --debug hmr", "build": "vite build", "storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"", "build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js", @@ -19,6 +19,7 @@ "dependencies": { "@discordapp/twemoji": "15.0.2", "@github/webauthn-json": "2.1.1", + "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@misskey-dev/browser-image-resizer": "2.2.1-misskey.10", "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "5.0.5", diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts index ef69eff764..c67911c9c3 100644 --- a/packages/frontend/src/boot/common.ts +++ b/packages/frontend/src/boot/common.ts @@ -22,6 +22,7 @@ import { getAccountFromId } from '@/scripts/get-account-from-id.js'; import { deckStore } from '@/ui/deck/deck-store.js'; import { miLocalStorage } from '@/local-storage.js'; import { fetchCustomEmojis } from '@/custom-emojis.js'; +import { setupRouter } from '@/global/router/definition.js'; export async function common(createVue: () => App) { console.info(`Misskey v${version}`); @@ -241,6 +242,8 @@ export async function common(createVue: () => App) { const app = createVue(); + setupRouter(app); + if (_DEV_) { app.config.performance = true; } diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 0159d0c032..5011ce9e74 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -3,23 +3,23 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { createApp, markRaw, defineAsyncComponent } from 'vue'; +import { createApp, defineAsyncComponent, markRaw } from 'vue'; import { common } from './common.js'; import { ui } from '@/config.js'; import { i18n } from '@/i18n.js'; -import { confirm, alert, post, popup, toast } from '@/os.js'; +import { alert, confirm, popup, post, toast } from '@/os.js'; import { useStream } from '@/stream.js'; import * as sound from '@/scripts/sound.js'; -import { $i, updateAccount, signout } from '@/account.js'; -import { defaultStore, ColdDeviceStorage } from '@/store.js'; +import { $i, signout, updateAccount } from '@/account.js'; +import { ColdDeviceStorage, defaultStore } from '@/store.js'; import { makeHotkey } from '@/scripts/hotkey.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; import { miLocalStorage } from '@/local-storage.js'; import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js'; -import { mainRouter } from '@/router.js'; import { initializeSw } from '@/scripts/initialize-sw.js'; import { deckStore } from '@/ui/deck/deck-store.js'; import { emojiPicker } from '@/scripts/emoji-picker.js'; +import { mainRouter } from '@/global/router/main.js'; export async function mainBoot() { const { isClientUpdated } = await common(() => createApp( diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index 328215c791..7aa08cf51f 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -6,12 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index ce077779c8..8298dc6d79 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -45,7 +45,7 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; -import { useRouter } from '@/router.js'; +import { useRouter } from '@/global/router/supplier.js'; const PRESET_DEFAULT = `/// @ 0.16.0 diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue index e0b9f87d46..7852018894 100644 --- a/packages/frontend/src/pages/flash/flash-index.vue +++ b/packages/frontend/src/pages/flash/flash-index.vue @@ -42,9 +42,9 @@ import { computed, ref } from 'vue'; import MkFlashPreview from '@/components/MkFlashPreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; -import { useRouter } from '@/router.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { useRouter } from '@/global/router/supplier.js'; const router = useRouter(); diff --git a/packages/frontend/src/pages/follow.vue b/packages/frontend/src/pages/follow.vue index 5a21604080..eefef828bd 100644 --- a/packages/frontend/src/pages/follow.vue +++ b/packages/frontend/src/pages/follow.vue @@ -13,9 +13,9 @@ import { } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { mainRouter } from '@/router.js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; +import { mainRouter } from '@/global/router/main.js'; async function follow(user): Promise { const { canceled } = await os.confirm({ diff --git a/packages/frontend/src/pages/gallery/edit.vue b/packages/frontend/src/pages/gallery/edit.vue index e0c7654531..f7db01ce95 100644 --- a/packages/frontend/src/pages/gallery/edit.vue +++ b/packages/frontend/src/pages/gallery/edit.vue @@ -48,9 +48,9 @@ import FormSuspense from '@/components/form/suspense.vue'; import { selectFiles } from '@/scripts/select-file.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useRouter } from '@/router.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; +import { useRouter } from '@/global/router/supplier.js'; const router = useRouter(); diff --git a/packages/frontend/src/pages/gallery/index.vue b/packages/frontend/src/pages/gallery/index.vue index 8d9ac07805..0198ab9700 100644 --- a/packages/frontend/src/pages/gallery/index.vue +++ b/packages/frontend/src/pages/gallery/index.vue @@ -53,7 +53,7 @@ import MkPagination from '@/components/MkPagination.vue'; import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router.js'; +import { useRouter } from '@/global/router/supplier.js'; const router = useRouter(); diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue index f71fe0f260..dcd427d6b4 100644 --- a/packages/frontend/src/pages/gallery/post.vue +++ b/packages/frontend/src/pages/gallery/post.vue @@ -72,13 +72,13 @@ import MkPagination from '@/components/MkPagination.vue'; import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; import { url } from '@/config.js'; -import { useRouter } from '@/router.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { defaultStore } from '@/store.js'; import { $i } from '@/account.js'; import { isSupportShare } from '@/scripts/navigator.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { useRouter } from '@/global/router/supplier.js'; const router = useRouter(); diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue index c5b1b54222..61b9424bdd 100644 --- a/packages/frontend/src/pages/my-antennas/create.vue +++ b/packages/frontend/src/pages/my-antennas/create.vue @@ -14,8 +14,8 @@ import { ref } from 'vue'; import XAntenna from './editor.vue'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { useRouter } from '@/router.js'; import { antennasCache } from '@/cache.js'; +import { useRouter } from '@/global/router/supplier.js'; const router = useRouter(); diff --git a/packages/frontend/src/pages/my-antennas/edit.vue b/packages/frontend/src/pages/my-antennas/edit.vue index 0648f5340f..b4ca7cc9f8 100644 --- a/packages/frontend/src/pages/my-antennas/edit.vue +++ b/packages/frontend/src/pages/my-antennas/edit.vue @@ -15,9 +15,9 @@ import * as Misskey from 'misskey-js'; import XAntenna from './editor.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { useRouter } from '@/router.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { antennasCache } from '@/cache.js'; +import { useRouter } from '@/global/router/supplier.js'; const router = useRouter(); diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue index 5798070ad8..85775a2fdd 100644 --- a/packages/frontend/src/pages/my-lists/list.vue +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -58,7 +58,6 @@ import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { mainRouter } from '@/router.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; import { userPage } from '@/filters/user.js'; @@ -70,6 +69,7 @@ import { userListsCache } from '@/cache.js'; import { signinRequired } from '@/account.js'; import { defaultStore } from '@/store.js'; import MkPagination from '@/components/MkPagination.vue'; +import { mainRouter } from '@/global/router/main.js'; const $i = signinRequired(); diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue index 496a8c3274..6db72dccba 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -73,10 +73,10 @@ import { url } from '@/config.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { selectFile } from '@/scripts/select-file.js'; -import { mainRouter } from '@/router.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { $i } from '@/account.js'; +import { mainRouter } from '@/global/router/main.js'; const props = defineProps<{ initPageId?: string; diff --git a/packages/frontend/src/pages/pages.vue b/packages/frontend/src/pages/pages.vue index bc51b55c7f..22ab9ced09 100644 --- a/packages/frontend/src/pages/pages.vue +++ b/packages/frontend/src/pages/pages.vue @@ -40,9 +40,9 @@ import { computed, ref } from 'vue'; import MkPagePreview from '@/components/MkPagePreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; -import { useRouter } from '@/router.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { useRouter } from '@/global/router/supplier.js'; const router = useRouter(); diff --git a/packages/frontend/src/pages/reset-password.vue b/packages/frontend/src/pages/reset-password.vue index c9d193b787..d8dec27513 100644 --- a/packages/frontend/src/pages/reset-password.vue +++ b/packages/frontend/src/pages/reset-password.vue @@ -25,8 +25,8 @@ import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { mainRouter } from '@/router.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import { mainRouter } from '@/global/router/main.js'; const props = defineProps<{ token?: string; diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue index 1b12910a38..811218faf5 100644 --- a/packages/frontend/src/pages/search.note.vue +++ b/packages/frontend/src/pages/search.note.vue @@ -51,8 +51,8 @@ import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -import { useRouter } from '@/router.js'; import MkFolder from '@/components/MkFolder.vue'; +import { useRouter } from '@/global/router/supplier.js'; const router = useRouter(); diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue index 5e9048ee57..82cedc9833 100644 --- a/packages/frontend/src/pages/search.user.vue +++ b/packages/frontend/src/pages/search.user.vue @@ -34,7 +34,7 @@ import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useRouter } from '@/router.js'; +import { useRouter } from '@/global/router/supplier.js'; const router = useRouter(); diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue index 990eff99c1..70d718f1ab 100644 --- a/packages/frontend/src/pages/settings/import-export.vue +++ b/packages/frontend/src/pages/settings/import-export.vue @@ -21,6 +21,14 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.export }} + + + + + + {{ i18n.ts.export }} + +
@@ -157,6 +165,10 @@ const exportFavorites = () => { misskeyApi('i/export-favorites', {}).then(onExportSuccess).catch(onError); }; +const exportClips = () => { + misskeyApi('i/export-clips', {}).then(onExportSuccess).catch(onError); +}; + const exportFollowing = () => { misskeyApi('i/export-following', { excludeMuting: excludeMutingUsers.value, diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index ee0188873e..be443033bc 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -35,9 +35,9 @@ import MkSuperMenu from '@/components/MkSuperMenu.vue'; import { signout, $i } from '@/account.js'; import { clearCache } from '@/scripts/clear-cache.js'; import { instance } from '@/instance.js'; -import { useRouter } from '@/router.js'; import { PageMetadata, definePageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js'; import * as os from '@/os.js'; +import { useRouter } from '@/global/router/supplier.js'; const indexInfo = { title: i18n.ts.settings, diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue index 9eb344bd46..a122c4c819 100644 --- a/packages/frontend/src/pages/settings/webhook.edit.vue +++ b/packages/frontend/src/pages/settings/webhook.edit.vue @@ -51,7 +51,7 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { useRouter } from '@/router.js'; +import { useRouter } from '@/global/router/supplier.js'; const router = useRouter(); diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue index 19c376c77b..10a21ef20d 100644 --- a/packages/frontend/src/pages/user-list-timeline.vue +++ b/packages/frontend/src/pages/user-list-timeline.vue @@ -29,9 +29,9 @@ import * as Misskey from 'misskey-js'; import MkTimeline from '@/components/MkTimeline.vue'; import { scroll } from '@/scripts/scroll.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { useRouter } from '@/router.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { i18n } from '@/i18n.js'; +import { useRouter } from '@/global/router/supplier.js'; const router = useRouter(); diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 5258165d7c..ed9722b7ed 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -166,13 +166,13 @@ import { getUserMenu } from '@/scripts/get-user-menu.js'; import number from '@/filters/number.js'; import { userPage } from '@/filters/user.js'; import * as os from '@/os.js'; -import { useRouter } from '@/router.js'; import { i18n } from '@/i18n.js'; import { $i, iAmModerator } from '@/account.js'; import { dateString } from '@/filters/date.js'; import { confetti } from '@/scripts/confetti.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; +import { useRouter } from '@/global/router/supplier.js'; function calcAge(birthdate: string): number { const date = new Date(birthdate); diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts deleted file mode 100644 index baee85866c..0000000000 --- a/packages/frontend/src/router.ts +++ /dev/null @@ -1,557 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { AsyncComponentLoader, defineAsyncComponent, inject } from 'vue'; -import { Router } from '@/nirax.js'; -import { $i, iAmModerator } from '@/account.js'; -import MkLoading from '@/pages/_loading_.vue'; -import MkError from '@/pages/_error_.vue'; - -export const page = (loader: AsyncComponentLoader) => defineAsyncComponent({ - loader: loader, - loadingComponent: MkLoading, - errorComponent: MkError, -}); - -export const routes = [{ - path: '/@:initUser/pages/:initPageName/view-source', - component: page(() => import('./pages/page-editor/page-editor.vue')), -}, { - path: '/@:username/pages/:pageName', - component: page(() => import('./pages/page.vue')), -}, { - path: '/@:acct/following', - component: page(() => import('./pages/user/following.vue')), -}, { - path: '/@:acct/followers', - component: page(() => import('./pages/user/followers.vue')), -}, { - name: 'user', - path: '/@:acct/:page?', - component: page(() => import('./pages/user/index.vue')), -}, { - name: 'note', - path: '/notes/:noteId', - component: page(() => import('./pages/note.vue')), -}, { - name: 'list', - path: '/list/:listId', - component: page(() => import('./pages/list.vue')), -}, { - path: '/clips/:clipId', - component: page(() => import('./pages/clip.vue')), -}, { - path: '/instance-info/:host', - component: page(() => import('./pages/instance-info.vue')), -}, { - name: 'settings', - path: '/settings', - component: page(() => import('./pages/settings/index.vue')), - loginRequired: true, - children: [{ - path: '/profile', - name: 'profile', - component: page(() => import('./pages/settings/profile.vue')), - }, { - path: '/avatar-decoration', - name: 'avatarDecoration', - component: page(() => import('./pages/settings/avatar-decoration.vue')), - }, { - path: '/roles', - name: 'roles', - component: page(() => import('./pages/settings/roles.vue')), - }, { - path: '/privacy', - name: 'privacy', - component: page(() => import('./pages/settings/privacy.vue')), - }, { - path: '/emoji-picker', - name: 'emojiPicker', - component: page(() => import('./pages/settings/emoji-picker.vue')), - }, { - path: '/drive', - name: 'drive', - component: page(() => import('./pages/settings/drive.vue')), - }, { - path: '/drive/cleaner', - name: 'drive', - component: page(() => import('./pages/settings/drive-cleaner.vue')), - }, { - path: '/notifications', - name: 'notifications', - component: page(() => import('./pages/settings/notifications.vue')), - }, { - path: '/email', - name: 'email', - component: page(() => import('./pages/settings/email.vue')), - }, { - path: '/security', - name: 'security', - component: page(() => import('./pages/settings/security.vue')), - }, { - path: '/general', - name: 'general', - component: page(() => import('./pages/settings/general.vue')), - }, { - path: '/theme/install', - name: 'theme', - component: page(() => import('./pages/settings/theme.install.vue')), - }, { - path: '/theme/manage', - name: 'theme', - component: page(() => import('./pages/settings/theme.manage.vue')), - }, { - path: '/theme', - name: 'theme', - component: page(() => import('./pages/settings/theme.vue')), - }, { - path: '/navbar', - name: 'navbar', - component: page(() => import('./pages/settings/navbar.vue')), - }, { - path: '/statusbar', - name: 'statusbar', - component: page(() => import('./pages/settings/statusbar.vue')), - }, { - path: '/sounds', - name: 'sounds', - component: page(() => import('./pages/settings/sounds.vue')), - }, { - path: '/plugin/install', - name: 'plugin', - component: page(() => import('./pages/settings/plugin.install.vue')), - }, { - path: '/plugin', - name: 'plugin', - component: page(() => import('./pages/settings/plugin.vue')), - }, { - path: '/import-export', - name: 'import-export', - component: page(() => import('./pages/settings/import-export.vue')), - }, { - path: '/mute-block', - name: 'mute-block', - component: page(() => import('./pages/settings/mute-block.vue')), - }, { - path: '/api', - name: 'api', - component: page(() => import('./pages/settings/api.vue')), - }, { - path: '/apps', - name: 'api', - component: page(() => import('./pages/settings/apps.vue')), - }, { - path: '/webhook/edit/:webhookId', - name: 'webhook', - component: page(() => import('./pages/settings/webhook.edit.vue')), - }, { - path: '/webhook/new', - name: 'webhook', - component: page(() => import('./pages/settings/webhook.new.vue')), - }, { - path: '/webhook', - name: 'webhook', - component: page(() => import('./pages/settings/webhook.vue')), - }, { - path: '/deck', - name: 'deck', - component: page(() => import('./pages/settings/deck.vue')), - }, { - path: '/preferences-backups', - name: 'preferences-backups', - component: page(() => import('./pages/settings/preferences-backups.vue')), - }, { - path: '/migration', - name: 'migration', - component: page(() => import('./pages/settings/migration.vue')), - }, { - path: '/custom-css', - name: 'general', - component: page(() => import('./pages/settings/custom-css.vue')), - }, { - path: '/accounts', - name: 'profile', - component: page(() => import('./pages/settings/accounts.vue')), - }, { - path: '/other', - name: 'other', - component: page(() => import('./pages/settings/other.vue')), - }, { - path: '/', - component: page(() => import('./pages/_empty_.vue')), - }], -}, { - path: '/reset-password/:token?', - component: page(() => import('./pages/reset-password.vue')), -}, { - path: '/signup-complete/:code', - component: page(() => import('./pages/signup-complete.vue')), -}, { - path: '/announcements', - component: page(() => import('./pages/announcements.vue')), -}, { - path: '/about', - component: page(() => import('./pages/about.vue')), - hash: 'initialTab', -}, { - path: '/about-misskey', - component: page(() => import('./pages/about-misskey.vue')), -}, { - path: '/invite', - name: 'invite', - component: page(() => import('./pages/invite.vue')), -}, { - path: '/ads', - component: page(() => import('./pages/ads.vue')), -}, { - path: '/theme-editor', - component: page(() => import('./pages/theme-editor.vue')), - loginRequired: true, -}, { - path: '/roles/:role', - component: page(() => import('./pages/role.vue')), -}, { - path: '/user-tags/:tag', - component: page(() => import('./pages/user-tag.vue')), -}, { - path: '/explore', - component: page(() => import('./pages/explore.vue')), - hash: 'initialTab', -}, { - path: '/search', - component: page(() => import('./pages/search.vue')), - query: { - q: 'query', - channel: 'channel', - type: 'type', - origin: 'origin', - }, -}, { - path: '/authorize-follow', - component: page(() => import('./pages/follow.vue')), - loginRequired: true, -}, { - path: '/share', - component: page(() => import('./pages/share.vue')), - loginRequired: true, -}, { - path: '/api-console', - component: page(() => import('./pages/api-console.vue')), - loginRequired: true, -}, { - path: '/scratchpad', - component: page(() => import('./pages/scratchpad.vue')), -}, { - path: '/auth/:token', - component: page(() => import('./pages/auth.vue')), -}, { - path: '/miauth/:session', - component: page(() => import('./pages/miauth.vue')), - query: { - callback: 'callback', - name: 'name', - icon: 'icon', - permission: 'permission', - }, -}, { - path: '/oauth/authorize', - component: page(() => import('./pages/oauth.vue')), -}, { - path: '/tags/:tag', - component: page(() => import('./pages/tag.vue')), -}, { - path: '/pages/new', - component: page(() => import('./pages/page-editor/page-editor.vue')), - loginRequired: true, -}, { - path: '/pages/edit/:initPageId', - component: page(() => import('./pages/page-editor/page-editor.vue')), - loginRequired: true, -}, { - path: '/pages', - component: page(() => import('./pages/pages.vue')), -}, { - path: '/play/:id/edit', - component: page(() => import('./pages/flash/flash-edit.vue')), - loginRequired: true, -}, { - path: '/play/new', - component: page(() => import('./pages/flash/flash-edit.vue')), - loginRequired: true, -}, { - path: '/play/:id', - component: page(() => import('./pages/flash/flash.vue')), -}, { - path: '/play', - component: page(() => import('./pages/flash/flash-index.vue')), -}, { - path: '/gallery/:postId/edit', - component: page(() => import('./pages/gallery/edit.vue')), - loginRequired: true, -}, { - path: '/gallery/new', - component: page(() => import('./pages/gallery/edit.vue')), - loginRequired: true, -}, { - path: '/gallery/:postId', - component: page(() => import('./pages/gallery/post.vue')), -}, { - path: '/gallery', - component: page(() => import('./pages/gallery/index.vue')), -}, { - path: '/channels/:channelId/edit', - component: page(() => import('./pages/channel-editor.vue')), - loginRequired: true, -}, { - path: '/channels/new', - component: page(() => import('./pages/channel-editor.vue')), - loginRequired: true, -}, { - path: '/channels/:channelId', - component: page(() => import('./pages/channel.vue')), -}, { - path: '/channels', - component: page(() => import('./pages/channels.vue')), -}, { - path: '/custom-emojis-manager', - component: page(() => import('./pages/custom-emojis-manager.vue')), -}, { - path: '/avatar-decorations', - name: 'avatarDecorations', - component: page(() => import('./pages/avatar-decorations.vue')), -}, { - path: '/registry/keys/:domain/:path(*)?', - component: page(() => import('./pages/registry.keys.vue')), -}, { - path: '/registry/value/:domain/:path(*)?', - component: page(() => import('./pages/registry.value.vue')), -}, { - path: '/registry', - component: page(() => import('./pages/registry.vue')), -}, { - path: '/install-extentions', - component: page(() => import('./pages/install-extentions.vue')), - loginRequired: true, -}, { - path: '/admin/user/:userId', - component: iAmModerator ? page(() => import('./pages/admin-user.vue')) : page(() => import('./pages/not-found.vue')), -}, { - path: '/admin/file/:fileId', - component: iAmModerator ? page(() => import('./pages/admin-file.vue')) : page(() => import('./pages/not-found.vue')), -}, { - path: '/admin', - component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page(() => import('./pages/not-found.vue')), - children: [{ - path: '/overview', - name: 'overview', - component: page(() => import('./pages/admin/overview.vue')), - }, { - path: '/users', - name: 'users', - component: page(() => import('./pages/admin/users.vue')), - }, { - path: '/emojis', - name: 'emojis', - component: page(() => import('./pages/custom-emojis-manager.vue')), - }, { - path: '/avatar-decorations', - name: 'avatarDecorations', - component: page(() => import('./pages/avatar-decorations.vue')), - }, { - path: '/queue', - name: 'queue', - component: page(() => import('./pages/admin/queue.vue')), - }, { - path: '/files', - name: 'files', - component: page(() => import('./pages/admin/files.vue')), - }, { - path: '/federation', - name: 'federation', - component: page(() => import('./pages/admin/federation.vue')), - }, { - path: '/announcements', - name: 'announcements', - component: page(() => import('./pages/admin/announcements.vue')), - }, { - path: '/ads', - name: 'ads', - component: page(() => import('./pages/admin/ads.vue')), - }, { - path: '/roles/:id/edit', - name: 'roles', - component: page(() => import('./pages/admin/roles.edit.vue')), - }, { - path: '/roles/new', - name: 'roles', - component: page(() => import('./pages/admin/roles.edit.vue')), - }, { - path: '/roles/:id', - name: 'roles', - component: page(() => import('./pages/admin/roles.role.vue')), - }, { - path: '/roles', - name: 'roles', - component: page(() => import('./pages/admin/roles.vue')), - }, { - path: '/database', - name: 'database', - component: page(() => import('./pages/admin/database.vue')), - }, { - path: '/abuses', - name: 'abuses', - component: page(() => import('./pages/admin/abuses.vue')), - }, { - path: '/modlog', - name: 'modlog', - component: page(() => import('./pages/admin/modlog.vue')), - }, { - path: '/settings', - name: 'settings', - component: page(() => import('./pages/admin/settings.vue')), - }, { - path: '/branding', - name: 'branding', - component: page(() => import('./pages/admin/branding.vue')), - }, { - path: '/moderation', - name: 'moderation', - component: page(() => import('./pages/admin/moderation.vue')), - }, { - path: '/email-settings', - name: 'email-settings', - component: page(() => import('./pages/admin/email-settings.vue')), - }, { - path: '/object-storage', - name: 'object-storage', - component: page(() => import('./pages/admin/object-storage.vue')), - }, { - path: '/security', - name: 'security', - component: page(() => import('./pages/admin/security.vue')), - }, { - path: '/relays', - name: 'relays', - component: page(() => import('./pages/admin/relays.vue')), - }, { - path: '/instance-block', - name: 'instance-block', - component: page(() => import('./pages/admin/instance-block.vue')), - }, { - path: '/proxy-account', - name: 'proxy-account', - component: page(() => import('./pages/admin/proxy-account.vue')), - }, { - path: '/external-services', - name: 'external-services', - component: page(() => import('./pages/admin/external-services.vue')), - }, { - path: '/other-settings', - name: 'other-settings', - component: page(() => import('./pages/admin/other-settings.vue')), - }, { - path: '/server-rules', - name: 'server-rules', - component: page(() => import('./pages/admin/server-rules.vue')), - }, { - path: '/invites', - name: 'invites', - component: page(() => import('./pages/admin/invites.vue')), - }, { - path: '/', - component: page(() => import('./pages/_empty_.vue')), - }], -}, { - path: '/my/notifications', - component: page(() => import('./pages/notifications.vue')), - loginRequired: true, -}, { - path: '/my/favorites', - component: page(() => import('./pages/favorites.vue')), - loginRequired: true, -}, { - path: '/my/achievements', - component: page(() => import('./pages/achievements.vue')), - loginRequired: true, -}, { - path: '/my/drive/folder/:folder', - component: page(() => import('./pages/drive.vue')), - loginRequired: true, -}, { - path: '/my/drive', - component: page(() => import('./pages/drive.vue')), - loginRequired: true, -}, { - path: '/my/drive/file/:fileId', - component: page(() => import('./pages/drive.file.vue')), - loginRequired: true, -}, { - path: '/my/follow-requests', - component: page(() => import('./pages/follow-requests.vue')), - loginRequired: true, -}, { - path: '/my/lists/:listId', - component: page(() => import('./pages/my-lists/list.vue')), - loginRequired: true, -}, { - path: '/my/lists', - component: page(() => import('./pages/my-lists/index.vue')), - loginRequired: true, -}, { - path: '/my/clips', - component: page(() => import('./pages/my-clips/index.vue')), - loginRequired: true, -}, { - path: '/my/antennas/create', - component: page(() => import('./pages/my-antennas/create.vue')), - loginRequired: true, -}, { - path: '/my/antennas/:antennaId', - component: page(() => import('./pages/my-antennas/edit.vue')), - loginRequired: true, -}, { - path: '/my/antennas', - component: page(() => import('./pages/my-antennas/index.vue')), - loginRequired: true, -}, { - path: '/timeline/list/:listId', - component: page(() => import('./pages/user-list-timeline.vue')), - loginRequired: true, -}, { - path: '/timeline/antenna/:antennaId', - component: page(() => import('./pages/antenna-timeline.vue')), - loginRequired: true, -}, { - path: '/clicker', - component: page(() => import('./pages/clicker.vue')), - loginRequired: true, -}, { - path: '/timeline', - component: page(() => import('./pages/timeline.vue')), -}, { - name: 'index', - path: '/', - component: $i ? page(() => import('./pages/timeline.vue')) : page(() => import('./pages/welcome.vue')), - globalCacheKey: 'index', -}, { - path: '/:(*)', - component: page(() => import('./pages/not-found.vue')), -}]; - -export const mainRouter = new Router(routes, location.pathname + location.search + location.hash, !!$i, page(() => import('@/pages/not-found.vue'))); - -window.history.replaceState({ key: mainRouter.getCurrentKey() }, '', location.href); - -mainRouter.addListener('push', ctx => { - window.history.pushState({ key: ctx.key }, '', ctx.path); -}); - -window.addEventListener('popstate', (event) => { - mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key); -}); - -export function useRouter(): Router { - return inject('router', null) ?? mainRouter; -} diff --git a/packages/frontend/src/scripts/achievements.ts b/packages/frontend/src/scripts/achievements.ts index 4b6b044d8b..67d997f09b 100644 --- a/packages/frontend/src/scripts/achievements.ts +++ b/packages/frontend/src/scripts/achievements.ts @@ -83,6 +83,8 @@ export const ACHIEVEMENT_TYPES = [ 'brainDiver', 'smashTestNotificationButton', 'tutorialCompleted', + 'bubbleGameExplodingHead', + 'bubbleGameDoubleExplodingHead', ] as const; export const ACHIEVEMENT_BADGES = { @@ -466,6 +468,16 @@ export const ACHIEVEMENT_BADGES = { bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', frame: 'bronze', }, + 'bubbleGameExplodingHead': { + img: '/fluent-emoji/1f92f.png', + bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))', + frame: 'bronze', + }, + 'bubbleGameDoubleExplodingHead': { + img: '/fluent-emoji/1f92f.png', + bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))', + frame: 'silver', + }, /* @see } as const satisfies Record { +function getMfmOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise): Omit { utils.assertObject(def); const text = def.value.get('text'); @@ -241,7 +241,7 @@ function getMfmOptions(def: values.Value | undefined): Omit { - if (onClickEv) call(onClickEv, values.STR(evId)); + if (onClickEv) call(onClickEv, [values.STR(evId)]); }, }; } diff --git a/packages/frontend/src/scripts/drop-and-fusion-engine.ts b/packages/frontend/src/scripts/drop-and-fusion-engine.ts new file mode 100644 index 0000000000..b6e735ddf2 --- /dev/null +++ b/packages/frontend/src/scripts/drop-and-fusion-engine.ts @@ -0,0 +1,400 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { EventEmitter } from 'eventemitter3'; +import * as Matter from 'matter-js'; +import * as sound from '@/scripts/sound.js'; + +export type Mono = { + id: string; + level: number; + size: number; + shape: 'circle' | 'rectangle'; + score: number; + dropCandidate: boolean; + sfxPitch: number; + img: string; + imgSize: number; + spriteScale: number; +}; + +const PHYSICS_QUALITY_FACTOR = 16; // 低いほどパフォーマンスが高いがガタガタして安定しなくなる、逆に高すぎても何故か不安定になる + +export class DropAndFusionGame extends EventEmitter<{ + changeScore: (newScore: number) => void; + changeCombo: (newCombo: number) => void; + changeStock: (newStock: { id: string; mono: Mono }[]) => void; + dropped: () => void; + fusioned: (x: number, y: number, scoreDelta: number) => void; + monoAdded: (mono: Mono) => void; + gameOver: () => void; +}> { + private COMBO_INTERVAL = 1000; + public readonly DROP_INTERVAL = 500; + public readonly PLAYAREA_MARGIN = 25; + private STOCK_MAX = 4; + private loaded = false; + private engine: Matter.Engine; + private render: Matter.Render; + private runner: Matter.Runner; + private overflowCollider: Matter.Body; + private isGameOver = false; + + private gameWidth: number; + private gameHeight: number; + private monoDefinitions: Mono[] = []; + private monoTextures: Record = {}; + private monoTextureUrls: Record = {}; + + /** + * フィールドに出ていて、かつ合体の対象となるアイテム + */ + private activeBodyIds: Matter.Body['id'][] = []; + + private latestDroppedBodyId: Matter.Body['id'] | null = null; + + private latestDroppedAt = 0; + private latestFusionedAt = 0; + private stock: { id: string; mono: Mono }[] = []; + + private _combo = 0; + private get combo() { + return this._combo; + } + private set combo(value: number) { + this._combo = value; + this.emit('changeCombo', value); + } + + private _score = 0; + private get score() { + return this._score; + } + private set score(value: number) { + this._score = value; + this.emit('changeScore', value); + } + + private comboIntervalId: number | null = null; + + constructor(opts: { + canvas: HTMLCanvasElement; + width: number; + height: number; + monoDefinitions: Mono[]; + }) { + super(); + + this.gameWidth = opts.width; + this.gameHeight = opts.height; + this.monoDefinitions = opts.monoDefinitions; + + this.engine = Matter.Engine.create({ + constraintIterations: 2 * PHYSICS_QUALITY_FACTOR, + positionIterations: 6 * PHYSICS_QUALITY_FACTOR, + velocityIterations: 4 * PHYSICS_QUALITY_FACTOR, + gravity: { + x: 0, + y: 1, + }, + timing: { + timeScale: 2, + }, + enableSleeping: false, + }); + + this.render = Matter.Render.create({ + engine: this.engine, + canvas: opts.canvas, + options: { + width: this.gameWidth, + height: this.gameHeight, + background: 'transparent', // transparent to hide + wireframeBackground: 'transparent', // transparent to hide + wireframes: false, + showSleeping: false, + pixelRatio: Math.max(2, window.devicePixelRatio), + }, + }); + + Matter.Render.run(this.render); + + this.runner = Matter.Runner.create(); + Matter.Runner.run(this.runner, this.engine); + + this.engine.world.bodies = []; + + //#region walls + const WALL_OPTIONS: Matter.IChamferableBodyDefinition = { + isStatic: true, + friction: 0.7, + slop: 1.0, + render: { + strokeStyle: 'transparent', + fillStyle: 'transparent', + }, + }; + + const thickness = 100; + Matter.Composite.add(this.engine.world, [ + Matter.Bodies.rectangle(this.gameWidth / 2, this.gameHeight + (thickness / 2) - this.PLAYAREA_MARGIN, this.gameWidth, thickness, WALL_OPTIONS), + Matter.Bodies.rectangle(this.gameWidth + (thickness / 2) - this.PLAYAREA_MARGIN, this.gameHeight / 2, thickness, this.gameHeight, WALL_OPTIONS), + Matter.Bodies.rectangle(-((thickness / 2) - this.PLAYAREA_MARGIN), this.gameHeight / 2, thickness, this.gameHeight, WALL_OPTIONS), + ]); + //#endregion + + this.overflowCollider = Matter.Bodies.rectangle(this.gameWidth / 2, 0, this.gameWidth, 200, { + isStatic: true, + isSensor: true, + render: { + strokeStyle: 'transparent', + fillStyle: 'transparent', + }, + }); + Matter.Composite.add(this.engine.world, this.overflowCollider); + + // fit the render viewport to the scene + Matter.Render.lookAt(this.render, { + min: { x: 0, y: 0 }, + max: { x: this.gameWidth, y: this.gameHeight }, + }); + } + + private createBody(mono: Mono, x: number, y: number) { + const options: Matter.IBodyDefinition = { + label: mono.id, + //density: 0.0005, + density: mono.size / 1000, + restitution: 0.2, + frictionAir: 0.01, + friction: 0.7, + frictionStatic: 5, + slop: 1.0, + //mass: 0, + render: { + sprite: { + texture: mono.img, + xScale: (mono.size / mono.imgSize) * mono.spriteScale, + yScale: (mono.size / mono.imgSize) * mono.spriteScale, + }, + }, + }; + if (mono.shape === 'circle') { + return Matter.Bodies.circle(x, y, mono.size / 2, options); + } else if (mono.shape === 'rectangle') { + return Matter.Bodies.rectangle(x, y, mono.size, mono.size, options); + } else { + throw new Error('unrecognized shape'); + } + } + + private fusion(bodyA: Matter.Body, bodyB: Matter.Body) { + const now = Date.now(); + if (this.latestFusionedAt > now - this.COMBO_INTERVAL) { + this.combo++; + } else { + this.combo = 1; + } + this.latestFusionedAt = now; + + // TODO: 単に位置だけでなくそれぞれの動きベクトルも融合する? + const newX = (bodyA.position.x + bodyB.position.x) / 2; + const newY = (bodyA.position.y + bodyB.position.y) / 2; + + Matter.Composite.remove(this.engine.world, [bodyA, bodyB]); + this.activeBodyIds = this.activeBodyIds.filter(x => x !== bodyA.id && x !== bodyB.id); + + const currentMono = this.monoDefinitions.find(y => y.id === bodyA.label)!; + const nextMono = this.monoDefinitions.find(x => x.level === currentMono.level + 1); + + if (nextMono) { + const body = this.createBody(nextMono, newX, newY); + Matter.Composite.add(this.engine.world, body); + + // 連鎖してfusionした場合の分かりやすさのため少し間を置いてからfusion対象になるようにする + window.setTimeout(() => { + this.activeBodyIds.push(body.id); + }, 100); + + const comboBonus = 1 + ((this.combo - 1) / 5); + const additionalScore = Math.round(currentMono.score * comboBonus); + this.score += additionalScore; + + // TODO: 効果音再生はコンポーネント側の責務なので移動する + const pan = ((newX / this.gameWidth) - 0.5) * 2; + sound.playUrl('/client-assets/drop-and-fusion/bubble2.mp3', 1, pan, nextMono.sfxPitch); + + this.emit('monoAdded', nextMono); + this.emit('fusioned', newX, newY, additionalScore); + } else { + //const VELOCITY = 30; + //for (let i = 0; i < 10; i++) { + // const body = createBody(FRUITS.find(x => x.level === (1 + Math.floor(Math.random() * 3)))!, x + ((Math.random() * VELOCITY) - (VELOCITY / 2)), y + ((Math.random() * VELOCITY) - (VELOCITY / 2))); + // Matter.Composite.add(world, body); + // bodies.push(body); + //} + //sound.playUrl({ + // type: 'syuilo/bubble2', + // volume: 1, + //}); + } + } + + private gameOver() { + this.isGameOver = true; + Matter.Runner.stop(this.runner); + this.emit('gameOver'); + } + + /** テクスチャをすべてキャッシュする */ + private async loadMonoTextures() { + async function loadSingleMonoTexture(mono: Mono, game: DropAndFusionGame) { + // Matter-js内にキャッシュがある場合はスキップ + if (game.render.textures[mono.img]) return; + console.log('loading', mono.img); + + let src = mono.img; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (game.monoTextureUrls[mono.img]) { + src = game.monoTextureUrls[mono.img]; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (game.monoTextures[mono.img]) { + src = URL.createObjectURL(game.monoTextures[mono.img]); + game.monoTextureUrls[mono.img] = src; + } else { + const res = await fetch(mono.img); + const blob = await res.blob(); + game.monoTextures[mono.img] = blob; + src = URL.createObjectURL(blob); + game.monoTextureUrls[mono.img] = src; + } + + const image = new Image(); + image.src = src; + game.render.textures[mono.img] = image; + } + + return Promise.all(this.monoDefinitions.map(x => loadSingleMonoTexture(x, this))); + } + + public start() { + if (!this.loaded) throw new Error('game is not loaded yet'); + + for (let i = 0; i < this.STOCK_MAX; i++) { + this.stock.push({ + id: Math.random().toString(), + mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)], + }); + } + this.emit('changeStock', this.stock); + + // TODO: fusion予約状態のアイテムは光らせるなどの演出をすると楽しそう + let fusionReservedPairs: { bodyA: Matter.Body; bodyB: Matter.Body }[] = []; + + const minCollisionEnergyForSound = 2.5; + const maxCollisionEnergyForSound = 9; + const soundPitchMax = 4; + const soundPitchMin = 0.5; + + Matter.Events.on(this.engine, 'collisionStart', (event) => { + for (const pairs of event.pairs) { + const { bodyA, bodyB } = pairs; + if (bodyA.id === this.overflowCollider.id || bodyB.id === this.overflowCollider.id) { + if (bodyA.id === this.latestDroppedBodyId || bodyB.id === this.latestDroppedBodyId) { + continue; + } + this.gameOver(); + break; + } + const shouldFusion = (bodyA.label === bodyB.label) && !fusionReservedPairs.some(x => x.bodyA.id === bodyA.id || x.bodyA.id === bodyB.id || x.bodyB.id === bodyA.id || x.bodyB.id === bodyB.id); + if (shouldFusion) { + if (this.activeBodyIds.includes(bodyA.id) && this.activeBodyIds.includes(bodyB.id)) { + this.fusion(bodyA, bodyB); + } else { + fusionReservedPairs.push({ bodyA, bodyB }); + window.setTimeout(() => { + fusionReservedPairs = fusionReservedPairs.filter(x => x.bodyA.id !== bodyA.id && x.bodyB.id !== bodyB.id); + this.fusion(bodyA, bodyB); + }, 100); + } + } else { + const energy = pairs.collision.depth; + if (energy > minCollisionEnergyForSound) { + // TODO: 効果音再生はコンポーネント側の責務なので移動する + const vol = (Math.min(maxCollisionEnergyForSound, energy - minCollisionEnergyForSound) / maxCollisionEnergyForSound) / 4; + const pan = ((((bodyA.position.x + bodyB.position.x) / 2) / this.gameWidth) - 0.5) * 2; + const pitch = soundPitchMin + ((soundPitchMax - soundPitchMin) * (1 - (Math.min(10, energy) / 10))); + sound.playUrl('/client-assets/drop-and-fusion/poi1.mp3', vol, pan, pitch); + } + } + } + }); + + this.comboIntervalId = window.setInterval(() => { + if (this.latestFusionedAt < Date.now() - this.COMBO_INTERVAL) { + this.combo = 0; + } + }, 500); + } + + public async load() { + await this.loadMonoTextures(); + this.loaded = true; + } + + public getTextureImageUrl(mono: Mono) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (this.monoTextureUrls[mono.img]) { + return this.monoTextureUrls[mono.img]; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (this.monoTextures[mono.img]) { + // Gameクラス内にキャッシュがある場合はそれを使う + const out = URL.createObjectURL(this.monoTextures[mono.img]); + this.monoTextureUrls[mono.img] = out; + return out; + } else { + return mono.img; + } + } + + public getActiveMonos() { + return this.engine.world.bodies.map(x => this.monoDefinitions.find((mono) => mono.id === x.label)!).filter(x => x !== undefined); + } + + public drop(_x: number) { + if (this.isGameOver) return; + if (Date.now() - this.latestDroppedAt < this.DROP_INTERVAL) { + return; + } + const st = this.stock.shift()!; + this.stock.push({ + id: Math.random().toString(), + mono: this.monoDefinitions.filter(x => x.dropCandidate)[Math.floor(Math.random() * this.monoDefinitions.filter(x => x.dropCandidate).length)], + }); + this.emit('changeStock', this.stock); + + const x = Math.min(this.gameWidth - this.PLAYAREA_MARGIN - (st.mono.size / 2), Math.max(this.PLAYAREA_MARGIN + (st.mono.size / 2), _x)); + const body = this.createBody(st.mono, x, 50 + st.mono.size / 2); + Matter.Composite.add(this.engine.world, body); + this.activeBodyIds.push(body.id); + this.latestDroppedBodyId = body.id; + this.latestDroppedAt = Date.now(); + this.emit('dropped'); + this.emit('monoAdded', st.mono); + + // TODO: 効果音再生はコンポーネント側の責務なので移動する + const pan = ((x / this.gameWidth) - 0.5) * 2; + sound.playUrl('/client-assets/drop-and-fusion/poi2.mp3', 1, pan); + } + + public dispose() { + if (this.comboIntervalId) window.clearInterval(this.comboIntervalId); + Matter.Render.stop(this.render); + Matter.Runner.stop(this.runner); + Matter.World.clear(this.engine.world, false); + Matter.Engine.clear(this.engine); + } +} diff --git a/packages/frontend/src/scripts/emojilist.ts b/packages/frontend/src/scripts/emojilist.ts index 8885bf4b7f..4bd8bf94be 100644 --- a/packages/frontend/src/scripts/emojilist.ts +++ b/packages/frontend/src/scripts/emojilist.ts @@ -36,7 +36,8 @@ for (let i = 0; i < emojilist.length; i++) { export const emojiCharByCategory = _charGroupByCategory; export function getEmojiName(char: string): string | null { - const idx = _indexByChar.get(char); + // Colorize it because emojilist.json assumes that + const idx = _indexByChar.get(colorizeEmoji(char)); if (idx == null) { return null; } else { @@ -44,6 +45,10 @@ export function getEmojiName(char: string): string | null { } } +export function colorizeEmoji(char: string) { + return char.length === 1 ? `${char}\uFE0F` : char; +} + export interface CustomEmojiFolderTree { value: string; category: string; diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 2735253b36..d9a52c3741 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -13,11 +13,11 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { defaultStore, userActions } from '@/store.js'; import { $i, iAmModerator } from '@/account.js'; -import { mainRouter } from '@/router.js'; -import { Router } from '@/nirax.js'; +import { IRouter } from '@/nirax.js'; import { antennasCache, rolesCache, userListsCache } from '@/cache.js'; +import { mainRouter } from '@/global/router/main.js'; -export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router = mainRouter) { +export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) { const meId = $i ? $i.id : null; const cleanups = [] as (() => void)[]; diff --git a/packages/frontend/src/scripts/lookup.ts b/packages/frontend/src/scripts/lookup.ts index ff438af24f..ddcfd8852e 100644 --- a/packages/frontend/src/scripts/lookup.ts +++ b/packages/frontend/src/scripts/lookup.ts @@ -6,8 +6,8 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { mainRouter } from '@/router.js'; import { Router } from '@/nirax.js'; +import { mainRouter } from '@/global/router/main.js'; export async function lookup(router?: Router) { const _router = router ?? mainRouter; diff --git a/packages/frontend/src/scripts/misskey-api.ts b/packages/frontend/src/scripts/misskey-api.ts index e71c5dd592..337fa15113 100644 --- a/packages/frontend/src/scripts/misskey-api.ts +++ b/packages/frontend/src/scripts/misskey-api.ts @@ -10,12 +10,17 @@ import { $i } from '@/account.js'; export const pendingApiRequestsCount = ref(0); // Implements Misskey.api.ApiClient.request -export function misskeyApi( +export function misskeyApi< + ResT = void, + E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, + P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'], + _ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType : ResT, +>( endpoint: E, data: P = {} as any, token?: string | null | undefined, signal?: AbortSignal, -): Promise> { +): Promise<_ResT> { if (endpoint.includes('://')) throw new Error('invalid endpoint'); pendingApiRequestsCount.value++; @@ -23,7 +28,7 @@ export function misskeyApi((resolve, reject) => { + const promise = new Promise<_ResT>((resolve, reject) => { // Append a credential if ($i) (data as any).i = $i.token; if (token !== undefined) (data as any).i = token; @@ -44,7 +49,7 @@ export function misskeyApi undefined } else { reject(body.error); } @@ -57,10 +62,15 @@ export function misskeyApi( +export function misskeyApiGet< + ResT = void, + E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, + P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'], + _ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType : ResT, +>( endpoint: E, data: P = {} as any, -): Promise> { +): Promise<_ResT> { pendingApiRequestsCount.value++; const onFinally = () => { @@ -69,7 +79,7 @@ export function misskeyApiGet((resolve, reject) => { + const promise = new Promise<_ResT>((resolve, reject) => { // Send request window.fetch(`${apiUrl}/${endpoint}?${query}`, { method: 'GET', @@ -81,7 +91,7 @@ export function misskeyApiGet undefined } else { reject(body.error); } diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts index 0b966ff199..690c342c85 100644 --- a/packages/frontend/src/scripts/sound.ts +++ b/packages/frontend/src/scripts/sound.ts @@ -5,7 +5,6 @@ import type { SoundStore } from '@/store.js'; import { defaultStore } from '@/store.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; let ctx: AudioContext; const cache = new Map(); @@ -89,63 +88,35 @@ export type OperationType = typeof operationTypes[number]; /** * 音声を読み込む - * @param soundStore サウンド設定 + * @param url url * @param options `useCache`: デフォルトは`true` 一度再生した音声はキャッシュする */ -export async function loadAudio(soundStore: SoundStore, options?: { useCache?: boolean; }) { +export async function loadAudio(url: string, options?: { useCache?: boolean; }) { if (_DEV_) console.log('loading audio. opts:', options); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) { - return; - } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (ctx == null) { ctx = new AudioContext(); } if (options?.useCache ?? true) { - if (soundStore.type === '_driveFile_' && cache.has(soundStore.fileId)) { + if (cache.has(url)) { if (_DEV_) console.log('use cache'); - return cache.get(soundStore.fileId) as AudioBuffer; - } else if (cache.has(soundStore.type)) { - if (_DEV_) console.log('use cache'); - return cache.get(soundStore.type) as AudioBuffer; + return cache.get(url) as AudioBuffer; } } let response: Response; - if (soundStore.type === '_driveFile_') { - try { - response = await fetch(soundStore.fileUrl); - } catch (err) { - try { - // URLが変わっている可能性があるのでドライブ側からURLを取得するフォールバック - const apiRes = await misskeyApi('drive/files/show', { - fileId: soundStore.fileId, - }); - response = await fetch(apiRes.url); - } catch (fbErr) { - // それでも無理なら諦める - return; - } - } - } else { - try { - response = await fetch(`/client-assets/sounds/${soundStore.type}.mp3`); - } catch (err) { - return; - } + try { + response = await fetch(url); + } catch (err) { + return; } const arrayBuffer = await response.arrayBuffer(); const audioBuffer = await ctx.decodeAudioData(arrayBuffer); if (options?.useCache ?? true) { - if (soundStore.type === '_driveFile_') { - cache.set(soundStore.fileId, audioBuffer); - } else { - cache.set(soundStore.type, audioBuffer); - } + cache.set(url, audioBuffer); } return audioBuffer; @@ -174,25 +145,46 @@ export function play(operationType: OperationType) { * @param soundStore サウンド設定 */ export async function playFile(soundStore: SoundStore) { - const buffer = await loadAudio(soundStore); + if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) { + return; + } + const url = soundStore.type === '_driveFile_' ? soundStore.fileUrl : `/client-assets/sounds/${soundStore.type}.mp3`; + const buffer = await loadAudio(url); if (!buffer) return; - createSourceNode(buffer, soundStore.volume)?.start(); + createSourceNode(buffer, soundStore.volume)?.soundSource.start(); } -export function createSourceNode(buffer: AudioBuffer, volume: number) : AudioBufferSourceNode | null { +export async function playUrl(url: string, volume = 1, pan = 0, playbackRate = 1) { + const buffer = await loadAudio(url); + if (!buffer) return; + createSourceNode(buffer, volume, pan, playbackRate)?.soundSource.start(); +} + +export function createSourceNode(buffer: AudioBuffer, volume: number, pan = 0, playbackRate = 1): { + soundSource: AudioBufferSourceNode; + panNode: StereoPannerNode; + gainNode: GainNode; +} | null { const masterVolume = defaultStore.state.sound_masterVolume; if (isMute() || masterVolume === 0 || volume === 0) { return null; } + const panNode = ctx.createStereoPanner(); + panNode.pan.value = pan; + const gainNode = ctx.createGain(); gainNode.gain.value = masterVolume * volume; const soundSource = ctx.createBufferSource(); soundSource.buffer = buffer; - soundSource.connect(gainNode).connect(ctx.destination); + soundSource.playbackRate.value = playbackRate; + soundSource + .connect(panNode) + .connect(gainNode) + .connect(ctx.destination); - return soundSource; + return { soundSource, panNode, gainNode }; } /** diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts index b970ff1df4..9930b321f7 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -27,6 +27,11 @@ function toolsMenuItems(): MenuItem[] { to: '/clicker', text: '🍪👈', icon: 'ti ti-cookie', + }, { + type: 'link', + to: '/bubble-game', + text: i18n.ts.bubbleGame, + icon: 'ti ti-apple', }, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? { type: 'link', to: '/custom-emojis-manager', diff --git a/packages/frontend/src/ui/_common_/sw-inject.ts b/packages/frontend/src/ui/_common_/sw-inject.ts index 504484f8de..4c77465eb1 100644 --- a/packages/frontend/src/ui/_common_/sw-inject.ts +++ b/packages/frontend/src/ui/_common_/sw-inject.ts @@ -7,8 +7,8 @@ import { post } from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { $i, login } from '@/account.js'; import { getAccountFromId } from '@/scripts/get-account-from-id.js'; -import { mainRouter } from '@/router.js'; import { deepClone } from '@/scripts/clone.js'; +import { mainRouter } from '@/global/router/main.js'; export function swInject() { navigator.serviceWorker.addEventListener('message', async ev => { diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue index e0985fdb11..fdddc0bb69 100644 --- a/packages/frontend/src/ui/classic.vue +++ b/packages/frontend/src/ui/classic.vue @@ -52,11 +52,11 @@ import XCommon from './_common_/common.vue'; import { instanceName } from '@/config.js'; import { StickySidebar } from '@/scripts/sticky-sidebar.js'; import * as os from '@/os.js'; -import { mainRouter } from '@/router.js'; import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import { miLocalStorage } from '@/local-storage.js'; +import { mainRouter } from '@/global/router/main.js'; const XHeaderMenu = defineAsyncComponent(() => import('./classic.header.vue')); const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index d184764b82..304ebbf0b2 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -103,7 +103,6 @@ import * as os from '@/os.js'; import { navbarItemDef } from '@/navbar.js'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; -import { mainRouter } from '@/router.js'; import { unisonReload } from '@/scripts/unison-reload.js'; import { deviceKind } from '@/scripts/device-kind.js'; import { defaultStore } from '@/store.js'; @@ -117,6 +116,7 @@ import XWidgetsColumn from '@/ui/deck/widgets-column.vue'; import XMentionsColumn from '@/ui/deck/mentions-column.vue'; import XDirectColumn from '@/ui/deck/direct-column.vue'; import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue'; +import { mainRouter } from '@/global/router/main.js'; const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue index c2b8f19079..674132e0d7 100644 --- a/packages/frontend/src/ui/deck/main-column.vue +++ b/packages/frontend/src/ui/deck/main-column.vue @@ -24,10 +24,10 @@ import XColumn from './column.vue'; import { deckStore, Column } from '@/ui/deck/deck-store.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { mainRouter } from '@/router.js'; import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js'; import { useScrollPositionManager } from '@/nirax.js'; import { getScrollContainer } from '@/scripts/scroll.js'; +import { mainRouter } from '@/global/router/main.js'; defineProps<{ column: Column; diff --git a/packages/frontend/src/ui/minimum.vue b/packages/frontend/src/ui/minimum.vue index f32f2de3df..b0a2aa35f9 100644 --- a/packages/frontend/src/ui/minimum.vue +++ b/packages/frontend/src/ui/minimum.vue @@ -16,9 +16,9 @@ SPDX-License-Identifier: AGPL-3.0-only