diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 510a0719e..5955f6b5d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,28 +10,23 @@ updates: schedule: interval: daily open-pull-requests-limit: 100 + +# Add only the root, not each workspace item +# https://github.com/dependabot/dependabot-core/issues/4993#issuecomment-1289133027 - package-ecosystem: npm directory: "/" schedule: interval: daily - open-pull-requests-limit: 100 -- package-ecosystem: npm - directory: "/packages/backend" - schedule: - interval: daily - open-pull-requests-limit: 100 -- package-ecosystem: npm - directory: "/packages/frontend" - schedule: - interval: daily - open-pull-requests-limit: 100 -- package-ecosystem: npm - directory: "/packages/sw" - schedule: - interval: daily - open-pull-requests-limit: 100 -- package-ecosystem: npm - directory: "/packages/misskey-js" - schedule: - interval: daily - open-pull-requests-limit: 100 + # PNPM has an issue with dependabot. See: + # https://github.com/dependabot/dependabot-core/issues/7258 + # https://github.com/pnpm/pnpm/issues/6530 + # TODO: Restore this when the issue is solved + open-pull-requests-limit: 0 + groups: + swc: + patterns: + - "@swc/*" + storybook: + patterns: + - "storybook*" + - "@storybook/*" diff --git a/.github/workflows/api-misskey-js.yml b/.github/workflows/api-misskey-js.yml index ed004c78d..9942708be 100644 --- a/.github/workflows/api-misskey-js.yml +++ b/.github/workflows/api-misskey-js.yml @@ -9,12 +9,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.3 - run: corepack enable - name: Setup Node.js - uses: actions/setup-node@v3.6.0 + uses: actions/setup-node@v3.8.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/check_copyright_year.yml b/.github/workflows/check_copyright_year.yml index 8daea44a8..174b6ab25 100644 --- a/.github/workflows/check_copyright_year.yml +++ b/.github/workflows/check_copyright_year.yml @@ -10,7 +10,7 @@ jobs: check_copyright_year: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3.2.0 + - uses: actions/checkout@v3.5.3 - run: | if [ "$(grep Copyright COPYING | sed -e 's/.*2014-\([0-9]*\) .*/\1/g')" -ne "$(date +%Y)" ]; then echo "Please change copyright year!" diff --git a/.github/workflows/docker-develop.yml b/.github/workflows/docker-develop.yml index e9197f5a8..98163dda1 100644 --- a/.github/workflows/docker-develop.yml +++ b/.github/workflows/docker-develop.yml @@ -13,10 +13,10 @@ jobs: if: github.repository == 'laoxong/misskey' steps: - name: Check out the repo - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.3 - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v2.3.0 + uses: docker/setup-buildx-action@v2.9.1 with: platforms: linux/amd64,linux/arm64 - name: Docker meta diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 1a794972b..5211931e7 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -12,10 +12,10 @@ jobs: steps: - name: Check out the repo - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.3 - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v2.3.0 + uses: docker/setup-buildx-action@v2.9.1 with: platforms: linux/amd64,linux/arm64 - name: Docker meta diff --git a/.github/workflows/dockle.yml b/.github/workflows/dockle.yml index 9b79ee54f..02dbef35d 100644 --- a/.github/workflows/dockle.yml +++ b/.github/workflows/dockle.yml @@ -14,7 +14,7 @@ jobs: env: DOCKER_CONTENT_TRUST: 1 steps: - - uses: actions/checkout@v3.2.0 + - uses: actions/checkout@v3.5.3 - run: | curl -L -o dockle.deb "https://github.com/goodwithtech/dockle/releases/download/v0.4.10/dockle_0.4.10_Linux-64bit.deb" sudo dpkg -i dockle.deb diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0f3702f95..2bdb6acd1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -11,7 +11,7 @@ jobs: pnpm_install: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3.3.0 + - uses: actions/checkout@v3.5.3 with: fetch-depth: 0 submodules: true @@ -19,7 +19,7 @@ jobs: with: version: 8 run_install: false - - uses: actions/setup-node@v3.6.0 + - uses: actions/setup-node@v3.8.0 with: node-version-file: '.node-version' cache: 'pnpm' @@ -38,7 +38,7 @@ jobs: - sw - misskey-js steps: - - uses: actions/checkout@v3.3.0 + - uses: actions/checkout@v3.5.3 with: fetch-depth: 0 submodules: true @@ -46,7 +46,7 @@ jobs: with: version: 7 run_install: false - - uses: actions/setup-node@v3.6.0 + - uses: actions/setup-node@v3.8.0 with: node-version-file: '.node-version' cache: 'pnpm' @@ -64,7 +64,7 @@ jobs: - backend - misskey-js steps: - - uses: actions/checkout@v3.3.0 + - uses: actions/checkout@v3.5.3 with: fetch-depth: 0 submodules: true @@ -72,7 +72,7 @@ jobs: with: version: 7 run_install: false - - uses: actions/setup-node@v3.6.0 + - uses: actions/setup-node@v3.8.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/ok-to-test.yml b/.github/workflows/ok-to-test.yml index 87af3a6ba..71d09f781 100644 --- a/.github/workflows/ok-to-test.yml +++ b/.github/workflows/ok-to-test.yml @@ -23,7 +23,7 @@ jobs: private_key: ${{ secrets.DEPLOYBOT_PRIVATE_KEY }} - name: Slash Command Dispatch - uses: peter-evans/slash-command-dispatch@v1 + uses: peter-evans/slash-command-dispatch@v3 env: TOKEN: ${{ steps.generate_token.outputs.token }} with: diff --git a/.github/workflows/pr-preview-deploy.yml b/.github/workflows/pr-preview-deploy.yml index 9b786d34a..fd1417b65 100644 --- a/.github/workflows/pr-preview-deploy.yml +++ b/.github/workflows/pr-preview-deploy.yml @@ -53,7 +53,7 @@ jobs: # Check out merge commit - name: Fork based /deploy checkout - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.3 with: ref: 'refs/pull/${{ github.event.client_payload.pull_request.number }}/merge' diff --git a/.github/workflows/reviewer_lottery.yml b/.github/workflows/reviewer_lottery.yml index 33228d746..f8f7d2253 100644 --- a/.github/workflows/reviewer_lottery.yml +++ b/.github/workflows/reviewer_lottery.yml @@ -7,7 +7,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 - - uses: uesteibar/reviewer-lottery@v2 + - uses: actions/checkout@v3.5.3 + - uses: uesteibar/reviewer-lottery@v3 with: repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index 1aea8b545..9dab60cb9 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -15,12 +15,12 @@ jobs: NODE_OPTIONS: "--max_old_space_size=7168" steps: - - uses: actions/checkout@v3.3.0 + - uses: actions/checkout@v3.5.3 if: github.event_name != 'pull_request_target' with: fetch-depth: 0 submodules: true - - uses: actions/checkout@v3.3.0 + - uses: actions/checkout@v3.5.3 if: github.event_name == 'pull_request_target' with: fetch-depth: 0 @@ -38,7 +38,7 @@ jobs: version: 8 run_install: false - name: Use Node.js 20.x - uses: actions/setup-node@v3.6.0 + uses: actions/setup-node@v3.8.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 96e64c322..001ed44db 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -29,7 +29,7 @@ jobs: - 56312:6379 steps: - - uses: actions/checkout@v3.3.0 + - uses: actions/checkout@v3.5.3 with: submodules: true - name: Install pnpm @@ -38,7 +38,7 @@ jobs: version: 8 run_install: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3.6.0 + uses: actions/setup-node@v3.8.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index eef68aa0d..63a270334 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -16,7 +16,7 @@ jobs: node-version: [20.x] steps: - - uses: actions/checkout@v3.3.0 + - uses: actions/checkout@v3.5.3 with: submodules: true - name: Install pnpm @@ -25,7 +25,7 @@ jobs: version: 8 run_install: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3.6.0 + uses: actions/setup-node@v3.8.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' @@ -68,7 +68,7 @@ jobs: - 56312:6379 steps: - - uses: actions/checkout@v3.3.0 + - uses: actions/checkout@v3.5.3 with: submodules: true # https://github.com/cypress-io/cypress-docker-images/issues/150 @@ -83,7 +83,7 @@ jobs: version: 7 run_install: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3.6.0 + uses: actions/setup-node@v3.8.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml index 213657ce1..899250f5c 100644 --- a/.github/workflows/test-misskey-js.yml +++ b/.github/workflows/test-misskey-js.yml @@ -21,12 +21,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3.3.0 + uses: actions/checkout@v3.5.3 - run: corepack enable - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3.6.0 + uses: actions/setup-node@v3.8.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml index 8429465b5..aa4014612 100644 --- a/.github/workflows/test-production.yml +++ b/.github/workflows/test-production.yml @@ -19,7 +19,7 @@ jobs: node-version: [20.x] steps: - - uses: actions/checkout@v3.3.0 + - uses: actions/checkout@v3.5.3 with: submodules: true - name: Install pnpm @@ -28,7 +28,7 @@ jobs: version: 8 run_install: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3.6.0 + uses: actions/setup-node@v3.8.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/CHANGELOG.md b/CHANGELOG.md index a5c74258d..5c3d73f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ ### General - OAuth 2.0のサポート +- お知らせ機能の強化 + - ユーザー個別のお知らせを作成可能に + - お知らせのバナー表示やダイアログ表示が可能に + - お知らせのアイコンを設定可能に - チャンネルをセンシティブ指定できるようになりました ### Client @@ -26,12 +30,14 @@ - Fix: サーバー情報画面(`/instance-info/{domain}`)でブロックができないのを修正 - Fix: 未読のお知らせの「わかった」をクリック・タップしてもその場で「わかった」が消えない問題を修正 - Fix: iOSで画面を回転させるとテキストサイズが変わる問題を修正 +- Fix: word mute for sub note is not applied +- Fix: タイムラインを下にスクロールしてノート画面に移動して再び戻ったら以前のスクロール位置を失う問題を修正 ### Server - cacheRemoteFilesの初期値はfalseになりました -- 一部のfeatured noteを照会できない問題を修正 - ファイルアップロード時等にファイル名の拡張子を修正する関数(correctFilename)の挙動を改善 -- fix: muteがapiからのuser list timeline取得で機能しない問題を修正 +- Fix: 一部のfeatured noteを照会できない問題を修正 +- Fix: muteがapiからのuser list timeline取得で機能しない問題を修正 ## 13.14.2 diff --git a/locales/index.d.ts b/locales/index.d.ts index 4dceec605..4984dd18e 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1098,6 +1098,22 @@ export interface Locale { "doYouAgree": string; "beSureToReadThisAsItIsImportant": string; "iHaveReadXCarefullyAndAgree": string; + "dialog": string; + "icon": string; + "forYou": string; + "currentAnnouncements": string; + "pastAnnouncements": string; + "youHaveUnreadAnnouncements": string; + "_announcement": { + "forExistingUsers": string; + "forExistingUsersDescription": string; + "needConfirmationToRead": string; + "needConfirmationToReadDescription": string; + "end": string; + "tooManyActiveAnnouncementDescription": string; + "readConfirmTitle": string; + "readConfirmText": string; + }; "_initialAccountSetting": { "accountCreated": string; "letsStartAccountSetup": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index cab5c8f97..9089cb1d3 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -330,7 +330,7 @@ watch: "ウォッチ" unwatch: "ウォッチ解除" accept: "許可" reject: "拒否" -normal: "正常" +normal: "通常" instanceName: "サーバー名" instanceDescription: "サーバーの紹介" maintainerName: "管理者の名前" @@ -1095,6 +1095,22 @@ expired: "期限切れ" doYouAgree: "同意しますか?" beSureToReadThisAsItIsImportant: "重要ですので必ずお読みください。" iHaveReadXCarefullyAndAgree: "「{x}」の内容をよく読み、同意します。" +dialog: "ダイアログ" +icon: "アイコン" +forYou: "あなたへ" +currentAnnouncements: "現在のお知らせ" +pastAnnouncements: "過去のお知らせ" +youHaveUnreadAnnouncements: "未読のお知らせがあります。" + +_announcement: + forExistingUsers: "既存ユーザーのみ" + forExistingUsersDescription: "有効にすると、このお知らせ作成時点で存在するユーザーにのみお知らせが表示されます。無効にすると、このお知らせ作成後にアカウントを作成したユーザーにもお知らせが表示されます。" + needConfirmationToRead: "既読にするのに確認が必要" + needConfirmationToReadDescription: "有効にすると、このお知らせを既読にする際に確認ダイアログが表示されます。また、一括既読操作の対象になりません。" + end: "お知らせを終了" + tooManyActiveAnnouncementDescription: "アクティブなお知らせが多いため、UXが低下する可能性があります。終了したお知らせはアーカイブすることを検討してください。" + readConfirmTitle: "既読にしますか?" + readConfirmText: "「{title}」の内容を読み、既読にします。" _initialAccountSetting: accountCreated: "アカウントの作成が完了しました!" diff --git a/packages/backend/migration/1691649257651-refine-announcement.js b/packages/backend/migration/1691649257651-refine-announcement.js new file mode 100644 index 000000000..d8d63f310 --- /dev/null +++ b/packages/backend/migration/1691649257651-refine-announcement.js @@ -0,0 +1,27 @@ +export class RefineAnnouncement1691649257651 { + name = 'RefineAnnouncement1691649257651' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "announcement" ADD "display" character varying(256) NOT NULL DEFAULT 'normal'`); + await queryRunner.query(`ALTER TABLE "announcement" ADD "needConfirmationToRead" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "announcement" ADD "isActive" boolean NOT NULL DEFAULT true`); + await queryRunner.query(`ALTER TABLE "announcement" ADD "forExistingUsers" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "announcement" ADD "userId" character varying(32)`); + await queryRunner.query(`CREATE INDEX "IDX_bc1afcc8ef7e9400cdc3c0a87e" ON "announcement" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_da795d3a83187e8832005ba19d" ON "announcement" ("forExistingUsers") `); + await queryRunner.query(`CREATE INDEX "IDX_fd25dfe3da37df1715f11ba6ec" ON "announcement" ("userId") `); + await queryRunner.query(`ALTER TABLE "announcement" ADD CONSTRAINT "FK_fd25dfe3da37df1715f11ba6ec8" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "announcement" DROP CONSTRAINT "FK_fd25dfe3da37df1715f11ba6ec8"`); + await queryRunner.query(`DROP INDEX "public"."IDX_fd25dfe3da37df1715f11ba6ec"`); + await queryRunner.query(`DROP INDEX "public"."IDX_da795d3a83187e8832005ba19d"`); + await queryRunner.query(`DROP INDEX "public"."IDX_bc1afcc8ef7e9400cdc3c0a87e"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "userId"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "forExistingUsers"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "isActive"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "needConfirmationToRead"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "display"`); + } +} diff --git a/packages/backend/migration/1691657412740-refine-announcement-2.js b/packages/backend/migration/1691657412740-refine-announcement-2.js new file mode 100644 index 000000000..8791f99f4 --- /dev/null +++ b/packages/backend/migration/1691657412740-refine-announcement-2.js @@ -0,0 +1,11 @@ +export class RefineAnnouncement21691657412740 { + name = 'RefineAnnouncement21691657412740' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "announcement" ADD "icon" character varying(256) NOT NULL DEFAULT 'info'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "icon"`); + } +} diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts new file mode 100644 index 000000000..482aeee39 --- /dev/null +++ b/packages/backend/src/core/AnnouncementService.ts @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { User } from '@/models/entities/User.js'; +import type { AnnouncementReadsRepository, AnnouncementsRepository, Announcement, AnnouncementRead } from '@/models/index.js'; +import { bindThis } from '@/decorators.js'; +import { Packed } from '@/misc/json-schema.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; + +@Injectable() +export class AnnouncementService { + constructor( + @Inject(DI.announcementsRepository) + private announcementsRepository: AnnouncementsRepository, + + @Inject(DI.announcementReadsRepository) + private announcementReadsRepository: AnnouncementReadsRepository, + + private idService: IdService, + private globalEventService: GlobalEventService, + ) { + } + + @bindThis + public async getReads(userId: User['id']): Promise { + return this.announcementReadsRepository.findBy({ + userId: userId, + }); + } + + @bindThis + public async getUnreadAnnouncements(user: User): Promise { + const readsQuery = this.announcementReadsRepository.createQueryBuilder('read') + .select('read.announcementId') + .where('read.userId = :userId', { userId: user.id }); + + const q = this.announcementsRepository.createQueryBuilder('announcement') + .where('announcement.isActive = true') + .andWhere(new Brackets(qb => { + qb.orWhere('announcement.userId = :userId', { userId: user.id }); + qb.orWhere('announcement.userId IS NULL'); + })) + .andWhere(new Brackets(qb => { + qb.orWhere('announcement.forExistingUsers = false'); + qb.orWhere('announcement.createdAt > :createdAt', { createdAt: user.createdAt }); + })) + .andWhere(`announcement.id NOT IN (${ readsQuery.getQuery() })`); + + q.setParameters(readsQuery.getParameters()); + + return q.getMany(); + } + + @bindThis + public async create(values: Partial): Promise<{ raw: Announcement; packed: Packed<'Announcement'> }> { + const announcement = await this.announcementsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + updatedAt: null, + title: values.title, + text: values.text, + imageUrl: values.imageUrl, + icon: values.icon, + display: values.display, + forExistingUsers: values.forExistingUsers, + needConfirmationToRead: values.needConfirmationToRead, + userId: values.userId, + }).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0])); + + const packed = (await this.packMany([announcement]))[0]; + + if (values.userId) { + this.globalEventService.publishMainStream(values.userId, 'announcementCreated', { + announcement: packed, + }); + } else { + this.globalEventService.publishBroadcastStream('announcementCreated', { + announcement: packed, + }); + } + + return { + raw: announcement, + packed: packed, + }; + } + + @bindThis + public async read(user: User, announcementId: Announcement['id']): Promise { + try { + await this.announcementReadsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + announcementId: announcementId, + userId: user.id, + }); + } catch (e) { + return; + } + + if ((await this.getUnreadAnnouncements(user)).length === 0) { + this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements'); + } + } + + @bindThis + public async packMany( + announcements: Announcement[], + me?: { id: User['id'] } | null | undefined, + options?: { + reads?: AnnouncementRead[]; + }, + ): Promise[]> { + const reads = me ? (options?.reads ?? await this.getReads(me.id)) : []; + return announcements.map(announcement => ({ + id: announcement.id, + createdAt: announcement.createdAt.toISOString(), + updatedAt: announcement.updatedAt?.toISOString() ?? null, + text: announcement.text, + title: announcement.title, + imageUrl: announcement.imageUrl, + icon: announcement.icon, + display: announcement.display, + needConfirmationToRead: announcement.needConfirmationToRead, + forYou: announcement.userId === me?.id, + isRead: reads.some(read => read.announcementId === announcement.id), + })); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 9d6fb8792..51d4f9cfa 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -7,6 +7,7 @@ import { Module } from '@nestjs/common'; import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; +import { AnnouncementService } from './AnnouncementService.js'; import { AntennaService } from './AntennaService.js'; import { AppLockService } from './AppLockService.js'; import { AchievementService } from './AchievementService.js'; @@ -130,6 +131,7 @@ const $LoggerService: Provider = { provide: 'LoggerService', useExisting: Logger const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService }; const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService }; const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; +const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExisting: AnnouncementService }; const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; @@ -257,6 +259,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AccountMoveService, AccountUpdateService, AiService, + AnnouncementService, AntennaService, AppLockService, AchievementService, @@ -377,6 +380,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AccountMoveService, $AccountUpdateService, $AiService, + $AnnouncementService, $AntennaService, $AppLockService, $AchievementService, @@ -498,6 +502,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AccountMoveService, AccountUpdateService, AiService, + AnnouncementService, AntennaService, AppLockService, AchievementService, @@ -617,6 +622,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AccountMoveService, $AccountUpdateService, $AiService, + $AnnouncementService, $AntennaService, $AppLockService, $AchievementService, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 195922422..7be547fbb 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -4,7 +4,6 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { In, Not } from 'typeorm'; import * as Redis from 'ioredis'; import _Ajv from 'ajv'; import { ModuleRef } from '@nestjs/core'; @@ -16,13 +15,13 @@ import { awaitAll } from '@/misc/prelude/await-all.js'; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; import type { LocalUser, PartialLocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; -import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, UserNotePiningsRepository, UserProfilesRepository, AnnouncementReadsRepository, AnnouncementsRepository, UserProfile, RenoteMutingsRepository, UserMemoRepository } from '@/models/index.js'; +import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, UserNotePiningsRepository, UserProfilesRepository, AnnouncementReadsRepository, AnnouncementsRepository, UserProfile, RenoteMutingsRepository, UserMemoRepository, Announcement } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import type { OnModuleInit } from '@nestjs/common'; -import type { AntennaService } from '../AntennaService.js'; +import type { AnnouncementService } from '../AnnouncementService.js'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { NoteEntityService } from './NoteEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js'; @@ -58,7 +57,7 @@ export class UserEntityService implements OnModuleInit { private driveFileEntityService: DriveFileEntityService; private pageEntityService: PageEntityService; private customEmojiService: CustomEmojiService; - private antennaService: AntennaService; + private announcementService: AnnouncementService; private roleService: RoleService; private federatedInstanceService: FederatedInstanceService; @@ -128,7 +127,7 @@ export class UserEntityService implements OnModuleInit { this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); this.pageEntityService = this.moduleRef.get('PageEntityService'); this.customEmojiService = this.moduleRef.get('CustomEmojiService'); - this.antennaService = this.moduleRef.get('AntennaService'); + this.announcementService = this.moduleRef.get('AnnouncementService'); this.roleService = this.moduleRef.get('RoleService'); this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); } @@ -208,19 +207,6 @@ export class UserEntityService implements OnModuleInit { }); } - @bindThis - public async getHasUnreadAnnouncement(userId: User['id']): Promise { - const reads = await this.announcementReadsRepository.findBy({ - userId: userId, - }); - - const count = await this.announcementsRepository.countBy(reads.length > 0 ? { - id: Not(In(reads.map(read => read.announcementId))), - } : {}); - - return count > 0; - } - @bindThis public async getHasUnreadAntenna(userId: User['id']): Promise { /* @@ -347,6 +333,7 @@ export class UserEntityService implements OnModuleInit { const isModerator = isMe && opts.detail ? this.roleService.isModerator(user) : null; const isAdmin = isMe && opts.detail ? this.roleService.isAdministrator(user) : null; + const unreadAnnouncements = isMe && opts.detail ? await this.announcementService.getUnreadAnnouncements(user) : null; const falsy = opts.detail ? false : undefined; @@ -456,7 +443,8 @@ export class UserEntityService implements OnModuleInit { where: { userId: user.id, isMentioned: true }, take: 1, }).then(count => count > 0), - hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id), + hasUnreadAnnouncement: unreadAnnouncements!.length > 0, + unreadAnnouncements, hasUnreadAntenna: this.getHasUnreadAntenna(user.id), hasUnreadChannel: false, // 後方互換性のため hasUnreadNotification: this.getHasUnreadNotification(user.id), diff --git a/packages/backend/src/misc/correct-filename.ts b/packages/backend/src/misc/correct-filename.ts index 34cb458a2..9130af44c 100644 --- a/packages/backend/src/misc/correct-filename.ts +++ b/packages/backend/src/misc/correct-filename.ts @@ -43,6 +43,8 @@ export function correctFilename(filename: string, ext: string | null) { // jpeg, tiffを同一視 dotExt === '.jpg' && filenameExt === '.jpeg' || dotExt === '.tif' && filenameExt === '.tiff' || + // dllもexeもportable executableなので判定が正しく行われない + dotExt === '.exe' && filenameExt === '.dll' || // 圧縮形式っぽければ下手に拡張子を変えない // https://github.com/misskey-dev/misskey/issues/11482 diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index ca8a030a6..80c1041c6 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -35,6 +35,7 @@ import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js'; import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js'; import { packedFlashSchema } from '@/models/json-schema/flash.js'; +import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -46,6 +47,7 @@ export const refs = { User: packedUserSchema, UserList: packedUserListSchema, + Announcement: packedAnnouncementSchema, App: packedAppSchema, Note: packedNoteSchema, NoteReaction: packedNoteReactionSchema, diff --git a/packages/backend/src/models/entities/Announcement.ts b/packages/backend/src/models/entities/Announcement.ts index 99cdf8933..18c26faab 100644 --- a/packages/backend/src/models/entities/Announcement.ts +++ b/packages/backend/src/models/entities/Announcement.ts @@ -3,8 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Entity, Index, Column, PrimaryColumn } from 'typeorm'; +import { Entity, Index, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() export class Announcement { @@ -38,6 +39,52 @@ export class Announcement { }) public imageUrl: string | null; + // info, warning, error, success + @Column('varchar', { + length: 256, nullable: false, + default: 'info', + }) + public icon: string; + + // normal ... お知らせページ掲載 + // banner ... お知らせページ掲載 + バナー表示 + // dialog ... お知らせページ掲載 + ダイアログ表示 + @Column('varchar', { + length: 256, nullable: false, + default: 'normal', + }) + public display: string; + + @Column('boolean', { + default: false, + }) + public needConfirmationToRead: boolean; + + @Index() + @Column('boolean', { + default: true, + }) + public isActive: boolean; + + @Index() + @Column('boolean', { + default: false, + }) + public forExistingUsers: boolean; + + @Index() + @Column({ + ...id(), + nullable: true, + }) + public userId: User['id'] | null; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + constructor(data: Partial) { if (data == null) return; diff --git a/packages/backend/src/models/json-schema/announcement.ts b/packages/backend/src/models/json-schema/announcement.ts new file mode 100644 index 000000000..c7e24c7f2 --- /dev/null +++ b/packages/backend/src/models/json-schema/announcement.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedAnnouncementSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + updatedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, + text: { + type: 'string', + optional: false, nullable: false, + }, + title: { + type: 'string', + optional: false, nullable: false, + }, + imageUrl: { + type: 'string', + optional: false, nullable: true, + }, + icon: { + type: 'string', + optional: false, nullable: false, + }, + display: { + type: 'string', + optional: false, nullable: false, + }, + forYou: { + type: 'boolean', + optional: false, nullable: false, + }, + needConfirmationToRead: { + type: 'boolean', + optional: false, nullable: false, + }, + isRead: { + type: 'boolean', + optional: true, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts index 8addffc6a..6c5520c2e 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -3,11 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AnnouncementsRepository } from '@/models/index.js'; -import { IdService } from '@/core/IdService.js'; -import { DI } from '@/di-symbols.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; export const meta = { tags: ['admin'], @@ -57,6 +55,11 @@ export const paramDef = { title: { type: 'string', minLength: 1 }, text: { type: 'string', minLength: 1 }, imageUrl: { type: 'string', nullable: true, minLength: 1 }, + icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'], default: 'info' }, + display: { type: 'string', enum: ['normal', 'banner', 'dialog'], default: 'normal' }, + forExistingUsers: { type: 'boolean', default: false }, + needConfirmationToRead: { type: 'boolean', default: false }, + userId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, }, required: ['title', 'text', 'imageUrl'], } as const; @@ -65,22 +68,23 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.announcementsRepository) - private announcementsRepository: AnnouncementsRepository, - - private idService: IdService, + private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { - const announcement = await this.announcementsRepository.insert({ - id: this.idService.genId(), + const { raw, packed } = await this.announcementService.create({ createdAt: new Date(), updatedAt: null, title: ps.title, text: ps.text, imageUrl: ps.imageUrl, - }).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0])); + icon: ps.icon, + display: ps.display, + forExistingUsers: ps.forExistingUsers, + needConfirmationToRead: ps.needConfirmationToRead, + userId: ps.userId, + }); - return Object.assign({}, announcement, { createdAt: announcement.createdAt.toISOString(), updatedAt: null }); + return packed; }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts index ec0d4061a..4da3f457f 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -66,6 +66,7 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id', nullable: true }, }, required: [], } as const; @@ -84,6 +85,11 @@ export default class extends Endpoint { ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); + if (ps.userId) { + query.andWhere('announcement.userId = :userId', { userId: ps.userId }); + } else { + query.andWhere('announcement.userId IS NULL'); + } const announcements = await query.limit(ps.limit).getMany(); @@ -102,6 +108,12 @@ export default class extends Endpoint { title: announcement.title, text: announcement.text, imageUrl: announcement.imageUrl, + icon: announcement.icon, + display: announcement.display, + isActive: announcement.isActive, + forExistingUsers: announcement.forExistingUsers, + needConfirmationToRead: announcement.needConfirmationToRead, + userId: announcement.userId, reads: reads.get(announcement)!, })); }); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts index b3df14320..7efc7c040 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts @@ -31,8 +31,13 @@ export const paramDef = { title: { type: 'string', minLength: 1 }, text: { type: 'string', minLength: 1 }, imageUrl: { type: 'string', nullable: true, minLength: 0 }, + icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'] }, + display: { type: 'string', enum: ['normal', 'banner', 'dialog'] }, + forExistingUsers: { type: 'boolean' }, + needConfirmationToRead: { type: 'boolean' }, + isActive: { type: 'boolean' }, }, - required: ['id', 'title', 'text', 'imageUrl'], + required: ['id'], } as const; // eslint-disable-next-line import/no-default-export @@ -53,6 +58,11 @@ export default class extends Endpoint { text: ps.text, /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */ imageUrl: ps.imageUrl || null, + display: ps.display, + icon: ps.icon, + forExistingUsers: ps.forExistingUsers, + needConfirmationToRead: ps.needConfirmationToRead, + isActive: ps.isActive, }); }); } diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts index df0a0afb0..070e6f0d7 100644 --- a/packages/backend/src/server/api/endpoints/announcements.ts +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -4,8 +4,10 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; import { DI } from '@/di-symbols.js'; import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/index.js'; @@ -20,40 +22,7 @@ export const meta = { items: { type: 'object', optional: false, nullable: false, - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - updatedAt: { - type: 'string', - optional: false, nullable: true, - format: 'date-time', - }, - text: { - type: 'string', - optional: false, nullable: false, - }, - title: { - type: 'string', - optional: false, nullable: false, - }, - imageUrl: { - type: 'string', - optional: false, nullable: true, - }, - isRead: { - type: 'boolean', - optional: true, nullable: false, - }, - }, + ref: 'Announcement', }, }, } as const; @@ -62,9 +31,9 @@ export const paramDef = { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - withUnreads: { type: 'boolean', default: false }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + isActive: { type: 'boolean', default: true }, }, required: [], } as const; @@ -80,27 +49,19 @@ export default class extends Endpoint { private announcementReadsRepository: AnnouncementReadsRepository, private queryService: QueryService, + private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); + const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId) + .where('announcement.isActive = :isActive', { isActive: ps.isActive }) + .andWhere(new Brackets(qb => { + if (me) qb.orWhere('announcement.userId = :meId', { meId: me.id }); + qb.orWhere('announcement.userId IS NULL'); + })); const announcements = await query.limit(ps.limit).getMany(); - if (me) { - const reads = (await this.announcementReadsRepository.findBy({ - userId: me.id, - })).map(x => x.announcementId); - - for (const announcement of announcements) { - (announcement as any).isRead = reads.includes(announcement.id); - } - } - - return (ps.withUnreads ? announcements.filter((a: any) => !a.isRead) : announcements).map((a) => ({ - ...a, - createdAt: a.createdAt.toISOString(), - updatedAt: a.updatedAt?.toISOString() ?? null, - })); + return this.announcementService.packMany(announcements, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/read-announcement.ts b/packages/backend/src/server/api/endpoints/i/read-announcement.ts index 7bda6e262..412532939 100644 --- a/packages/backend/src/server/api/endpoints/i/read-announcement.ts +++ b/packages/backend/src/server/api/endpoints/i/read-announcement.ts @@ -3,14 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { IdService } from '@/core/IdService.js'; -import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/index.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; export const meta = { tags: ['account'], @@ -20,11 +15,6 @@ export const meta = { kind: 'write:account', errors: { - noSuchAnnouncement: { - message: 'No such announcement.', - code: 'NO_SUCH_ANNOUNCEMENT', - id: '184663db-df88-4bc2-8b52-fb85f0681939', - }, }, } as const; @@ -40,47 +30,10 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.announcementsRepository) - private announcementsRepository: AnnouncementsRepository, - - @Inject(DI.announcementReadsRepository) - private announcementReadsRepository: AnnouncementReadsRepository, - - private userEntityService: UserEntityService, - private idService: IdService, - private globalEventService: GlobalEventService, + private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { - // Check if announcement exists - const announcementExist = await this.announcementsRepository.exist({ where: { id: ps.announcementId } }); - - if (!announcementExist) { - throw new ApiError(meta.errors.noSuchAnnouncement); - } - - // Check if already read - const alreadyRead = await this.announcementReadsRepository.exist({ - where: { - announcementId: ps.announcementId, - userId: me.id, - }, - }); - - if (alreadyRead) { - return; - } - - // Create read - await this.announcementReadsRepository.insert({ - id: this.idService.genId(), - createdAt: new Date(), - announcementId: ps.announcementId, - userId: me.id, - }); - - if (!await this.userEntityService.getHasUnreadAnnouncement(me.id)) { - this.globalEventService.publishMainStream(me.id, 'readAllAnnouncements'); - } + await this.announcementService.read(me, ps.announcementId); }); } } diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index 82ccd91c8..751a23de8 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -64,6 +64,9 @@ export interface BroadcastTypes { [other: string]: any; }[]; }; + announcementCreated: { + announcement: Packed<'Announcement'>; + }; } export interface MainStreamTypes { @@ -105,6 +108,9 @@ export interface MainStreamTypes { driveFileCreated: Packed<'DriveFile'>; readAntenna: Antenna; receiveFollowRequest: Packed<'User'>; + announcementCreated: { + announcement: Packed<'Announcement'>; + }; } export interface DriveStreamTypes { diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 0530b44ce..8afbcbe32 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -160,6 +160,7 @@ describe('ユーザー', () => { hasUnreadChannel: user.hasUnreadChannel, hasUnreadNotification: user.hasUnreadNotification, hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest, + unreadAnnouncements: user.unreadAnnouncements, mutedWords: user.mutedWords, mutedInstances: user.mutedInstances, mutingNotificationTypes: user.mutingNotificationTypes, @@ -405,6 +406,7 @@ describe('ユーザー', () => { assert.strictEqual(response.hasUnreadChannel, false); assert.strictEqual(response.hasUnreadNotification, false); assert.strictEqual(response.hasPendingReceivedFollowRequest, false); + assert.deepStrictEqual(response.unreadAnnouncements, []); assert.deepStrictEqual(response.mutedWords, []); assert.deepStrictEqual(response.mutedInstances, []); assert.deepStrictEqual(response.mutingNotificationTypes, []); diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts new file mode 100644 index 000000000..c97e081ba --- /dev/null +++ b/packages/backend/test/unit/AnnouncementService.ts @@ -0,0 +1,194 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { jest } from '@jest/globals'; +import { ModuleMocker } from 'jest-mock'; +import { Test } from '@nestjs/testing'; +import { GlobalModule } from '@/GlobalModule.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; +import type { Announcement, AnnouncementsRepository, AnnouncementReadsRepository, UsersRepository, User } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { genAid } from '@/misc/id/aid.js'; +import { CacheService } from '@/core/CacheService.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; +import type { TestingModule } from '@nestjs/testing'; +import type { MockFunctionMetadata } from 'jest-mock'; + +const moduleMocker = new ModuleMocker(global); + +describe('AnnouncementService', () => { + let app: TestingModule; + let announcementService: AnnouncementService; + let usersRepository: UsersRepository; + let announcementsRepository: AnnouncementsRepository; + let announcementReadsRepository: AnnouncementReadsRepository; + let globalEventService: jest.Mocked; + + function createUser(data: Partial = {}) { + const un = secureRndstr(16); + return usersRepository.insert({ + id: genAid(new Date()), + createdAt: new Date(), + username: un, + usernameLower: un, + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + } + + function createAnnouncement(data: Partial = {}) { + return announcementsRepository.insert({ + id: genAid(new Date()), + createdAt: new Date(), + updatedAt: null, + title: 'Title', + text: 'Text', + ...data, + }) + .then(x => announcementsRepository.findOneByOrFail(x.identifiers[0])); + } + + beforeEach(async () => { + app = await Test.createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + AnnouncementService, + CacheService, + IdService, + ], + }) + .useMocker((token) => { + if (token === GlobalEventService) { + return { + publishMainStream: jest.fn(), + publishBroadcastStream: jest.fn(), + }; + } + if (typeof token === 'function') { + const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; + const Mock = moduleMocker.generateFromMetadata(mockMetadata); + return new Mock(); + } + }) + .compile(); + + app.enableShutdownHooks(); + + announcementService = app.get(AnnouncementService); + usersRepository = app.get(DI.usersRepository); + announcementsRepository = app.get(DI.announcementsRepository); + announcementReadsRepository = app.get(DI.announcementReadsRepository); + globalEventService = app.get(GlobalEventService) as jest.Mocked; + }); + + afterEach(async () => { + await Promise.all([ + app.get(DI.metasRepository).delete({}), + usersRepository.delete({}), + announcementsRepository.delete({}), + announcementReadsRepository.delete({}), + ]); + + await app.close(); + }); + + describe('getUnreadAnnouncements', () => { + test('通常', async () => { + const user = await createUser(); + const announcement = await createAnnouncement({ + title: '1', + }); + + const result = await announcementService.getUnreadAnnouncements(user); + + expect(result.length).toBe(1); + expect(result[0].title).toBe(announcement.title); + }); + + test('isActiveがfalseは除外', async () => { + const user = await createUser(); + await createAnnouncement({ + isActive: false, + }); + + const result = await announcementService.getUnreadAnnouncements(user); + + expect(result.length).toBe(0); + }); + + test('forExistingUsers', async () => { + const user = await createUser(); + const [announcementAfter, announcementBefore, announcementBefore2] = await Promise.all([ + createAnnouncement({ + title: 'after', + createdAt: new Date(), + forExistingUsers: true, + }), + createAnnouncement({ + title: 'before', + createdAt: new Date(Date.now() - 1000), + forExistingUsers: true, + }), + createAnnouncement({ + title: 'before2', + createdAt: new Date(Date.now() - 1000), + forExistingUsers: false, + }), + ]); + + const result = await announcementService.getUnreadAnnouncements(user); + + expect(result.length).toBe(2); + expect(result.some(a => a.title === announcementAfter.title)).toBe(true); + expect(result.some(a => a.title === announcementBefore.title)).toBe(false); + expect(result.some(a => a.title === announcementBefore2.title)).toBe(true); + }); + }); + + describe('create', () => { + test('通常', async () => { + const result = await announcementService.create({ + title: 'Title', + text: 'Text', + }); + + expect(result.raw.title).toBe('Title'); + expect(result.packed.title).toBe('Title'); + + expect(globalEventService.publishBroadcastStream).toHaveBeenCalled(); + expect(globalEventService.publishBroadcastStream.mock.lastCall![0]).toBe('announcementCreated'); + expect((globalEventService.publishBroadcastStream.mock.lastCall![1] as any).announcement).toBe(result.packed); + }); + + test('ユーザー指定', async () => { + const user = await createUser(); + const result = await announcementService.create({ + title: 'Title', + text: 'Text', + userId: user.id, + }); + + expect(result.raw.title).toBe('Title'); + expect(result.packed.title).toBe('Title'); + + expect(globalEventService.publishBroadcastStream).not.toHaveBeenCalled(); + expect(globalEventService.publishMainStream).toHaveBeenCalled(); + expect(globalEventService.publishMainStream.mock.lastCall![0]).toBe(user.id); + expect(globalEventService.publishMainStream.mock.lastCall![1]).toBe('announcementCreated'); + expect((globalEventService.publishMainStream.mock.lastCall![2] as any).announcement).toBe(result.packed); + }); + }); + + describe('read', () => { + // TODO + }); +}); + diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index c3f3337c9..634084c75 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -96,7 +96,6 @@ export async function removeAccount(idOrToken: Account['id']) { function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise { return new Promise((done, fail) => { - // Fetch user window.fetch(`${apiUrl}/i`, { method: 'POST', body: JSON.stringify({ @@ -108,8 +107,8 @@ function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Pr }) .then(res => new Promise }>((done2, fail2) => { if (res.status >= 500 && res.status < 600) { - // サーバーエラー(5xx)の場合をrejectとする - // (認証エラーなど4xxはresolve) + // サーバーエラー(5xx)の場合をrejectとする + // (認証エラーなど4xxはresolve) return fail2(res); } res.json().then(done2, fail2); diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 7459ea0fa..9ab1f6e14 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -83,6 +83,21 @@ export async function mainBoot() { } }); + for (const announcement of ($i.unreadAnnouncements ?? []).filter(x => x.display === 'dialog')) { + popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { + announcement, + }, {}, 'closed'); + } + + stream.on('announcementCreated', (ev) => { + const announcement = ev.announcement; + if (announcement.display === 'dialog') { + popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { + announcement, + }, {}, 'closed'); + } + }); + if ($i.isDeleted) { alert({ type: 'warning', diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index d46e5da06..cb97875bc 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only