diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 9681cbec59..3b49173f45 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -8,7 +8,7 @@ on: pull_request: jobs: - jest: + unit: runs-on: ubuntu-latest strategy: @@ -51,9 +51,59 @@ jobs: - name: Build run: pnpm build - name: Test - run: pnpm jest-and-coverage - - name: Upload Coverage + run: pnpm --filter backend test-and-coverage + - name: Upload to Codecov uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./packages/backend/coverage/coverage-final.json + + e2e: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.10.0] + + services: + postgres: + image: postgres:15 + ports: + - 54312:5432 + env: + POSTGRES_DB: test-misskey + POSTGRES_HOST_AUTH_METHOD: trust + redis: + image: redis:7 + ports: + - 56312:6379 + + steps: + - uses: actions/checkout@v4.1.1 + with: + submodules: true + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + run_install: false + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4.0.1 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - run: corepack enable + - run: pnpm i --frozen-lockfile + - name: Check pnpm-lock.yaml + run: git diff --exit-code pnpm-lock.yaml + - name: Copy Configure + run: cp .github/misskey/test.yml .config + - name: Build + run: pnpm build + - name: Test + run: pnpm --filter backend test-and-coverage:e2e + - name: Upload to Codecov + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/backend/coverage/coverage-final.json diff --git a/.gitignore b/.gitignore index a66e527db0..d7b486b977 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ docker-compose.yml # misskey /build built +built-test /data /.cache-loader /db diff --git a/CHANGELOG.md b/CHANGELOG.md index f5289d98d6..50cab6c497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,17 +16,22 @@ ### General - Feat: [mCaptcha](https://github.com/mCaptcha/mCaptcha)のサポートを追加 +- Fix: リストライムラインの「リノートを表示」が正しく機能しない問題を修正 ### Client - Feat: 新しいゲームを追加 - Enhance: ハッシュタグ入力時に、本文の末尾の行に何も書かれていない場合は新たにスペースを追加しないように +- Enhance: チャンネルノートのピン留めをノートのメニューからできるように +- Fix: ネイティブモードの絵文字がモノクロにならないように - Fix: v2023.12.0で追加された「モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能」が管理画面上で正しく表示されていない問題を修正 -- Enhance: チャンネルノートのピン留めをノートのメニューからできるよ +- Fix: AiScriptの`readline`関数が不正な値を返すことがある問題のv2023.12.0時点での修正がPlay以外に適用されていないのを修正 - Enhance: 絵文字ピッカー・オートコンプリートで、完全一致した絵文字を優先的に表示するように ### Server - Enhance: 連合先のレートリミットに引っかかった際にリトライするようになりました - Enhance: ActivityPub Deliver queueでBodyを事前処理するように (#12916) +- Enhance: クリップをエクスポートできるように +- Fix: `drive/files/update`でファイル名のバリデーションが機能していない問題を修正 ## 2023.12.2 diff --git a/locales/index.d.ts b/locales/index.d.ts index 99bc0fc04f..7c73caaac9 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1192,6 +1192,7 @@ export interface Locale { "decorate": string; "addMfmFunction": string; "enableQuickAddMfmFunction": string; + "bubbleGame": string; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; @@ -1656,6 +1657,15 @@ export interface Locale { "title": string; "description": string; }; + "_bubbleGameExplodingHead": { + "title": string; + "description": string; + }; + "_bubbleGameDoubleExplodingHead": { + "title": string; + "description": string; + "flavor": string; + }; }; }; "_role": { @@ -2256,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 7cf5663a72..55ff3201f0 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1189,6 +1189,7 @@ seasonalScreenEffect: "季節に応じた画面の演出" decorate: "デコる" addMfmFunction: "装飾を追加" enableQuickAddMfmFunction: "高度なMFMのピッカーを表示する" +bubbleGame: "バブルゲーム" _announcement: forExistingUsers: "既存ユーザーのみ" @@ -1567,6 +1568,13 @@ _achievements: _tutorialCompleted: title: "Misskey初心者講座 修了証" description: "チュートリアルを完了した" + _bubbleGameExplodingHead: + title: "🤯" + description: "バブルゲームで最も大きいモノを出した" + _bubbleGameDoubleExplodingHead: + title: "ダブル🤯" + description: "バブルゲームで最も大きいモノを2つ同時に出した" + flavor: "これくらいの おべんとばこに 🤯 🤯 ちょっとつめて" _role: new: "ロールの作成" @@ -2159,6 +2167,7 @@ _profile: _exportOrImport: allNotes: "全てのノート" favoritedNotes: "お気に入りにしたノート" + clips: "クリップ" followingList: "フォロー" muteList: "ミュート" blockingList: "ブロック" diff --git a/packages/backend/jest.config.cjs b/packages/backend/jest.config.cjs index 97d777c862..5a4aa4e15a 100644 --- a/packages/backend/jest.config.cjs +++ b/packages/backend/jest.config.cjs @@ -160,7 +160,6 @@ module.exports = { testMatch: [ "/test/unit/**/*.ts", "/src/**/*.test.ts", - "/test/e2e/**/*.ts", ], // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped diff --git a/packages/backend/jest.config.e2e.cjs b/packages/backend/jest.config.e2e.cjs new file mode 100644 index 0000000000..4502da47df --- /dev/null +++ b/packages/backend/jest.config.e2e.cjs @@ -0,0 +1,15 @@ +/* +* For a detailed explanation regarding each configuration property and type check, visit: +* https://jestjs.io/docs/en/configuration.html +*/ + +const base = require('./jest.config.cjs') + +module.exports = { + ...base, + globalSetup: "/built-test/entry.js", + setupFilesAfterEnv: ["/test/jest.setup.ts"], + testMatch: [ + "/test/e2e/**/*.ts", + ], +}; diff --git a/packages/backend/jest.config.unit.cjs b/packages/backend/jest.config.unit.cjs new file mode 100644 index 0000000000..aa5992936b --- /dev/null +++ b/packages/backend/jest.config.unit.cjs @@ -0,0 +1,14 @@ +/* +* For a detailed explanation regarding each configuration property and type check, visit: +* https://jestjs.io/docs/en/configuration.html +*/ + +const base = require('./jest.config.cjs') + +module.exports = { + ...base, + testMatch: [ + "/test/unit/**/*.ts", + "/src/**/*.test.ts", + ], +}; diff --git a/packages/backend/package.json b/packages/backend/package.json index 7b9654c207..5ab476295c 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -13,6 +13,7 @@ "revert": "pnpm typeorm migration:revert -d ormconfig.js", "check:connect": "node ./check_connect.js", "build": "swc src -d built -D", + "build:test": "swc test-server -d built-test -D --config-file test-server/.swcrc", "watch:swc": "swc src -d built -D -w", "build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", "watch": "node watch.mjs", @@ -21,11 +22,15 @@ "typecheck": "tsc --noEmit", "eslint": "eslint --quiet \"src/**/*.ts\"", "lint": "pnpm typecheck && pnpm eslint", - "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit", - "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit", + "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs", + "jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs", + "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.unit.cjs", + "jest-and-coverage:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.e2e.cjs", "jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache", "test": "pnpm jest", + "test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e", "test-and-coverage": "pnpm jest-and-coverage", + "test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e", "generate-api-json": "node ./generate_api_json.js" }, "optionalDependencies": { @@ -178,6 +183,7 @@ "devDependencies": { "@jest/globals": "29.7.0", "@misskey-dev/eslint-plugin": "^1.0.0", + "@nestjs/platform-express": "^10.3.0", "@simplewebauthn/typescript-types": "8.3.4", "@swc/jest": "0.2.29", "@types/accepts": "1.3.7", @@ -226,9 +232,11 @@ "eslint": "8.56.0", "eslint-plugin-import": "2.29.1", "execa": "8.0.1", + "fkill": "^9.0.0", "jest": "29.7.0", "jest-mock": "29.7.0", "nodemon": "3.0.2", + "pid-port": "^1.0.0", "simple-oauth2": "5.0.0" } } 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/DriveService.ts b/packages/backend/src/core/DriveService.ts index 04f0e38e6f..075bc9d7f7 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -655,7 +655,7 @@ export class DriveService { public async updateFile(file: MiDriveFile, values: Partial, updater: MiUser) { const alwaysMarkNsfw = (await this.roleService.getUserPolicies(file.userId)).alwaysMarkNsfw; - if (values.name && !this.driveFileEntityService.validateFileName(file.name)) { + if (values.name != null && !this.driveFileEntityService.validateFileName(values.name)) { throw new DriveService.InvalidFileNameError(); } 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/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/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/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/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-server/.eslintrc.cjs b/packages/backend/test-server/.eslintrc.cjs new file mode 100644 index 0000000000..c261741a36 --- /dev/null +++ b/packages/backend/test-server/.eslintrc.cjs @@ -0,0 +1,32 @@ +module.exports = { + parserOptions: { + tsconfigRootDir: __dirname, + project: ['./tsconfig.json'], + }, + extends: [ + '../../shared/.eslintrc.js', + ], + rules: { + 'import/order': ['warn', { + 'groups': ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'], + 'pathGroups': [ + { + 'pattern': '@/**', + 'group': 'external', + 'position': 'after' + } + ], + }], + 'no-restricted-globals': [ + 'error', + { + 'name': '__dirname', + 'message': 'Not in ESModule. Use `import.meta.url` instead.' + }, + { + 'name': '__filename', + 'message': 'Not in ESModule. Use `import.meta.url` instead.' + } + ] + }, +}; diff --git a/packages/backend/test-server/.swcrc b/packages/backend/test-server/.swcrc new file mode 100644 index 0000000000..e3d6935169 --- /dev/null +++ b/packages/backend/test-server/.swcrc @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "jsc": { + "parser": { + "syntax": "typescript", + "dynamicImport": true, + "decorators": true + }, + "transform": { + "legacyDecorator": true, + "decoratorMetadata": true + }, + "experimental": { + "keepImportAssertions": true + }, + "baseUrl": "../built", + "paths": { + "@/*": ["*"] + }, + "target": "es2022" + }, + "minify": false +} diff --git a/packages/backend/test-server/entry.ts b/packages/backend/test-server/entry.ts new file mode 100644 index 0000000000..866a7e1f5b --- /dev/null +++ b/packages/backend/test-server/entry.ts @@ -0,0 +1,80 @@ +import { portToPid } from 'pid-port'; +import fkill from 'fkill'; +import Fastify from 'fastify'; +import { NestFactory } from '@nestjs/core'; +import { MainModule } from '@/MainModule.js'; +import { ServerService } from '@/server/ServerService.js'; +import { loadConfig } from '@/config.js'; +import { NestLogger } from '@/NestLogger.js'; + +const config = loadConfig(); +const originEnv = JSON.stringify(process.env); + +process.env.NODE_ENV = 'test'; + +/** + * テスト用のサーバインスタンスを起動する + */ +async function launch() { + await killTestServer(); + + console.log('starting application...'); + + const app = await NestFactory.createApplicationContext(MainModule, { + logger: new NestLogger(), + }); + const serverService = app.get(ServerService); + await serverService.launch(); + + await startControllerEndpoints(); + + // ジョブキューは必要な時にテストコード側で起動する + // ジョブキューが動くとテスト結果の確認に支障が出ることがあるので意図的に動かさないでいる + + console.log('application initialized.'); +} + +/** + * 既に重複したポートで待ち受けしているサーバがある場合はkillする + */ +async function killTestServer() { + // + try { + const pid = await portToPid(config.port); + if (pid) { + await fkill(pid, { force: true }); + } + } catch { + // NOP; + } +} + +/** + * 別プロセスに切り離してしまったが故に出来なくなった環境変数の書き換え等を実現するためのエンドポイントを作る + * @param port + */ +async function startControllerEndpoints(port = config.port + 1000) { + const fastify = Fastify(); + + fastify.post<{ Body: { key?: string, value?: string } }>('/env', async (req, res) => { + console.log(req.body); + const key = req.body['key']; + if (!key) { + res.code(400).send({ success: false }); + return; + } + + process.env[key] = req.body['value']; + + res.code(200).send({ success: true }); + }); + + fastify.post<{ Body: { key?: string, value?: string } }>('/env-reset', async (req, res) => { + process.env = JSON.parse(originEnv); + res.code(200).send({ success: true }); + }); + + await fastify.listen({ port: port, host: 'localhost' }); +} + +export default launch; diff --git a/packages/backend/test-server/tsconfig.json b/packages/backend/test-server/tsconfig.json new file mode 100644 index 0000000000..10313699c2 --- /dev/null +++ b/packages/backend/test-server/tsconfig.json @@ -0,0 +1,52 @@ +{ + "compilerOptions": { + "allowJs": true, + "noEmitOnError": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noUnusedParameters": false, + "noUnusedLocals": false, + "noFallthroughCasesInSwitch": true, + "declaration": false, + "sourceMap": true, + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "allowSyntheticDefaultImports": true, + "removeComments": false, + "noLib": false, + "strict": true, + "strictNullChecks": true, + "strictPropertyInitialization": false, + "skipLibCheck": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "resolveJsonModule": true, + "isolatedModules": true, + "rootDir": "../src", + "baseUrl": "./", + "paths": { + "@/*": ["../src/*"] + }, + "outDir": "../built-test", + "types": [ + "node" + ], + "typeRoots": [ + "../src/@types", + "../node_modules/@types", + "../node_modules" + ], + "lib": [ + "esnext" + ] + }, + "compileOnSave": false, + "include": [ + "./**/*.ts", + "../src/**/*.ts" + ], + "exclude": [ + "../src/**/*.test.ts" + ] +} diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts index dfed8b2fce..165a1055c9 100644 --- a/packages/backend/test/e2e/2fa.ts +++ b/packages/backend/test/e2e/2fa.ts @@ -10,7 +10,7 @@ import * as crypto from 'node:crypto'; import cbor from 'cbor'; import * as OTPAuth from 'otpauth'; import { loadConfig } from '@/config.js'; -import { api, signup, startServer } from '../utils.js'; +import { api, signup } from '../utils.js'; import type { AuthenticationResponseJSON, AuthenticatorAssertionResponseJSON, @@ -19,11 +19,9 @@ import type { PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON, } from '@simplewebauthn/typescript-types'; -import type { INestApplicationContext } from '@nestjs/common'; import type * as misskey from 'misskey-js'; describe('2要素認証', () => { - let app: INestApplicationContext; let alice: misskey.entities.SignupResponse; const config = loadConfig(); @@ -185,14 +183,9 @@ describe('2要素認証', () => { }; beforeAll(async () => { - app = await startServer(); alice = await signup({ username, password }); }, 1000 * 60 * 2); - afterAll(async () => { - await app.close(); - }); - test('が設定でき、OTPでログインできる。', async () => { const registerResponse = await api('/i/2fa/register', { password, diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index 9bac5122d4..e63722b246 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -6,24 +6,20 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { inspect } from 'node:util'; import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import type { Packed } from '@/misc/json-schema.js'; import { - signup, - post, - userList, - page, - role, - startServer, api, - successfulApiCall, failedApiCall, - uploadFile, + post, + role, + signup, + successfulApiCall, testPaginationConsistency, + uploadFile, + userList, } from '../utils.js'; import type * as misskey from 'misskey-js'; -import type { INestApplicationContext } from '@nestjs/common'; const compareBy = (selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => { return selector(a).localeCompare(selector(b)); @@ -54,8 +50,6 @@ describe('アンテナ', () => { withReplies: false, }; - let app: INestApplicationContext; - let root: User; let alice: User; let bob: User; @@ -79,10 +73,6 @@ describe('アンテナ', () => { let userMutingAlice: User; let userMutedByAlice: User; - beforeAll(async () => { - app = await startServer(); - }, 1000 * 60 * 2); - beforeAll(async () => { root = await signup({ username: 'root' }); alice = await signup({ username: 'alice' }); @@ -136,10 +126,6 @@ describe('アンテナ', () => { await api('mute/create', { userId: userMutedByAlice.id }, alice); }, 1000 * 60 * 10); - afterAll(async () => { - await app.close(); - }); - beforeEach(async () => { // テスト間で影響し合わないように毎回全部消す。 for (const user of [alice, bob]) { diff --git a/packages/backend/test/e2e/api-visibility.ts b/packages/backend/test/e2e/api-visibility.ts index afe4f9c05a..89d8b42271 100644 --- a/packages/backend/test/e2e/api-visibility.ts +++ b/packages/backend/test/e2e/api-visibility.ts @@ -6,21 +6,10 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { signup, api, post, startServer } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; +import { api, post, signup } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('API visibility', () => { - let app: INestApplicationContext; - - beforeAll(async () => { - app = await startServer(); - }, 1000 * 60 * 2); - - afterAll(async () => { - await app.close(); - }); - describe('Note visibility', () => { //#region vars /** ヒロイン */ diff --git a/packages/backend/test/e2e/api.ts b/packages/backend/test/e2e/api.ts index ad351eebbb..25d5bdb175 100644 --- a/packages/backend/test/e2e/api.ts +++ b/packages/backend/test/e2e/api.ts @@ -7,27 +7,30 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { IncomingMessage } from 'http'; -import { signup, api, startServer, successfulApiCall, failedApiCall, uploadFile, waitFire, connectStream, relativeFetch, createAppToken } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; +import { + api, + connectStream, + createAppToken, + failedApiCall, + relativeFetch, + signup, + successfulApiCall, + uploadFile, + waitFire, +} from '../utils.js'; import type * as misskey from 'misskey-js'; describe('API', () => { - let app: INestApplicationContext; let alice: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse; let carol: misskey.entities.SignupResponse; beforeAll(async () => { - app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); }, 1000 * 60 * 2); - afterAll(async () => { - await app.close(); - }); - describe('General validation', () => { test('wrong type', async () => { const res = await api('/test', { diff --git a/packages/backend/test/e2e/block.ts b/packages/backend/test/e2e/block.ts index 25ff9f11ac..1dfc87c64f 100644 --- a/packages/backend/test/e2e/block.ts +++ b/packages/backend/test/e2e/block.ts @@ -6,29 +6,21 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { signup, api, post, startServer } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; +import { api, post, signup } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Block', () => { - let app: INestApplicationContext; - // alice blocks bob let alice: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse; let carol: misskey.entities.SignupResponse; beforeAll(async () => { - app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); }, 1000 * 60 * 2); - afterAll(async () => { - await app.close(); - }); - test('Block作成', async () => { const res = await api('/blocking/create', { userId: bob.id, diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts index 49092fba63..b679eea8cf 100644 --- a/packages/backend/test/e2e/clips.ts +++ b/packages/backend/test/e2e/clips.ts @@ -18,25 +18,13 @@ import { paramDef as UnfavoriteParamDef } from '@/server/api/endpoints/clips/unf import { paramDef as AddNoteParamDef } from '@/server/api/endpoints/clips/add-note.js'; import { paramDef as RemoveNoteParamDef } from '@/server/api/endpoints/clips/remove-note.js'; import { paramDef as NotesParamDef } from '@/server/api/endpoints/clips/notes.js'; -import { - signup, - post, - startServer, - api, - successfulApiCall, - failedApiCall, - ApiRequest, - hiddenNote, -} from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; +import { api, ApiRequest, failedApiCall, hiddenNote, post, signup, successfulApiCall } from '../utils.js'; describe('クリップ', () => { type User = Packed<'User'>; type Note = Packed<'Note'>; type Clip = Packed<'Clip'>; - let app: INestApplicationContext; - let alice: User; let bob: User; let aliceNote: Note; @@ -145,7 +133,6 @@ describe('クリップ', () => { }; beforeAll(async () => { - app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); @@ -160,10 +147,6 @@ describe('クリップ', () => { bobSpecifiedNote = await post(bob, { text: 'specified only', visibility: 'specified' }) as any; }, 1000 * 60 * 2); - afterAll(async () => { - await app.close(); - }); - afterEach(async () => { // テスト間で影響し合わないように毎回全部消す。 for (const user of [alice, bob]) { diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index bfd0b0272d..b12b062a63 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -10,30 +10,22 @@ import * as assert from 'assert'; // https://github.com/node-fetch/node-fetch/pull/1664 import { Blob } from 'node-fetch'; import { MiUser } from '@/models/_.js'; -import { startServer, signup, post, api, uploadFile, simpleGet, initTestDb } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; +import { api, initTestDb, post, signup, simpleGet, uploadFile } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Endpoints', () => { - let app: INestApplicationContext; - let alice: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse; let carol: misskey.entities.SignupResponse; let dave: misskey.entities.SignupResponse; beforeAll(async () => { - app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); dave = await signup({ username: 'dave' }); }, 1000 * 60 * 2); - afterAll(async () => { - await app.close(); - }); - describe('signup', () => { test('不正なユーザー名でアカウントが作成できない', async () => { const res = await api('signup', { @@ -710,6 +702,18 @@ describe('Endpoints', () => { assert.strictEqual(res.status, 400); }); + test('不正なファイル名で怒られる', async () => { + const file = (await uploadFile(alice)).body; + const newName = ''; + + const res = await api('/drive/files/update', { + fileId: file.id, + name: newName, + }, alice); + + assert.strictEqual(res.status, 400); + }); + test('間違ったIDで怒られる', async () => { const res = await api('/drive/files/update', { fileId: 'kyoppie', diff --git a/packages/backend/test/e2e/exports.ts b/packages/backend/test/e2e/exports.ts new file mode 100644 index 0000000000..f9b59144a3 --- /dev/null +++ b/packages/backend/test/e2e/exports.ts @@ -0,0 +1,193 @@ +/* + * 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 { api, port, post, signup, startJobQueue } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; +import type * as misskey from 'misskey-js'; + +describe('export-clips', () => { + let queue: 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 () => { + queue = await startJobQueue(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + }, 1000 * 60 * 2); + + afterAll(async () => { + await queue.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/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts index 28affe7768..0d23b4fe67 100644 --- a/packages/backend/test/e2e/fetch-resource.ts +++ b/packages/backend/test/e2e/fetch-resource.ts @@ -6,9 +6,8 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { startServer, channel, clip, cookie, galleryPost, signup, page, play, post, simpleGet, uploadFile } from '../utils.js'; +import { channel, clip, cookie, galleryPost, page, play, post, signup, simpleGet, uploadFile } from '../utils.js'; import type { SimpleGetResponse } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; import type * as misskey from 'misskey-js'; // Request Accept @@ -23,8 +22,6 @@ const HTML = 'text/html; charset=utf-8'; const JSON_UTF8 = 'application/json; charset=utf-8'; describe('Webリソース', () => { - let app: INestApplicationContext; - let alice: misskey.entities.SignupResponse; let aliceUploadedFile: any; let alicesPost: any; @@ -79,7 +76,6 @@ describe('Webリソース', () => { }; beforeAll(async () => { - app = await startServer(); alice = await signup({ username: 'alice' }); aliceUploadedFile = await uploadFile(alice); alicesPost = await post(alice, { @@ -96,10 +92,6 @@ describe('Webリソース', () => { bob = await signup({ username: 'bob' }); }, 1000 * 60 * 2); - afterAll(async () => { - await app.close(); - }); - describe.each([ { path: '/', type: HTML }, { path: '/docs/ja-JP/about', type: HTML }, // "指定されたURLに該当するページはありませんでした。" diff --git a/packages/backend/test/e2e/ff-visibility.ts b/packages/backend/test/e2e/ff-visibility.ts index 4d323e14e7..1fe0478a18 100644 --- a/packages/backend/test/e2e/ff-visibility.ts +++ b/packages/backend/test/e2e/ff-visibility.ts @@ -6,26 +6,18 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { signup, api, startServer, simpleGet } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; +import { api, signup, simpleGet } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('FF visibility', () => { - let app: INestApplicationContext; - let alice: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse; beforeAll(async () => { - app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); }, 1000 * 60 * 2); - afterAll(async () => { - await app.close(); - }); - test('followingVisibility, followersVisibility がともに public なユーザーのフォロー/フォロワーを誰でも見れる', async () => { await api('/i/update', { followingVisibility: 'public', diff --git a/packages/backend/test/e2e/move.ts b/packages/backend/test/e2e/move.ts index f7da66a27c..3937203569 100644 --- a/packages/backend/test/e2e/move.ts +++ b/packages/backend/test/e2e/move.ts @@ -3,19 +3,19 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { INestApplicationContext } from '@nestjs/common'; + process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { loadConfig } from '@/config.js'; import { MiUser, UsersRepository } from '@/models/_.js'; -import { jobQueue } from '@/boot/common.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; -import { uploadFile, signup, startServer, initTestDb, api, sleep, successfulApiCall } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; +import { jobQueue } from '@/boot/common.js'; +import { api, initTestDb, signup, sleep, successfulApiCall, uploadFile } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Account Move', () => { - let app: INestApplicationContext; let jq: INestApplicationContext; let url: URL; @@ -30,8 +30,8 @@ describe('Account Move', () => { let Users: UsersRepository; beforeAll(async () => { - app = await startServer(); jq = await jobQueue(); + const config = loadConfig(); url = new URL(config.url); const connection = await initTestDb(false); @@ -46,7 +46,7 @@ describe('Account Move', () => { }, 1000 * 60 * 2); afterAll(async () => { - await Promise.all([app.close(), jq.close()]); + await jq.close(); }); describe('Create Alias', () => { diff --git a/packages/backend/test/e2e/mute.ts b/packages/backend/test/e2e/mute.ts index 3b5542dfe0..5144df5ebe 100644 --- a/packages/backend/test/e2e/mute.ts +++ b/packages/backend/test/e2e/mute.ts @@ -6,29 +6,21 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { signup, api, post, react, startServer, waitFire } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; +import { api, post, react, signup, waitFire } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Mute', () => { - let app: INestApplicationContext; - // alice mutes carol let alice: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse; let carol: misskey.entities.SignupResponse; beforeAll(async () => { - app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); }, 1000 * 60 * 2); - afterAll(async () => { - await app.close(); - }); - test('ミュート作成', async () => { const res = await api('/mute/create', { userId: carol.id, diff --git a/packages/backend/test/e2e/nodeinfo.ts b/packages/backend/test/e2e/nodeinfo.ts index 7eed39c5ed..934ef08507 100644 --- a/packages/backend/test/e2e/nodeinfo.ts +++ b/packages/backend/test/e2e/nodeinfo.ts @@ -6,20 +6,9 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { relativeFetch, startServer } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; +import { relativeFetch } from '../utils.js'; describe('nodeinfo', () => { - let app: INestApplicationContext; - - beforeAll(async () => { - app = await startServer(); - }, 1000 * 60 * 2); - - afterAll(async () => { - await app.close(); - }); - test('nodeinfo 2.1', async () => { const res = await relativeFetch('nodeinfo/2.1'); assert.ok(res.ok); diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts index 8d33c63485..0f2e08e675 100644 --- a/packages/backend/test/e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -8,29 +8,22 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { MiNote } from '@/models/Note.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; -import { signup, post, uploadUrl, startServer, initTestDb, api, uploadFile } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; +import { api, initTestDb, post, signup, uploadFile, uploadUrl } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Note', () => { - let app: INestApplicationContext; let Notes: any; let alice: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse; beforeAll(async () => { - app = await startServer(); const connection = await initTestDb(true); Notes = connection.getRepository(MiNote); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); }, 1000 * 60 * 2); - afterAll(async () => { - await app.close(); - }); - test('投稿できる', async () => { const post = { text: 'test', diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index 3ca1f8b542..df6ff42df9 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -11,13 +11,18 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { AuthorizationCode, ResourceOwnerPassword, type AuthorizationTokenConfig, ClientCredentials, ModuleOptions } from 'simple-oauth2'; +import { + AuthorizationCode, + type AuthorizationTokenConfig, + ClientCredentials, + ModuleOptions, + ResourceOwnerPassword, +} from 'simple-oauth2'; import pkceChallenge from 'pkce-challenge'; import { JSDOM } from 'jsdom'; -import Fastify, { type FastifyReply, type FastifyInstance } from 'fastify'; -import { api, port, signup, startServer } from '../utils.js'; +import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify'; +import { api, port, sendEnvUpdateRequest, signup } from '../utils.js'; import type * as misskey from 'misskey-js'; -import type { INestApplicationContext } from '@nestjs/common'; const host = `http://127.0.0.1:${port}`; @@ -147,7 +152,6 @@ async function assertDirectError(response: Response, status: number, error: stri } describe('OAuth', () => { - let app: INestApplicationContext; let fastify: FastifyInstance; let alice: misskey.entities.SignupResponse; @@ -156,7 +160,6 @@ describe('OAuth', () => { let sender: (reply: FastifyReply) => void; beforeAll(async () => { - app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); @@ -168,7 +171,7 @@ describe('OAuth', () => { }, 1000 * 60 * 2); beforeEach(async () => { - process.env.MISSKEY_TEST_CHECK_IP_RANGE = ''; + await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '' }); sender = (reply): void => { reply.send(` @@ -180,7 +183,6 @@ describe('OAuth', () => { afterAll(async () => { await fastify.close(); - await app.close(); }); test('Full flow', async () => { @@ -881,7 +883,7 @@ describe('OAuth', () => { }); test('Disallow loopback', async () => { - process.env.MISSKEY_TEST_CHECK_IP_RANGE = '1'; + await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '1' }); const client = new AuthorizationCode(clientConfig); const response = await fetch(client.authorizeURL({ diff --git a/packages/backend/test/e2e/renote-mute.ts b/packages/backend/test/e2e/renote-mute.ts index fededdff32..42cc414c3f 100644 --- a/packages/backend/test/e2e/renote-mute.ts +++ b/packages/backend/test/e2e/renote-mute.ts @@ -6,29 +6,21 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { signup, api, post, react, startServer, waitFire, sleep } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; +import { api, post, signup, sleep, waitFire } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Renote Mute', () => { - let app: INestApplicationContext; - // alice mutes carol let alice: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse; let carol: misskey.entities.SignupResponse; beforeAll(async () => { - app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); }, 1000 * 60 * 2); - afterAll(async () => { - await app.close(); - }); - test('ミュート作成', async () => { const res = await api('/renote-mute/create', { userId: carol.id, diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index a447ba94ae..b6f584fa70 100644 --- a/packages/backend/test/e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -8,12 +8,10 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { WebSocket } from 'ws'; import { MiFollowing } from '@/models/Following.js'; -import { signup, api, post, startServer, initTestDb, waitFire, createAppToken, port } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; +import { api, createAppToken, initTestDb, port, post, signup, waitFire } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Streaming', () => { - let app: INestApplicationContext; let Followings: any; const follow = async (follower: any, followee: any) => { @@ -48,7 +46,6 @@ describe('Streaming', () => { let list: any; beforeAll(async () => { - app = await startServer(); const connection = await initTestDb(true); Followings = connection.getRepository(MiFollowing); @@ -95,10 +92,6 @@ describe('Streaming', () => { }, chitose); }, 1000 * 60 * 2); - afterAll(async () => { - await app.close(); - }); - describe('Events', () => { test('mention event', async () => { const fired = await waitFire( diff --git a/packages/backend/test/e2e/thread-mute.ts b/packages/backend/test/e2e/thread-mute.ts index 5c68e2b150..26c30d6c4c 100644 --- a/packages/backend/test/e2e/thread-mute.ts +++ b/packages/backend/test/e2e/thread-mute.ts @@ -6,28 +6,20 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { signup, api, post, connectStream, startServer } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; +import { api, connectStream, post, signup } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Note thread mute', () => { - let app: INestApplicationContext; - let alice: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse; let carol: misskey.entities.SignupResponse; beforeAll(async () => { - app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); }, 1000 * 60 * 2); - afterAll(async () => { - await app.close(); - }); - test('notes/mentions にミュートしているスレッドの投稿が含まれない', async () => { const bobNote = await post(bob, { text: '@alice @carol root note' }); const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index cb9558b416..88f89c4a6f 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -6,12 +6,8 @@ // How to run: // pnpm jest -- e2e/timelines.ts -process.env.NODE_ENV = 'test'; -process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING = 'true'; - import * as assert from 'assert'; -import { api, post, randomString, signup, sleep, startServer, uploadUrl } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; +import { api, post, randomString, sendEnvUpdateRequest, signup, sleep, uploadUrl } from '../utils.js'; function genHost() { return randomString() + '.example.com'; @@ -21,16 +17,6 @@ function waitForPushToTl() { return sleep(500); } -let app: INestApplicationContext; - -beforeAll(async () => { - app = await startServer(); -}, 1000 * 60 * 2); - -afterAll(async () => { - await app.close(); -}); - describe('Timelines', () => { describe('Home TL', () => { test.concurrent('自分の visibility: followers なノートが含まれる', async () => { @@ -334,8 +320,9 @@ describe('Timelines', () => { test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); + await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); + const bobNote = await post(bob, { text: 'hi' }); await waitForPushToTl(); @@ -348,8 +335,9 @@ describe('Timelines', () => { test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); + await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); + const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); await waitForPushToTl(); @@ -762,8 +750,9 @@ describe('Timelines', () => { test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); + await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); + const bobNote = await post(bob, { text: 'hi' }); await waitForPushToTl(); @@ -776,8 +765,9 @@ describe('Timelines', () => { test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); + await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); await api('/following/create', { userId: bob.id }, alice); - await sleep(1000); + const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); await waitForPushToTl(); diff --git a/packages/backend/test/e2e/user-notes.ts b/packages/backend/test/e2e/user-notes.ts index 4f2e7c4cf3..07da0db369 100644 --- a/packages/backend/test/e2e/user-notes.ts +++ b/packages/backend/test/e2e/user-notes.ts @@ -6,20 +6,16 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { signup, api, post, uploadUrl, startServer } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; +import { api, post, signup, uploadUrl } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('users/notes', () => { - let app: INestApplicationContext; - let alice: misskey.entities.SignupResponse; let jpgNote: any; let pngNote: any; let jpgPngNote: any; beforeAll(async () => { - app = await startServer(); alice = await signup({ username: 'alice' }); const jpg = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); const png = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.png'); @@ -34,10 +30,6 @@ describe('users/notes', () => { }); }, 1000 * 60 * 2); - afterAll(async() => { - await app.close(); - }); - test('withFiles', async () => { const res = await api('/users/notes', { userId: alice.id, diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 9c4cbac368..bc23c009b2 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -8,20 +8,8 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { inspect } from 'node:util'; import { DEFAULT_POLICIES } from '@/core/RoleService.js'; -import type { Packed } from '@/misc/json-schema.js'; -import { - signup, - post, - page, - role, - startServer, - api, - successfulApiCall, - failedApiCall, - uploadFile, -} from '../utils.js'; +import { api, page, post, role, signup, successfulApiCall, uploadFile } from '../utils.js'; import type * as misskey from 'misskey-js'; -import type { INestApplicationContext } from '@nestjs/common'; describe('ユーザー', () => { // エンティティとしてのユーザーを主眼においたテストを記述する @@ -185,8 +173,6 @@ describe('ユーザー', () => { }); }; - let app: INestApplicationContext; - let root: User; let alice: User; let aliceNote: misskey.entities.Note; @@ -230,10 +216,6 @@ describe('ユーザー', () => { let userFollowRequesting: User; let userFollowRequested: User; - beforeAll(async () => { - app = await startServer(); - }, 1000 * 60 * 2); - beforeAll(async () => { root = await signup({ username: 'root' }); alice = await signup({ username: 'alice' }); @@ -321,10 +303,6 @@ describe('ユーザー', () => { await api('following/create', { userId: userFollowRequested.id }, userFollowRequesting); }, 1000 * 60 * 10); - afterAll(async () => { - await app.close(); - }); - beforeEach(async () => { alice = { ...alice, diff --git a/packages/backend/test/e2e/well-known.ts b/packages/backend/test/e2e/well-known.ts index 14e32e1627..0429b7c8b2 100644 --- a/packages/backend/test/e2e/well-known.ts +++ b/packages/backend/test/e2e/well-known.ts @@ -6,24 +6,16 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { host, origin, relativeFetch, signup, startServer } from '../utils.js'; -import type { INestApplicationContext } from '@nestjs/common'; +import { host, origin, relativeFetch, signup } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('.well-known', () => { - let app: INestApplicationContext; let alice: misskey.entities.User; beforeAll(async () => { - app = await startServer(); - alice = await signup({ username: 'alice' }); }, 1000 * 60 * 2); - afterAll(async () => { - await app.close(); - }); - test('nodeinfo', async () => { const res = await relativeFetch('.well-known/nodeinfo'); assert.ok(res.ok); diff --git a/packages/backend/test/jest.setup.ts b/packages/backend/test/jest.setup.ts new file mode 100644 index 0000000000..cf5b9bf24d --- /dev/null +++ b/packages/backend/test/jest.setup.ts @@ -0,0 +1,8 @@ +import { initTestDb, sendEnvResetRequest } from './utils.js'; + +beforeAll(async () => { + await Promise.all([ + initTestDb(false), + sendEnvResetRequest(), + ]); +}); diff --git a/packages/backend/test/misc/mock-resolver.ts b/packages/backend/test/misc/mock-resolver.ts index 7cba7a2aa8..7ee65d1ab0 100644 --- a/packages/backend/test/misc/mock-resolver.ts +++ b/packages/backend/test/misc/mock-resolver.ts @@ -15,7 +15,13 @@ import type { LoggerService } from '@/core/LoggerService.js'; import type { MetaService } from '@/core/MetaService.js'; import type { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; -import type { NoteReactionsRepository, NotesRepository, PollsRepository, UsersRepository, FollowRequestsRepository } from '@/models/_.js'; +import type { + FollowRequestsRepository, + NoteReactionsRepository, + NotesRepository, + PollsRepository, + UsersRepository, +} from '@/models/_.js'; type MockResponse = { type: string; diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts index f2aa5d35e4..f02c4e6700 100644 --- a/packages/backend/test/unit/AnnouncementService.ts +++ b/packages/backend/test/unit/AnnouncementService.ts @@ -10,7 +10,13 @@ import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; import { GlobalModule } from '@/GlobalModule.js'; import { AnnouncementService } from '@/core/AnnouncementService.js'; -import type { MiAnnouncement, AnnouncementsRepository, AnnouncementReadsRepository, UsersRepository, MiUser } from '@/models/_.js'; +import type { + AnnouncementReadsRepository, + AnnouncementsRepository, + MiAnnouncement, + MiUser, + UsersRepository, +} from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { genAidx } from '@/misc/id/aidx.js'; import { CacheService } from '@/core/CacheService.js'; diff --git a/packages/backend/test/unit/DriveService.ts b/packages/backend/test/unit/DriveService.ts index 7234da2e36..64397a1a4f 100644 --- a/packages/backend/test/unit/DriveService.ts +++ b/packages/backend/test/unit/DriveService.ts @@ -6,7 +6,13 @@ process.env.NODE_ENV = 'test'; import { Test } from '@nestjs/testing'; -import { DeleteObjectCommandOutput, DeleteObjectCommand, NoSuchKey, InvalidObjectState, S3Client } from '@aws-sdk/client-s3'; +import { + DeleteObjectCommand, + DeleteObjectCommandOutput, + InvalidObjectState, + NoSuchKey, + S3Client, +} from '@aws-sdk/client-s3'; import { mockClient } from 'aws-sdk-client-mock'; import { GlobalModule } from '@/GlobalModule.js'; import { DriveService } from '@/core/DriveService.js'; diff --git a/packages/backend/test/unit/FetchInstanceMetadataService.ts b/packages/backend/test/unit/FetchInstanceMetadataService.ts index 34200899d4..cddc374f9a 100644 --- a/packages/backend/test/unit/FetchInstanceMetadataService.ts +++ b/packages/backend/test/unit/FetchInstanceMetadataService.ts @@ -55,7 +55,8 @@ describe('FetchInstanceMetadataService', () => { return { fetch: jest.fn() }; } else if (token === DI.redis) { return mockRedis; - }}) + } + }) .compile(); app.enableShutdownHooks(); diff --git a/packages/backend/test/unit/FileInfoService.ts b/packages/backend/test/unit/FileInfoService.ts index de0b31488c..f3717d73cd 100644 --- a/packages/backend/test/unit/FileInfoService.ts +++ b/packages/backend/test/unit/FileInfoService.ts @@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; -import { describe, beforeAll, afterAll, test } from '@jest/globals'; +import { afterAll, beforeAll, describe, test } from '@jest/globals'; import { GlobalModule } from '@/GlobalModule.js'; import { FileInfoService } from '@/core/FileInfoService.js'; //import { DI } from '@/di-symbols.js'; diff --git a/packages/backend/test/unit/MetaService.ts b/packages/backend/test/unit/MetaService.ts index ab30f48283..c4c7f21913 100644 --- a/packages/backend/test/unit/MetaService.ts +++ b/packages/backend/test/unit/MetaService.ts @@ -6,15 +6,13 @@ process.env.NODE_ENV = 'test'; import { jest } from '@jest/globals'; -import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; import { GlobalModule } from '@/GlobalModule.js'; -import type { MetasRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { MetaService } from '@/core/MetaService.js'; import { CoreModule } from '@/core/CoreModule.js'; -import type { DataSource } from 'typeorm'; import type { TestingModule } from '@nestjs/testing'; +import type { DataSource } from 'typeorm'; describe('MetaService', () => { let app: TestingModule; diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 9879eb8e3e..46613c29c8 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -11,7 +11,7 @@ import { Test } from '@nestjs/testing'; import * as lolex from '@sinonjs/fake-timers'; import { GlobalModule } from '@/GlobalModule.js'; import { RoleService } from '@/core/RoleService.js'; -import type { MiRole, RolesRepository, RoleAssignmentsRepository, UsersRepository, MiUser } from '@/models/_.js'; +import type { MiRole, MiUser, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { MetaService } from '@/core/MetaService.js'; import { genAidx } from '@/misc/id/aidx.js'; diff --git a/packages/backend/test/unit/S3Service.ts b/packages/backend/test/unit/S3Service.ts index c1eafc96b7..2ffc99380d 100644 --- a/packages/backend/test/unit/S3Service.ts +++ b/packages/backend/test/unit/S3Service.ts @@ -6,7 +6,13 @@ process.env.NODE_ENV = 'test'; import { Test } from '@nestjs/testing'; -import { UploadPartCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import { + CompleteMultipartUploadCommand, + CreateMultipartUploadCommand, + PutObjectCommand, + S3Client, + UploadPartCommand, +} from '@aws-sdk/client-s3'; import { mockClient } from 'aws-sdk-client-mock'; import { GlobalModule } from '@/GlobalModule.js'; import { CoreModule } from '@/core/CoreModule.js'; diff --git a/packages/backend/test/unit/misc/id.ts b/packages/backend/test/unit/misc/id.ts index 59783a9fa1..1498c075aa 100644 --- a/packages/backend/test/unit/misc/id.ts +++ b/packages/backend/test/unit/misc/id.ts @@ -4,13 +4,13 @@ */ import { ulid } from 'ulid'; -import { describe, test, expect } from '@jest/globals'; +import { describe, expect, test } from '@jest/globals'; import { aidRegExp, genAid, parseAid } from '@/misc/id/aid.js'; import { aidxRegExp, genAidx, parseAidx } from '@/misc/id/aidx.js'; import { genMeid, meidRegExp, parseMeid } from '@/misc/id/meid.js'; import { genMeidg, meidgRegExp, parseMeidg } from '@/misc/id/meidg.js'; import { genObjectId, objectIdRegExp, parseObjectId } from '@/misc/id/object-id.js'; -import { ulidRegExp, parseUlid } from '@/misc/id/ulid.js'; +import { parseUlid, ulidRegExp } from '@/misc/id/ulid.js'; describe('misc:id', () => { test('aid', () => { diff --git a/packages/backend/test/unit/misc/others.ts b/packages/backend/test/unit/misc/others.ts index b16d26d866..caa815b3df 100644 --- a/packages/backend/test/unit/misc/others.ts +++ b/packages/backend/test/unit/misc/others.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { describe, test, expect } from '@jest/globals'; +import { describe, expect, test } from '@jest/globals'; import { contentDisposition } from '@/misc/content-disposition.js'; describe('misc:content-disposition', () => { diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 46b8ea9cdd..2b232a0a5d 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -5,7 +5,7 @@ import * as assert from 'node:assert'; import { readFile } from 'node:fs/promises'; -import { isAbsolute, basename } from 'node:path'; +import { basename, isAbsolute } from 'node:path'; import { randomUUID } from 'node:crypto'; import { inspect } from 'node:util'; import WebSocket, { ClientOptions } from 'ws'; @@ -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; @@ -68,7 +68,11 @@ export const failedApiCall = async (request: ApiRequest, assertion: { return res.body; }; -const request = async (path: string, params: any, me?: UserToken): Promise<{ status: number, headers: Headers, body: any }> => { +const request = async (path: string, params: any, me?: UserToken): Promise<{ + status: number, + headers: Headers, + body: any +}> => { const bodyAuth: Record = {}; const headers: Record = { 'Content-Type': 'application/json', @@ -275,7 +279,11 @@ interface UploadOptions { * Upload file * @param user User */ -export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadOptions = {}): Promise<{ status: number, headers: Headers, body: misskey.Endpoints['drive/files/create']['res'] | null }> => { +export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadOptions = {}): Promise<{ + status: number, + headers: Headers, + body: misskey.Endpoints['drive/files/create']['res'] | null +}> => { const absPath = path == null ? new URL('resources/Lenna.jpg', import.meta.url) : isAbsolute(path.toString()) @@ -426,8 +434,8 @@ export const simpleGet = async (path: string, accept = '*/*', cookie: any = unde ]; const body = - jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() : - htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) : + jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() : + htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) : null; return { @@ -557,3 +565,34 @@ export function sleep(msec: number) { }, msec); }); } + +export async function sendEnvUpdateRequest(params: { key: string, value?: string }) { + const res = await fetch( + `http://localhost:${port + 1000}/env`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + }, + ); + + if (res.status !== 200) { + throw new Error('server env update failed.'); + } +} + +export async function sendEnvResetRequest() { + const res = await fetch( + `http://localhost:${port + 1000}/env-reset`, + { + method: 'POST', + body: JSON.stringify({}), + }, + ); + + if (res.status !== 200) { + throw new Error('server env update failed.'); + } +} 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/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/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/frame.svg b/packages/frontend/assets/drop-and-fusion/frame.svg deleted file mode 100644 index 4276dae833..0000000000 Binary files a/packages/frontend/assets/drop-and-fusion/frame.svg and /dev/null 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/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/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/package.json b/packages/frontend/package.json index 7e7559d825..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", 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/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index b46b25eba2..8a74319f29 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -45,9 +45,9 @@ import bytes from '@/filters/bytes.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; -import { useRouter } from '@/router.js'; import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js'; import { deviceKind } from '@/scripts/device-kind.js'; +import { useRouter } from '@/global/router/supplier.js'; const router = useRouter(); diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 2647ace7db..28058c338b 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -23,26 +23,26 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
@@ -637,6 +682,8 @@ definePageMetadata({ } .root { + margin: 0 auto; + max-width: 600px; user-select: none; * { @@ -667,7 +714,8 @@ definePageMetadata({ top: 0; left: 0; width: 100%; - filter: drop-shadow(0 6px 16px #0007); + // なんかiOSでちらつく + //filter: drop-shadow(0 6px 16px #0007); pointer-events: none; user-select: none; } @@ -677,7 +725,8 @@ definePageMetadata({ display: block; z-index: 1; margin-top: -50px; - max-width: 100%; + width: 100% !important; + height: auto !important; pointer-events: none; user-select: none; } @@ -699,34 +748,49 @@ definePageMetadata({ text-align: center; font-weight: bold; font-style: oblique; + color: #fff; + -webkit-text-stroke: 1px rgb(255, 145, 0); + text-shadow: 0 0 6px #0005; pointer-events: none; user-select: none; } -.currentFruit { +.currentMono { position: absolute; - margin-top: 20px; + margin-top: 80px; z-index: 2; filter: drop-shadow(0 6px 16px #0007); pointer-events: none; user-select: none; } -.currentFruitArrow { +.dropper { position: absolute; - margin-top: 20px; + top: 0; + width: 70px; + margin-top: -10px; + margin-left: -30px; + z-index: 2; + filter: drop-shadow(0 6px 16px #0007); + pointer-events: none; + user-select: none; +} + +.currentMonoArrow { + position: absolute; + margin-top: 100px; z-index: 3; - animation: currentFruitArrow 2s ease infinite; + animation: currentMonoArrow 2s ease infinite; pointer-events: none; user-select: none; } .dropGuide { position: absolute; - top: 50px; + top: 120px; z-index: 3; width: 3px; - height: calc(100% - 50px); + height: calc(100% - 120px); background: #f002; pointer-events: none; user-select: none; @@ -751,7 +815,7 @@ definePageMetadata({ } } -@keyframes currentFruitArrow { +@keyframes currentMonoArrow { 0% { transform: translateY(0); } 25% { transform: translateY(-8px); } 50% { transform: translateY(0); } 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 9cf4be778c..0000000000 --- a/packages/frontend/src/router.ts +++ /dev/null @@ -1,561 +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: '/drop-and-fusion', - component: page(() => import('./pages/drop-and-fusion.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 acde78f5fd..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,69 +88,35 @@ export type OperationType = typeof operationTypes[number]; /** * 音声を読み込む - * @param soundStore サウンド設定 + * @param url url * @param options `useCache`: デフォルトは`true` 一度再生した音声はキャッシュする */ -export async function loadAudio(soundStore: { - type: Exclude; -} | { - type: '_driveFile_'; - fileId: string; - fileUrl: string; -}, 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; @@ -180,18 +145,26 @@ 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 async function playRaw(type: Exclude, volume = 1, pan = 0, playbackRate = 1) { - const buffer = await loadAudio({ type }); +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)?.start(); + createSourceNode(buffer, volume, pan, playbackRate)?.soundSource.start(); } -export function createSourceNode(buffer: AudioBuffer, volume: number, pan = 0, playbackRate = 1) : AudioBufferSourceNode | null { +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; @@ -211,7 +184,7 @@ export function createSourceNode(buffer: AudioBuffer, volume: number, pan = 0, p .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 e50002dc2c..9930b321f7 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -29,8 +29,8 @@ function toolsMenuItems(): MenuItem[] { icon: 'ti ti-cookie', }, { type: 'link', - to: '/drop-and-fusion', - text: 'Drop & Fusion', + to: '/bubble-game', + text: i18n.ts.bubbleGame, icon: 'ti ti-apple', }, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? { type: 'link', 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