diff --git a/locales/en-US.yml b/locales/en-US.yml index 49b0fde360..234ab73a69 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -251,6 +251,7 @@ noSuchUser: "User not found" lookup: "Lookup" announcements: "Announcements" imageUrl: "Image URL" +displayOrder: "Position" remove: "Delete" removed: "Successfully deleted" removeAreYouSure: "Are you sure that you want to remove \"{x}\"?" diff --git a/locales/index.d.ts b/locales/index.d.ts index e447caf55d..fc858aa288 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -254,6 +254,7 @@ export interface Locale { "lookup": string; "announcements": string; "imageUrl": string; + "displayOrder": string; "remove": string; "removed": string; "removeAreYouSure": string; @@ -1075,6 +1076,7 @@ export interface Locale { "additionalEmojiDictionary": string; "installed": string; "branding": string; + "dialogCloseDuration": string; "enableServerMachineStats": string; "enableIdenticonGeneration": string; "turnOffToImprovePerformance": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index d54d7eb208..ccb84da12c 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -251,6 +251,7 @@ noSuchUser: "ユーザーが見つかりません" lookup: "照会" announcements: "お知らせ" imageUrl: "画像URL" +displayOrder: "表示順" remove: "削除" removed: "削除しました" removeAreYouSure: "「{x}」を削除しますか?" @@ -1072,6 +1073,7 @@ goToMisskey: "Misskeyへ" additionalEmojiDictionary: "絵文字の追加辞書" installed: "インストール済み" branding: "ブランディング" +dialogCloseDuration: "ダイアログを閉じるまでの待機時間" enableServerMachineStats: "サーバーのマシン情報を公開する" enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする" turnOffToImprovePerformance: "オフにするとパフォーマンスが向上します。" diff --git a/packages/backend/migration/1688647797135-userannouncement.js b/packages/backend/migration/1688647797135-userannouncement.js new file mode 100644 index 0000000000..6b3ca6cd6b --- /dev/null +++ b/packages/backend/migration/1688647797135-userannouncement.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Userannouncement1688647797135 { + name = 'Userannouncement1688647797135' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "announcement" ADD COLUMN "userId" character varying(32)`); + await queryRunner.query(`ALTER TABLE "announcement" ADD COLUMN "closeDuration" integer NOT NULL DEFAULT 0`); + await queryRunner.query(`CREATE INDEX "IDX_fd25dfe3da37df1715f11ba6ec" ON "announcement" ("userId") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_fd25dfe3da37df1715f11ba6ec"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "userId"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "closeDuration"`); + } +} diff --git a/packages/backend/migration/1690463372775-announcement-display-order.js b/packages/backend/migration/1690463372775-announcement-display-order.js new file mode 100644 index 0000000000..81baa55897 --- /dev/null +++ b/packages/backend/migration/1690463372775-announcement-display-order.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AnnouncementDisplayOrder1690463372775 { + name = 'AnnouncementDisplayOrder1690463372775' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "announcement" ADD "displayOrder" integer NOT NULL DEFAULT '0'`); + await queryRunner.query(`CREATE INDEX "IDX_b64d293ca4bef21e91963054b0" ON "announcement" ("displayOrder") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_b64d293ca4bef21e91963054b0"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "displayOrder"`); + } +} diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts index 482aeee39f..e00bd2582c 100644 --- a/packages/backend/src/core/AnnouncementService.ts +++ b/packages/backend/src/core/AnnouncementService.ts @@ -4,14 +4,17 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { Brackets } from 'typeorm'; -import { DI } from '@/di-symbols.js'; +import { Brackets, In } from 'typeorm'; +import type { AnnouncementReadsRepository, AnnouncementsRepository, UsersRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; -import type { AnnouncementReadsRepository, AnnouncementsRepository, Announcement, AnnouncementRead } from '@/models/index.js'; +import { Announcement, AnnouncementRead } from '@/models/index.js'; +import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; import { bindThis } from '@/decorators.js'; -import { Packed } from '@/misc/json-schema.js'; -import { IdService } from '@/core/IdService.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 { @@ -22,63 +25,52 @@ export class AnnouncementService { @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 getReads(userId: User['id']): Promise { - return this.announcementReadsRepository.findBy({ - userId: userId, - }); - } + 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]), + ); - @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]; + const packed = await this.announcementEntityService.pack( + announcement, + null, + ); if (values.userId) { - this.globalEventService.publishMainStream(values.userId, 'announcementCreated', { - announcement: packed, - }); + this.globalEventService.publishMainStream( + values.userId, + 'announcementCreated', + { + announcement: packed, + }, + ); } else { this.globalEventService.publishBroadcastStream('announcementCreated', { announcement: packed, @@ -92,44 +84,271 @@ export class AnnouncementService { } @bindThis - public async read(user: User, announcementId: Announcement['id']): Promise { + 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: user.id, + userId: me.id, }); } catch (e) { return; } - if ((await this.getUnreadAnnouncements(user)).length === 0) { - this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements'); + if ((await this.countUnreadAnnouncements(me)) === 0) { + this.globalEventService.publishMainStream(me.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 51d4f9cfa9..23055d0fba 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -72,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'; @@ -198,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 }; @@ -324,6 +326,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApRequestChart, ChartManagementService, AbuseUserReportEntityService, + AnnouncementEntityService, AntennaEntityService, AppEntityService, AuthSessionEntityService, @@ -445,6 +448,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApRequestChart, $ChartManagementService, $AbuseUserReportEntityService, + $AnnouncementEntityService, $AntennaEntityService, $AppEntityService, $AuthSessionEntityService, @@ -566,6 +570,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApRequestChart, ChartManagementService, AbuseUserReportEntityService, + AnnouncementEntityService, AntennaEntityService, AppEntityService, AuthSessionEntityService, @@ -686,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/models/entities/Announcement.ts b/packages/backend/src/models/entities/Announcement.ts index 18c26faab0..7dcd0c1755 100644 --- a/packages/backend/src/models/entities/Announcement.ts +++ b/packages/backend/src/models/entities/Announcement.ts @@ -60,12 +60,26 @@ export class Announcement { }) public needConfirmationToRead: boolean; + @Column('integer', { + nullable: false, + default: 0, + }) + 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, diff --git a/packages/backend/src/models/json-schema/announcement.ts b/packages/backend/src/models/json-schema/announcement.ts index c7e24c7f29..e23620e992 100644 --- a/packages/backend/src/models/json-schema/announcement.ts +++ b/packages/backend/src/models/json-schema/announcement.ts @@ -50,6 +50,14 @@ export const packedAnnouncementSchema = { 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, 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 6c5520c2ef..c60d0d1889 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -45,6 +45,34 @@ 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, + }, + userId: { + type: 'string', + optional: false, nullable: true, + }, }, }, } as const; @@ -59,6 +87,8 @@ export const paramDef = { 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'], @@ -81,10 +111,26 @@ export default class extends Endpoint { display: ps.display, forExistingUsers: ps.forExistingUsers, needConfirmationToRead: ps.needConfirmationToRead, + closeDuration: ps.closeDuration, + displayOrder: ps.displayOrder, userId: ps.userId, }); - return packed; + 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 4da3f457f9..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,12 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; -import type { AnnouncementsRepository, AnnouncementReadsRepository } 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 { DI } from '@/di-symbols.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; export const meta = { tags: ['admin'], @@ -39,19 +36,56 @@ 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, }, - reads: { + 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, + }, + userId: { + type: 'string', + optional: false, nullable: true, + }, + user: { + type: 'object', + optional: false, nullable: true, + ref: 'UserLite', + }, + readCount: { type: 'number', optional: false, nullable: false, }, @@ -64,8 +98,7 @@ export const paramDef = { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, + offset: { type: 'integer', default: 0 }, userId: { type: 'string', format: 'misskey:id', nullable: true }, }, required: [], @@ -75,46 +108,28 @@ export const paramDef = { @Injectable() 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.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(); - - const reads = new Map(); - - for (const announcement of announcements) { - reads.set(announcement, await this.announcementReadsRepository.countBy({ - announcementId: announcement.id, - })); - } + 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, - isActive: announcement.isActive, forExistingUsers: announcement.forExistingUsers, needConfirmationToRead: announcement.needConfirmationToRead, + closeDuration: announcement.closeDuration, + displayOrder: announcement.displayOrder, userId: announcement.userId, - reads: reads.get(announcement)!, + 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 7efc7c0402..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 { 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'], @@ -35,6 +36,8 @@ export const paramDef = { 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'], @@ -46,24 +49,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.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, - display: ps.display, - icon: ps.icon, - forExistingUsers: ps.forExistingUsers, - needConfirmationToRead: ps.needConfirmationToRead, - isActive: ps.isActive, - }); + 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 070e6f0d77..372c002f14 100644 --- a/packages/backend/src/server/api/endpoints/announcements.ts +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -31,8 +31,7 @@ export const paramDef = { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, + offset: { type: 'integer', default: 0 }, isActive: { type: 'boolean', default: true }, }, required: [], @@ -52,16 +51,7 @@ export default class extends Endpoint { private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { - 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(); - - return this.announcementService.packMany(announcements, me); + 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 412532939c..ed26658d4f 100644 --- a/packages/backend/src/server/api/endpoints/i/read-announcement.ts +++ b/packages/backend/src/server/api/endpoints/i/read-announcement.ts @@ -33,7 +33,7 @@ export default class extends Endpoint { private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { - await this.announcementService.read(me, ps.announcementId); + await this.announcementService.markAsRead(me, ps.announcementId); }); } } diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts index c97e081ba5..221eee204a 100644 --- a/packages/backend/test/unit/AnnouncementService.ts +++ b/packages/backend/test/unit/AnnouncementService.ts @@ -5,18 +5,19 @@ 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 { jest } from '@jest/globals'; 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 { 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'; @@ -60,6 +61,7 @@ describe('AnnouncementService', () => { GlobalModule, ], providers: [ + AnnouncementEntityService, AnnouncementService, CacheService, IdService, diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 9ab1f6e14c..2038ef3455 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -89,15 +89,6 @@ export async function mainBoot() { }, {}, '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', @@ -224,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 }); }); @@ -257,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: [], + }); }); // トークンが再生成されたとき diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue index 8e11053813..0888ca3185 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.vue +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -13,16 +13,16 @@ SPDX-License-Identifier: AGPL-3.0-only - {{ announcement.title }} + -
- {{ i18n.ts.ok }} +
+ {{ i18n.ts.gotIt }} ({{ sec }}) @@ -94,11 +116,7 @@ onMounted(() => { margin-right: 0.5em; } -.title { - font-weight: bold; -} - -.text { +.content { margin: 1em 0; } diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue index ce41b3116d..387d493065 100644 --- a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue +++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue @@ -6,8 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index bb903af459..fc19102dab 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -8,7 +8,22 @@ SPDX-License-Identifier: AGPL-3.0-only
- {{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }} + + + + + + + +
+
@{{ user.username }}
+
+ {{ i18n.ts.selectUser }} + {{ i18n.ts.remove }} +
+
+
+
@@ -21,8 +36,8 @@ SPDX-License-Identifier: AGPL-3.0-only
- - + + @@ -49,40 +64,69 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._announcement.needConfirmationToRead }} -

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

+ + + + + + + +

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

+ + {{ i18n.ts.specifyUser }}
- {{ i18n.ts.save }} - {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }}) + {{ i18n.ts.save }} + {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }}) {{ i18n.ts.delete }}
+ {{ i18n.ts.loadMore }}
+ + diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index e08c13f159..8b54cb28d9 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only - {{ announcement.title }} +
@@ -40,17 +40,18 @@ SPDX-License-Identifier: AGPL-3.0-only