diff --git a/locales/index.d.ts b/locales/index.d.ts index e97eec6c6d..fc858aa288 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1076,8 +1076,6 @@ export interface Locale { "additionalEmojiDictionary": string; "installed": string; "branding": string; - "newUserAnnouncementAvailable": string; - "viewAnnouncement": string; "dialogCloseDuration": string; "enableServerMachineStats": string; "enableIdenticonGeneration": string; @@ -1102,6 +1100,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 a4d877954e..ccb84da12c 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -330,7 +330,7 @@ watch: "ウォッチ" unwatch: "ウォッチ解除" accept: "許可" reject: "拒否" -normal: "正常" +normal: "通常" instanceName: "サーバー名" instanceDescription: "サーバーの紹介" maintainerName: "管理者の名前" @@ -1073,8 +1073,6 @@ goToMisskey: "Misskeyへ" additionalEmojiDictionary: "絵文字の追加辞書" installed: "インストール済み" branding: "ブランディング" -newUserAnnouncementAvailable: "新着のあなた宛てのお知らせがあります" -viewAnnouncement: "お知らせを見る" dialogCloseDuration: "ダイアログを閉じるまでの待機時間" enableServerMachineStats: "サーバーのマシン情報を公開する" enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする" @@ -1099,6 +1097,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 0000000000..16fe987b0f --- /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 0000000000..8791f99f44 --- /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 0000000000..e00bd2582c --- /dev/null +++ b/packages/backend/src/core/AnnouncementService.ts @@ -0,0 +1,354 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets, In } from 'typeorm'; +import type { AnnouncementReadsRepository, AnnouncementsRepository, UsersRepository } from '@/models/index.js'; +import type { User } from '@/models/entities/User.js'; +import { Announcement, AnnouncementRead } from '@/models/index.js'; +import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; +import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { IdService } from '@/core/IdService.js'; +import { Packed } from '@/misc/json-schema.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; + +@Injectable() +export class AnnouncementService { + constructor( + @Inject(DI.announcementsRepository) + private announcementsRepository: AnnouncementsRepository, + + @Inject(DI.announcementReadsRepository) + private announcementReadsRepository: AnnouncementReadsRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private idService: IdService, + private userEntityService: UserEntityService, + private announcementEntityService: AnnouncementEntityService, + private globalEventService: GlobalEventService, + ) {} + + @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, + closeDuration: values.closeDuration, + displayOrder: values.displayOrder, + userId: values.userId, + }) + .then((x) => + this.announcementsRepository.findOneByOrFail(x.identifiers[0]), + ); + + const packed = await this.announcementEntityService.pack( + announcement, + null, + ); + + 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 list( + userId: User['id'] | null, + limit: number, + offset: number, + moderator: User, + ): Promise<(Announcement & { userInfo: Packed<'UserLite'> | null, readCount: number })[]> { + const query = this.announcementsRepository.createQueryBuilder('announcement'); + if (userId) { + query.andWhere('announcement."userId" = :userId', { userId: userId }); + } else { + query.andWhere('announcement."userId" IS NULL'); + } + + query.orderBy({ + 'announcement."displayOrder"': 'DESC', + 'announcement."createdAt"': 'DESC', + }); + + const announcements = await query + .limit(limit) + .offset(offset) + .getMany(); + + const reads = new Map(); + + for (const announcement of announcements) { + reads.set(announcement, await this.announcementReadsRepository.countBy({ + announcementId: announcement.id, + })); + } + + const users = await this.usersRepository.findBy({ + id: In(announcements.map(a => a.userId).filter(id => id != null)), + }); + const packedUsers = await this.userEntityService.packMany(users, moderator, { + detail: false, + }); + + return announcements.map(announcement => ({ + ...announcement, + userInfo: packedUsers.find(u => u.id === announcement.userId) ?? null, + readCount: reads.get(announcement) ?? 0, + })); + } + + @bindThis + public async update( + announcementId: Announcement['id'], + values: Partial, + ): Promise<{ raw: Announcement; packed: Packed<'Announcement'> }> { + const oldAnnouncement = await this.announcementsRepository.findOneByOrFail({ + id: announcementId, + }); + + if (oldAnnouncement.userId && oldAnnouncement.userId !== values.userId) { + await this.announcementReadsRepository.delete({ + announcementId: announcementId, + userId: oldAnnouncement.userId, + }); + } + + const announcement = await this.announcementsRepository + .update(announcementId, { + updatedAt: new Date(), + isActive: values.isActive, + title: values.title, + text: values.text, + imageUrl: values.imageUrl !== '' ? values.imageUrl : null, + icon: values.icon, + display: values.display, + forExistingUsers: values.forExistingUsers, + needConfirmationToRead: values.needConfirmationToRead, + closeDuration: values.closeDuration, + displayOrder: values.displayOrder, + userId: values.userId, + }) + .then(() => + this.announcementsRepository.findOneByOrFail({ id: announcementId }), + ); + + const packed = await this.announcementEntityService.pack( + announcement, + values.userId ? { id: values.userId } : null, + ); + + 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 delete(announcementId: Announcement['id']): Promise { + await this.announcementReadsRepository.delete({ + announcementId: announcementId, + }); + await this.announcementsRepository.delete({ id: announcementId }); + } + + @bindThis + public async getAnnouncements( + me: User | null, + limit: number, + offset: number, + isActive?: boolean, + ): Promise[]> { + const query = this.announcementsRepository.createQueryBuilder('announcement'); + if (me) { + query.leftJoin( + AnnouncementRead, + 'read', + 'read."announcementId" = announcement.id AND read."userId" = :userId', + { userId: me.id }, + ); + query.select([ + 'announcement.*', + 'CASE WHEN read.id IS NULL THEN FALSE ELSE TRUE END as "isRead"', + ]); + query + .andWhere( + new Brackets((qb) => { + qb.orWhere('announcement."userId" = :userId', { userId: me.id }); + qb.orWhere('announcement."userId" IS NULL'); + }), + ) + .andWhere( + new Brackets((qb) => { + qb.orWhere('announcement."forExistingUsers" = false'); + qb.orWhere('announcement."createdAt" > :createdAt', { + createdAt: me.createdAt, + }); + }), + ); + } else { + query.select([ + 'announcement.*', + 'NULL as "isRead"', + ]); + query.andWhere('announcement."userId" IS NULL'); + query.andWhere('announcement."forExistingUsers" = false'); + } + + if (isActive !== undefined) { + query.andWhere('announcement."isActive" = :isActive', { + isActive: isActive, + }); + } + + query.orderBy({ + '"isRead"': 'ASC', + 'announcement."displayOrder"': 'DESC', + 'announcement."createdAt"': 'DESC', + }); + + return this.announcementEntityService.packMany( + await query + .limit(limit) + .offset(offset) + .getRawMany(), + me, + ); + } + + @bindThis + public async getUnreadAnnouncements(me: User): Promise[]> { + const query = this.announcementsRepository.createQueryBuilder('announcement'); + query.leftJoinAndSelect( + AnnouncementRead, + 'read', + 'read."announcementId" = announcement.id AND read."userId" = :userId', + { userId: me.id }, + ); + query.andWhere('read.id IS NULL'); + query.andWhere('announcement."isActive" = true'); + + query + .andWhere( + new Brackets((qb) => { + qb.orWhere('announcement."userId" = :userId', { userId: me.id }); + qb.orWhere('announcement."userId" IS NULL'); + }), + ) + .andWhere( + new Brackets((qb) => { + qb.orWhere('announcement."forExistingUsers" = false'); + qb.orWhere('announcement."createdAt" > :createdAt', { + createdAt: me.createdAt, + }); + }), + ); + + query.orderBy({ + 'announcement."displayOrder"': 'DESC', + 'announcement."createdAt"': 'DESC', + }); + + return this.announcementEntityService.packMany( + await query.getMany(), + me, + ); + } + + @bindThis + public async countUnreadAnnouncements(me: User): Promise { + const query = this.announcementsRepository.createQueryBuilder('announcement'); + query.leftJoinAndSelect( + AnnouncementRead, + 'read', + 'read."announcementId" = announcement.id AND read."userId" = :userId', + { userId: me.id }, + ); + query.andWhere('read.id IS NULL'); + query.andWhere('announcement."isActive" = true'); + + query + .andWhere( + new Brackets((qb) => { + qb.orWhere('announcement."userId" = :userId', { userId: me.id }); + qb.orWhere('announcement."userId" IS NULL'); + }), + ) + .andWhere( + new Brackets((qb) => { + qb.orWhere('announcement."forExistingUsers" = false'); + qb.orWhere('announcement."createdAt" > :createdAt', { + createdAt: me.createdAt, + }); + }), + ); + + return query.getCount(); + } + + @bindThis + public async markAsRead( + me: User, + announcementId: Announcement['id'], + ): Promise { + try { + await this.announcementReadsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + announcementId: announcementId, + userId: me.id, + }); + } catch (e) { + return; + } + + if ((await this.countUnreadAnnouncements(me)) === 0) { + this.globalEventService.publishMainStream(me.id, 'readAllAnnouncements'); + } + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 9d6fb87923..23055d0fba 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'; @@ -71,6 +72,7 @@ import PerUserDriveChart from './chart/charts/per-user-drive.js'; import ApRequestChart from './chart/charts/ap-request.js'; import { ChartManagementService } from './chart/ChartManagementService.js'; import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js'; +import { AnnouncementEntityService } from './entities/AnnouncementEntityService.js'; import { AntennaEntityService } from './entities/AntennaEntityService.js'; import { AppEntityService } from './entities/AppEntityService.js'; import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js'; @@ -130,6 +132,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 }; @@ -196,6 +199,7 @@ const $ApRequestChart: Provider = { provide: 'ApRequestChart', useExisting: ApRe const $ChartManagementService: Provider = { provide: 'ChartManagementService', useExisting: ChartManagementService }; const $AbuseUserReportEntityService: Provider = { provide: 'AbuseUserReportEntityService', useExisting: AbuseUserReportEntityService }; +const $AnnouncementEntityService: Provider = { provide: 'AnnouncementEntityService', useExisting: AnnouncementEntityService }; const $AntennaEntityService: Provider = { provide: 'AntennaEntityService', useExisting: AntennaEntityService }; const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: AppEntityService }; const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService }; @@ -257,6 +261,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AccountMoveService, AccountUpdateService, AiService, + AnnouncementService, AntennaService, AppLockService, AchievementService, @@ -321,6 +326,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApRequestChart, ChartManagementService, AbuseUserReportEntityService, + AnnouncementEntityService, AntennaEntityService, AppEntityService, AuthSessionEntityService, @@ -377,6 +383,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AccountMoveService, $AccountUpdateService, $AiService, + $AnnouncementService, $AntennaService, $AppLockService, $AchievementService, @@ -441,6 +448,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApRequestChart, $ChartManagementService, $AbuseUserReportEntityService, + $AnnouncementEntityService, $AntennaEntityService, $AppEntityService, $AuthSessionEntityService, @@ -498,6 +506,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AccountMoveService, AccountUpdateService, AiService, + AnnouncementService, AntennaService, AppLockService, AchievementService, @@ -561,6 +570,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApRequestChart, ChartManagementService, AbuseUserReportEntityService, + AnnouncementEntityService, AntennaEntityService, AppEntityService, AuthSessionEntityService, @@ -617,6 +627,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AccountMoveService, $AccountUpdateService, $AiService, + $AnnouncementService, $AntennaService, $AppLockService, $AchievementService, @@ -680,6 +691,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApRequestChart, $ChartManagementService, $AbuseUserReportEntityService, + $AnnouncementEntityService, $AntennaEntityService, $AppEntityService, $AuthSessionEntityService, diff --git a/packages/backend/src/core/entities/AnnouncementEntityService.ts b/packages/backend/src/core/entities/AnnouncementEntityService.ts new file mode 100644 index 0000000000..ffe480fb1a --- /dev/null +++ b/packages/backend/src/core/entities/AnnouncementEntityService.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { + AnnouncementReadsRepository, + AnnouncementsRepository, +} from '@/models/index.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { bindThis } from '@/decorators.js'; +import { Announcement, User } from '@/models/index.js'; + +@Injectable() +export class AnnouncementEntityService { + constructor( + @Inject(DI.announcementsRepository) + private announcementsRepository: AnnouncementsRepository, + + @Inject(DI.announcementReadsRepository) + private announcementReadsRepository: AnnouncementReadsRepository, + ) { + } + + @bindThis + public async pack( + src: Announcement['id'] | Announcement & { isRead?: boolean | null }, + me: { id: User['id'] } | null | undefined, + ): Promise> { + const announcement = typeof src === 'object' + ? src + : await this.announcementsRepository.findOneByOrFail({ + id: src, + }) as Announcement & { isRead?: boolean | null }; + + if (me && announcement.isRead === undefined) { + announcement.isRead = await this.announcementReadsRepository.countBy({ + announcementId: announcement.id, + userId: me.id, + }).then(count => count > 0); + } + + return { + id: announcement.id, + createdAt: announcement.createdAt.toISOString(), + updatedAt: announcement.updatedAt?.toISOString() ?? null, + title: announcement.title, + text: announcement.text, + imageUrl: announcement.imageUrl, + icon: announcement.icon, + display: announcement.display, + forYou: announcement.userId === me?.id, + needConfirmationToRead: announcement.needConfirmationToRead, + closeDuration: announcement.closeDuration, + displayOrder: announcement.displayOrder, + isRead: announcement.isRead !== null ? announcement.isRead : undefined, + }; + } + + @bindThis + public async packMany( + announcements: (Announcement['id'] | Announcement & { isRead?: boolean | null } | Announcement)[], + me: { id: User['id'] } | null | undefined, + ) : Promise[]> { + return (await Promise.allSettled(announcements.map(x => this.pack(x, me)))) + .filter(result => result.status === 'fulfilled') + .map(result => (result as PromiseFulfilledResult>).value); + } +} diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 41705d6725..8fa4b0d1bf 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, IsNull, Not } from 'typeorm'; import * as Redis from 'ioredis'; import _Ajv from 'ajv'; import { ModuleRef } from '@nestjs/core'; @@ -49,7 +48,7 @@ import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { IdentifiableError } from '@/misc/identifiable-error.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'; @@ -85,7 +84,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; @@ -164,7 +163,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'); } @@ -244,21 +243,6 @@ export class UserEntityService implements OnModuleInit { }); } - @bindThis - public async getHasUnreadAnnouncement(userId: User['id']): Promise { - const reads = await this.announcementReadsRepository.findBy({ - userId: userId, - }); - - const id = reads.length > 0 ? Not(In(reads.map(read => read.announcementId))) : undefined; - const count = await this.announcementsRepository.countBy([ - { id, userId: IsNull() }, - { id, userId: userId }, - ]); - - return count > 0; - } - @bindThis public async getHasUnreadAntenna(userId: User['id']): Promise { /* @@ -387,6 +371,7 @@ export class UserEntityService implements OnModuleInit { const isModerator = isMe && opts.detail ? await this.roleService.isModerator(user) : null; const isAdmin = isMe && opts.detail ? await this.roleService.isAdministrator(user) : null; const policies = opts.detail ? await this.roleService.getUserPolicies(user.id) : null; + const unreadAnnouncements = isMe && opts.detail ? await this.announcementService.getUnreadAnnouncements(user) : null; const falsy = opts.detail ? false : undefined; @@ -498,7 +483,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/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 5b8906f9c5..4cc53cf547 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -38,6 +38,7 @@ import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; import { packedRenoteMutingSchema } from '@/models/json-schema/renote-muting.js'; import { packedRoleSchema } from '@/models/json-schema/role.js'; import { packedUserListSchema } from '@/models/json-schema/user-list.js'; +import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -49,6 +50,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 4929a791f8..7dcd0c1755 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,19 +39,26 @@ export class Announcement { }) public imageUrl: string | null; - // UIに表示する際の並び順用(大きいほど先頭) - @Index() - @Column('integer', { - default: 0, - }) - public displayOrder: number; - - @Index() + // info, warning, error, success @Column('varchar', { - ...id(), - nullable: true, + length: 256, nullable: false, + default: 'info', }) - public userId: string | null; + public icon: string; + + // normal ... お知らせページ掲載 + // banner ... お知らせページ掲載 + バナー表示 + // dialog ... お知らせページ掲載 + ダイアログ表示 + @Column('varchar', { + length: 256, nullable: false, + default: 'normal', + }) + public display: string; + + @Column('boolean', { + default: false, + }) + public needConfirmationToRead: boolean; @Column('integer', { nullable: false, @@ -58,6 +66,39 @@ export class Announcement { }) public closeDuration: number; + @Index() + @Column('boolean', { + default: true, + }) + public isActive: boolean; + + // UIに表示する際の並び順用(大きいほど先頭) + @Index() + @Column('integer', { + nullable: false, + default: 0, + }) + public displayOrder: number; + + @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 0000000000..e23620e992 --- /dev/null +++ b/packages/backend/src/models/json-schema/announcement.ts @@ -0,0 +1,66 @@ +/* + * 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, + }, + closeDuration: { + type: 'number', + optional: false, nullable: false, + }, + displayOrder: { + type: 'number', + 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 38f69a8925..c60d0d1889 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'], @@ -47,6 +45,26 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + icon: { + type: 'string', + optional: false, nullable: false, + }, + display: { + type: 'string', + optional: false, nullable: false, + }, + forExistingUsers: { + type: 'boolean', + optional: false, nullable: false, + }, + needConfirmationToRead: { + type: 'boolean', + optional: false, nullable: false, + }, + closeDuration: { + type: 'number', + optional: false, nullable: false, + }, displayOrder: { type: 'number', optional: false, nullable: false, @@ -55,10 +73,6 @@ export const meta = { type: 'string', optional: false, nullable: true, }, - closeDuration: { - type: 'number', - optional: false, nullable: false, - }, }, }, } as const; @@ -69,9 +83,13 @@ export const paramDef = { title: { type: 'string', minLength: 1 }, text: { type: 'string', minLength: 1 }, imageUrl: { type: 'string', nullable: true, minLength: 1 }, - displayOrder: { type: 'number' }, - userId: { type: 'string', nullable: true, format: 'misskey:id' }, - closeDuration: { type: 'number', nullable: false }, + 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 }, + closeDuration: { type: 'number', default: 0 }, + displayOrder: { type: 'number', default: 0 }, + userId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, }, required: ['title', 'text', 'imageUrl'], } as const; @@ -80,25 +98,39 @@ 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, - displayOrder: ps.displayOrder, - userId: ps.userId ?? null, + icon: ps.icon, + display: ps.display, + forExistingUsers: ps.forExistingUsers, + needConfirmationToRead: ps.needConfirmationToRead, closeDuration: ps.closeDuration, - }).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0])); + displayOrder: ps.displayOrder, + userId: ps.userId, + }); - return Object.assign({}, announcement, { createdAt: announcement.createdAt.toISOString(), updatedAt: null }); + return { + id: packed.id, + createdAt: packed.createdAt, + updatedAt: packed.updatedAt, + title: packed.title, + text: packed.text, + imageUrl: packed.imageUrl, + icon: packed.icon, + display: packed.display, + forExistingUsers: raw.forExistingUsers, + needConfirmationToRead: packed.needConfirmationToRead, + closeDuration: packed.closeDuration, + displayOrder: packed.displayOrder, + userId: raw.userId, + }; }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts b/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts index 6066a3ceaf..02dc7e7896 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts @@ -5,9 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AnnouncementsRepository } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../../error.js'; +import type { AnnouncementsRepository } from '@/models/index.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; export const meta = { tags: ['admin'], @@ -38,13 +39,15 @@ export default class extends Endpoint { constructor( @Inject(DI.announcementsRepository) private announcementsRepository: AnnouncementsRepository, + + private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { const announcement = await this.announcementsRepository.findOneBy({ id: ps.id }); if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); - await this.announcementsRepository.delete(announcement.id); + await this.announcementService.delete(announcement.id); }); } } 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 a0d554d945..7dac500799 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -3,14 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; -import type { AnnouncementsRepository, AnnouncementReadsRepository, UsersRepository } from '@/models/index.js'; -import type { Announcement } from '@/models/entities/Announcement.js'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DI } from '@/di-symbols.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; export const meta = { tags: ['admin'], @@ -41,18 +36,42 @@ export const meta = { optional: false, nullable: true, format: 'date-time', }, - text: { - type: 'string', + isActive: { + type: 'boolean', optional: false, nullable: false, }, title: { type: 'string', optional: false, nullable: false, }, + text: { + 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, + }, + forExistingUsers: { + type: 'boolean', + optional: false, nullable: false, + }, + needConfirmationToRead: { + type: 'boolean', + optional: false, nullable: false, + }, + closeDuration: { + type: 'number', + optional: false, nullable: false, + }, displayOrder: { type: 'number', optional: false, nullable: false, @@ -63,14 +82,10 @@ export const meta = { }, user: { type: 'object', - optional: true, nullable: false, + optional: false, nullable: true, ref: 'UserLite', }, - reads: { - type: 'number', - optional: false, nullable: false, - }, - closeDuration: { + readCount: { type: 'number', optional: false, nullable: false, }, @@ -84,7 +99,7 @@ export const paramDef = { properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, offset: { type: 'integer', default: 0 }, - userId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id', nullable: true }, }, required: [], } as const; @@ -93,62 +108,28 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.announcementsRepository) - private announcementsRepository: AnnouncementsRepository, - - @Inject(DI.announcementReadsRepository) - private announcementReadsRepository: AnnouncementReadsRepository, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private userEntityService: UserEntityService, + private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.announcementsRepository.createQueryBuilder('announcement'); - if (ps.userId) { - query.where('"userId" = :userId', { userId: ps.userId }); - } else { - query.where('"userId" IS NULL'); - } - - query.orderBy({ - 'announcement."displayOrder"': 'DESC', - 'announcement."createdAt"': 'DESC', - }); - - const announcements = await query - .offset(ps.offset) - .limit(ps.limit) - .getMany(); - - const reads = new Map(); - - for (const announcement of announcements) { - reads.set(announcement, await this.announcementReadsRepository.countBy({ - announcementId: announcement.id, - })); - } - - const users = await this.usersRepository.findBy({ - id: In(announcements.map(a => a.userId).filter(id => id != null)), - }); - const packedUsers = await this.userEntityService.packMany(users, me, { - detail: false, - }); + const announcements = await this.announcementService.list(ps.userId ?? null, ps.limit, ps.offset, me); return announcements.map(announcement => ({ id: announcement.id, createdAt: announcement.createdAt.toISOString(), updatedAt: announcement.updatedAt?.toISOString() ?? null, + isActive: announcement.isActive, title: announcement.title, text: announcement.text, imageUrl: announcement.imageUrl, + icon: announcement.icon, + display: announcement.display, + forExistingUsers: announcement.forExistingUsers, + needConfirmationToRead: announcement.needConfirmationToRead, + closeDuration: announcement.closeDuration, displayOrder: announcement.displayOrder, userId: announcement.userId, - user: packedUsers.find(user => user.id === announcement.userId), - reads: reads.get(announcement)!, - closeDuration: announcement.closeDuration, + user: announcement.userInfo, + readCount: announcement.readCount, })); }); } 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 9290b13706..a7fe791c20 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts @@ -5,9 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/index.js'; +import { ApiError } from '@/server/api/error.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../../error.js'; +import type { AnnouncementsRepository } from '@/models/index.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; export const meta = { tags: ['admin'], @@ -31,11 +32,15 @@ export const paramDef = { title: { type: 'string', minLength: 1 }, text: { type: 'string', minLength: 1 }, imageUrl: { type: 'string', nullable: true, minLength: 0 }, - displayOrder: { type: 'number' }, - userId: { type: 'string', nullable: true, format: 'misskey:id' }, - closeDuration: { type: 'number', nullable: false }, + icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'] }, + display: { type: 'string', enum: ['normal', 'banner', 'dialog'] }, + forExistingUsers: { type: 'boolean' }, + needConfirmationToRead: { type: 'boolean' }, + closeDuration: { type: 'number', default: 0 }, + displayOrder: { type: 'number', default: 0 }, + isActive: { type: 'boolean' }, }, - required: ['id', 'title', 'text', 'imageUrl', 'closeDuration'], + required: ['id'], } as const; // eslint-disable-next-line import/no-default-export @@ -45,28 +50,14 @@ export default class extends Endpoint { @Inject(DI.announcementsRepository) private announcementsRepository: AnnouncementsRepository, - @Inject(DI.announcementReadsRepository) - private announcementsReadsRepository: AnnouncementReadsRepository, + private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { const announcement = await this.announcementsRepository.findOneBy({ id: ps.id }); if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); - if (announcement.userId && announcement.userId !== ps.userId) { - await this.announcementsReadsRepository.delete({ id: announcement.id, userId: announcement.userId }); - } - - await this.announcementsRepository.update(announcement.id, { - updatedAt: new Date(), - title: ps.title, - text: ps.text, - /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */ - imageUrl: ps.imageUrl || null, - displayOrder: ps.displayOrder, - userId: ps.userId ?? null, - closeDuration: ps.closeDuration, - }); + await this.announcementService.update(announcement.id, ps); }); } } diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts index 72a80f7700..372c002f14 100644 --- a/packages/backend/src/server/api/endpoints/announcements.ts +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -4,10 +4,12 @@ */ 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 { AnnouncementsRepository } from '@/models/index.js'; -import { Announcement, AnnouncementRead } from '@/models/index.js'; +import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/index.js'; export const meta = { tags: ['meta'], @@ -20,48 +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, - }, - isPrivate: { - type: 'boolean', - optional: false, nullable: true, - }, - closeDuration: { - type: 'number', - optional: false, nullable: false, - }, - }, + ref: 'Announcement', }, }, } as const; @@ -71,8 +32,7 @@ export const paramDef = { properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, offset: { type: 'integer', default: 0 }, - withUnreads: { type: 'boolean', default: false }, - privateOnly: { type: 'boolean', default: false }, + isActive: { type: 'boolean', default: true }, }, required: [], } as const; @@ -83,46 +43,15 @@ export default class extends Endpoint { constructor( @Inject(DI.announcementsRepository) private announcementsRepository: AnnouncementsRepository, + + @Inject(DI.announcementReadsRepository) + private announcementReadsRepository: AnnouncementReadsRepository, + + private queryService: QueryService, + private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.announcementsRepository.createQueryBuilder('announcement'); - if (me) { - query.leftJoinAndSelect(AnnouncementRead, 'reads', 'reads."announcementId" = announcement.id AND reads."userId" = :userId', { userId: me.id }); - query.select([ - 'announcement.*', - 'CASE WHEN reads.id IS NULL THEN FALSE ELSE TRUE END as "isRead"', - ]); - if (ps.privateOnly) { - query.where('announcement."userId" = :userId', { userId: me.id }); - } else { - query.where('announcement."userId" IS NULL'); - query.orWhere('announcement."userId" = :userId', { userId: me.id }); - } - } else { - query.select([ - 'announcement.*', - 'FALSE as "isRead"', - ]); - query.where('announcement."userId" IS NULL'); - } - - query.orderBy({ - '"isRead"': 'ASC', - 'announcement."displayOrder"': 'DESC', - 'announcement."createdAt"': 'DESC', - }); - - const announcements = await query - .offset(ps.offset) - .limit(ps.limit) - .getRawMany(); - - return (ps.withUnreads ? announcements.filter(i => !i.isRead) : announcements).map((a) => ({ - ...a, - createdAt: a.createdAt.toISOString(), - updatedAt: a.updatedAt?.toISOString() ?? null, - isPrivate: !!a.userId, - })); + return this.announcementService.getAnnouncements(me, ps.limit, ps.offset, ps.isActive); }); } } 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 61adb39a46..ed26658d4f 100644 --- a/packages/backend/src/server/api/endpoints/i/read-announcement.ts +++ b/packages/backend/src/server/api/endpoints/i/read-announcement.ts @@ -3,15 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull } from 'typeorm'; +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'], @@ -21,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; @@ -41,58 +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, - userId: IsNull(), - }, - { - id: ps.announcementId, - userId: me.id, - } - ] - }); - - 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.markAsRead(me, ps.announcementId); }); } } diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index 82ccd91c88..751a23de8d 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 cfcf7a8267..72f8034b61 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -162,6 +162,7 @@ describe('ユーザー', () => { hasUnreadChannel: user.hasUnreadChannel, hasUnreadNotification: user.hasUnreadNotification, hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest, + unreadAnnouncements: user.unreadAnnouncements, mutedWords: user.mutedWords, mutedInstances: user.mutedInstances, mutingNotificationTypes: user.mutingNotificationTypes, @@ -409,6 +410,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 0000000000..221eee204a --- /dev/null +++ b/packages/backend/test/unit/AnnouncementService.ts @@ -0,0 +1,196 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { ModuleMocker } from 'jest-mock'; +import { Test } from '@nestjs/testing'; +import { jest } from '@jest/globals'; +import type { Announcement, AnnouncementsRepository, AnnouncementReadsRepository, UsersRepository, User } from '@/models/index.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { IdService } from '@/core/IdService.js'; +import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; +import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; +import { genAid } from '@/misc/id/aid.js'; +import { GlobalEventService } from '@/core/GlobalEventService.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: [ + AnnouncementEntityService, + 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 3087b99f3b..767b2bbdfb 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 d81c8033b6..2038ef3455 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -7,7 +7,7 @@ import { computed, createApp, watch, markRaw, version as vueVersion, defineAsync import { common } from './common'; import { version, ui, lang, updateLocale } from '@/config'; import { i18n, updateI18n } from '@/i18n'; -import { confirm, alert, post, popup, toast, api } from '@/os'; +import { confirm, alert, post, popup, toast } from '@/os'; import { useStream } from '@/stream'; import * as sound from '@/scripts/sound'; import { $i, refreshAccount, login, updateAccount, signout } from '@/account'; @@ -83,6 +83,12 @@ export async function mainBoot() { } }); + for (const announcement of ($i.unreadAnnouncements ?? []).filter(x => x.display === 'dialog')) { + popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { + announcement, + }, {}, 'closed'); + } + if ($i.isDeleted) { alert({ type: 'warning', @@ -209,6 +215,20 @@ export async function mainBoot() { updateAccount(i); }); + main.on('announcementCreated', (ev) => { + const announcement = ev.announcement; + updateAccount({ + hasUnreadAnnouncement: true, + unreadAnnouncements: [...($i?.unreadAnnouncements ?? []), announcement], + }); + + if (announcement.display === 'dialog') { + popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { + announcement, + }, {}, 'closed'); + } + }); + main.on('readAllNotifications', () => { updateAccount({ hasUnreadNotification: false }); }); @@ -242,8 +262,25 @@ export async function mainBoot() { sound.play('antenna'); }); + stream.on('announcementCreated', (ev) => { + const announcement = ev.announcement; + updateAccount({ + hasUnreadAnnouncement: true, + unreadAnnouncements: [...($i?.unreadAnnouncements ?? []), announcement], + }); + + if (announcement.display === 'dialog') { + popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { + announcement, + }, {}, 'closed'); + } + }); + main.on('readAllAnnouncements', () => { - updateAccount({ hasUnreadAnnouncement: false }); + updateAccount({ + hasUnreadAnnouncement: false, + unreadAnnouncements: [], + }); }); // トークンが再生成されたとき @@ -251,11 +288,6 @@ export async function mainBoot() { main.on('myTokenRegenerated', () => { signout(); }); - - const unreadUserAnnouncementsList = await api('announcements', { privateOnly: true, withUnreads: true }); - if (unreadUserAnnouncementsList.length > 0) { - unreadUserAnnouncementsList.forEach((v) => popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementModal.vue')), { title: v.title, text: v.text, closeDuration: v.closeDuration, announcementId: v.id }, {}, 'closed')); - } } // shortcut diff --git a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts new file mode 100644 index 0000000000..42cfb90f7c --- /dev/null +++ b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import MkAnnouncementDialog from './MkAnnouncementDialog.vue'; +export const Default = { + render(args) { + return { + components: { + MkAnnouncementDialog, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + announcement: { + id: '1', + title: 'Title', + text: 'Text', + createdAt: new Date().toISOString(), + updatedAt: null, + icon: 'info', + imageUrl: null, + display: 'dialog', + needConfirmationToRead: false, + forYou: true, + }, + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue new file mode 100644 index 0000000000..0888ca3185 --- /dev/null +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -0,0 +1,122 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index c34ba4f83c..0ebdfc46d4 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -172,7 +172,6 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog'; const props = defineProps<{ note: misskey.entities.Note; - pinned?: boolean; }>(); const inChannel = inject('inChannel', null); diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 559133ef46..fcde45405e 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -37,7 +37,6 @@ import { userPage } from '@/filters/user'; defineProps<{ note: misskey.entities.Note; - pinned?: boolean; }>(); diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index 9648b7230a..e3c9e2bd5f 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -31,7 +31,6 @@ import { $i } from '@/account'; const props = defineProps<{ note: misskey.entities.Note; - pinned?: boolean; }>(); const showContent = $ref(false); diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index 9304f17177..42823b88f0 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -17,7 +17,12 @@ SPDX-License-Identifier: AGPL-3.0-only - + + + + + +

@@ -30,6 +35,7 @@ import { i18n } from '@/i18n'; const props = defineProps<{ modelValue: boolean | Ref; disabled?: boolean; + helpText?: string; }>(); const emit = defineEmits<{ @@ -41,10 +47,6 @@ const checked = toRefs(props).modelValue; const toggle = () => { if (props.disabled) return; emit('update:modelValue', !checked.value); - - if (!checked.value) { - - } }; @@ -140,4 +142,10 @@ const toggle = () => { display: none; } } + +.help { + margin-left: 0.5em; + font-size: 85%; + vertical-align: top; +} diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue new file mode 100644 index 0000000000..387d493065 --- /dev/null +++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue @@ -0,0 +1,169 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkUserAnnouncementModal.vue b/packages/frontend/src/components/MkUserAnnouncementModal.vue deleted file mode 100644 index 097028ea9d..0000000000 --- a/packages/frontend/src/components/MkUserAnnouncementModal.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - - diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index a713048f1f..fc19102dab 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only -
+
@@ -24,8 +24,18 @@ SPDX-License-Identifier: AGPL-3.0-only
-
-
+ + + + + + +
@@ -35,22 +45,42 @@ SPDX-License-Identifier: AGPL-3.0-only - - - + + + + + + + + + + + + + + + {{ i18n.ts._announcement.forExistingUsers }} + + + {{ i18n.ts._announcement.needConfirmationToRead }} + -

{{ i18n.t('nUsersRead', { n: announcement.reads }) }}

+ + + +

{{ i18n.t('nUsersRead', { n: announcement.readCount }) }}

{{ i18n.ts.specifyUser }}
- {{ i18n.ts.save }} - {{ i18n.ts.remove }} + {{ i18n.ts.save }} + {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }}) + {{ i18n.ts.delete }}
-
+ {{ i18n.ts.loadMore }}
@@ -59,28 +89,26 @@ SPDX-License-Identifier: AGPL-3.0-only - diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index 266479610f..a6e2a7411c 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -80,7 +80,7 @@ SPDX-License-Identifier: AGPL-3.0-only isSensitive {{ i18n.ts.localOnly }} - {{ i18n.ts.delete }} + {{ i18n.ts.delete }}
diff --git a/packages/frontend/src/pages/gallery/index.vue b/packages/frontend/src/pages/gallery/index.vue index e7c4ae88b5..af0ac2191b 100644 --- a/packages/frontend/src/pages/gallery/index.vue +++ b/packages/frontend/src/pages/gallery/index.vue @@ -86,7 +86,7 @@ const tagUsersPagination = $computed(() => ({ endpoint: 'hashtags/users' as const, limit: 30, params: { - tag: this.tag, + tag: props.tag, origin: 'combined', sort: '+follower', }, diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue index e6fd9418d4..d6ea1a5245 100644 --- a/packages/frontend/src/pages/user-info.vue +++ b/packages/frontend/src/pages/user-info.vue @@ -133,6 +133,31 @@ SPDX-License-Identifier: AGPL-3.0-only
+ + + +
+ {{ i18n.ts.new }} + + + + +
+
+ @@ -186,28 +211,30 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index f047474741..3518891811 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -8,6 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+
@@ -113,6 +114,7 @@ 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'; const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); +const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); const columnComponents = { main: XMainColumn, diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index 1c09df8394..d9cb81b5ef 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -8,7 +8,12 @@ SPDX-License-Identifier: AGPL-3.0-only - +
@@ -105,6 +110,7 @@ import { useScrollPositionManager } from '@/nirax'; const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue')); const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); +const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); const DESKTOP_THRESHOLD = 1100; const MOBILE_THRESHOLD = 500; diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 2c28e7f9a6..d1d60b67f0 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -30,9 +30,13 @@ type Announcement = { text: string; title: string; imageUrl: string | null; - isRead?: boolean; - isPrivate: boolean; + display: 'normal' | 'banner' | 'dialog'; + icon: 'info' | 'warning' | 'error' | 'success'; + needConfirmationToRead: boolean; closeDuration: number; + displayOrder: number; + forYou: boolean; + isRead?: boolean; }; // @public (undocumented) @@ -566,11 +570,9 @@ export type Endpoints = { }; 'announcements': { req: { + isActive?: boolean; limit?: number; - withUnreads?: boolean; - sinceId?: Announcement['id']; - untilId?: Announcement['id']; - privateOnly?: boolean; + offset?: number; }; res: Announcement[]; }; @@ -2476,6 +2478,7 @@ type MeDetailed = UserDetailed & { noCrawle: boolean; receiveAnnouncementEmail: boolean; usePasswordLessLogin: boolean; + unreadAnnouncements: Announcement[]; [other: string]: any; }; diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts index 42655b2cea..d75c47171a 100644 --- a/packages/misskey-js/src/api.types.ts +++ b/packages/misskey-js/src/api.types.ts @@ -77,7 +77,7 @@ export type Endpoints = { 'admin/relays/remove': { req: TODO; res: TODO; }; // announcements - 'announcements': { req: { limit?: number; withUnreads?: boolean; sinceId?: Announcement['id']; untilId?: Announcement['id']; privateOnly?: boolean; }; res: Announcement[]; }; + 'announcements': { req: { isActive?: boolean; limit?: number; offset?: number; }; res: Announcement[]; }; // antennas 'antennas/create': { req: TODO; res: Antenna; }; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 4564d77051..7611aa7444 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -106,6 +106,7 @@ export type MeDetailed = UserDetailed & { noCrawle: boolean; receiveAnnouncementEmail: boolean; usePasswordLessLogin: boolean; + unreadAnnouncements: Announcement[]; [other: string]: any; }; @@ -418,9 +419,13 @@ export type Announcement = { text: string; title: string; imageUrl: string | null; - isRead?: boolean; - isPrivate: boolean; + display: 'normal' | 'banner' | 'dialog'; + icon: 'info' | 'warning' | 'error' | 'success'; + needConfirmationToRead: boolean; closeDuration: number; + displayOrder: number; + forYou: boolean; + isRead?: boolean; }; export type Antenna = {