feat: refine announcement (misskey-dev#11497)
This commit is contained in:
		| @@ -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"`); | ||||
|     } | ||||
| } | ||||
| @@ -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"`); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										135
									
								
								packages/backend/src/core/AnnouncementService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								packages/backend/src/core/AnnouncementService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,135 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and other misskey contributors | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Brackets } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { User } from '@/models/entities/User.js'; | ||||
| import type { AnnouncementReadsRepository, AnnouncementsRepository, Announcement, AnnouncementRead } from '@/models/index.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { Packed } from '@/misc/json-schema.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
|  | ||||
| @Injectable() | ||||
| export class AnnouncementService { | ||||
| 	constructor( | ||||
| 		@Inject(DI.announcementsRepository) | ||||
| 		private announcementsRepository: AnnouncementsRepository, | ||||
|  | ||||
| 		@Inject(DI.announcementReadsRepository) | ||||
| 		private announcementReadsRepository: AnnouncementReadsRepository, | ||||
|  | ||||
| 		private idService: IdService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async getReads(userId: User['id']): Promise<AnnouncementRead[]> { | ||||
| 		return this.announcementReadsRepository.findBy({ | ||||
| 			userId: userId, | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async getUnreadAnnouncements(user: User): Promise<Announcement[]> { | ||||
| 		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<Announcement>): Promise<{ raw: Announcement; packed: Packed<'Announcement'> }> { | ||||
| 		const announcement = await this.announcementsRepository.insert({ | ||||
| 			id: this.idService.genId(), | ||||
| 			createdAt: new Date(), | ||||
| 			updatedAt: null, | ||||
| 			title: values.title, | ||||
| 			text: values.text, | ||||
| 			imageUrl: values.imageUrl, | ||||
| 			icon: values.icon, | ||||
| 			display: values.display, | ||||
| 			forExistingUsers: values.forExistingUsers, | ||||
| 			needConfirmationToRead: values.needConfirmationToRead, | ||||
| 			userId: values.userId, | ||||
| 		}).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0])); | ||||
|  | ||||
| 		const packed = (await this.packMany([announcement]))[0]; | ||||
|  | ||||
| 		if (values.userId) { | ||||
| 			this.globalEventService.publishMainStream(values.userId, 'announcementCreated', { | ||||
| 				announcement: packed, | ||||
| 			}); | ||||
| 		} else { | ||||
| 			this.globalEventService.publishBroadcastStream('announcementCreated', { | ||||
| 				announcement: packed, | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		return { | ||||
| 			raw: announcement, | ||||
| 			packed: packed, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async read(user: User, announcementId: Announcement['id']): Promise<void> { | ||||
| 		try { | ||||
| 			await this.announcementReadsRepository.insert({ | ||||
| 				id: this.idService.genId(), | ||||
| 				createdAt: new Date(), | ||||
| 				announcementId: announcementId, | ||||
| 				userId: user.id, | ||||
| 			}); | ||||
| 		} catch (e) { | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		if ((await this.getUnreadAnnouncements(user)).length === 0) { | ||||
| 			this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements'); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async packMany( | ||||
| 		announcements: Announcement[], | ||||
| 		me?: { id: User['id'] } | null | undefined, | ||||
| 		options?: { | ||||
| 			reads?: AnnouncementRead[]; | ||||
| 		}, | ||||
| 	): Promise<Packed<'Announcement'>[]> { | ||||
| 		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), | ||||
| 		})); | ||||
| 	} | ||||
| } | ||||
| @@ -7,6 +7,7 @@ import { Module } from '@nestjs/common'; | ||||
| import { AccountMoveService } from './AccountMoveService.js'; | ||||
| import { AccountUpdateService } from './AccountUpdateService.js'; | ||||
| import { AiService } from './AiService.js'; | ||||
| import { AnnouncementService } from './AnnouncementService.js'; | ||||
| import { AntennaService } from './AntennaService.js'; | ||||
| import { AppLockService } from './AppLockService.js'; | ||||
| import { AchievementService } from './AchievementService.js'; | ||||
| @@ -130,6 +131,7 @@ const $LoggerService: Provider = { provide: 'LoggerService', useExisting: Logger | ||||
| const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService }; | ||||
| const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService }; | ||||
| const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; | ||||
| const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExisting: AnnouncementService }; | ||||
| const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; | ||||
| const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; | ||||
| const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; | ||||
| @@ -257,6 +259,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		AccountMoveService, | ||||
| 		AccountUpdateService, | ||||
| 		AiService, | ||||
| 		AnnouncementService, | ||||
| 		AntennaService, | ||||
| 		AppLockService, | ||||
| 		AchievementService, | ||||
| @@ -377,6 +380,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		$AccountMoveService, | ||||
| 		$AccountUpdateService, | ||||
| 		$AiService, | ||||
| 		$AnnouncementService, | ||||
| 		$AntennaService, | ||||
| 		$AppLockService, | ||||
| 		$AchievementService, | ||||
| @@ -498,6 +502,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		AccountMoveService, | ||||
| 		AccountUpdateService, | ||||
| 		AiService, | ||||
| 		AnnouncementService, | ||||
| 		AntennaService, | ||||
| 		AppLockService, | ||||
| 		AchievementService, | ||||
| @@ -617,6 +622,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting | ||||
| 		$AccountMoveService, | ||||
| 		$AccountUpdateService, | ||||
| 		$AiService, | ||||
| 		$AnnouncementService, | ||||
| 		$AntennaService, | ||||
| 		$AppLockService, | ||||
| 		$AchievementService, | ||||
|   | ||||
| @@ -4,7 +4,6 @@ | ||||
|  */ | ||||
|  | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { In, Not } from 'typeorm'; | ||||
| import * as Redis from 'ioredis'; | ||||
| import _Ajv from 'ajv'; | ||||
| import { ModuleRef } from '@nestjs/core'; | ||||
| @@ -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,19 +243,6 @@ export class UserEntityService implements OnModuleInit { | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async getHasUnreadAnnouncement(userId: User['id']): Promise<boolean> { | ||||
| 		const reads = await this.announcementReadsRepository.findBy({ | ||||
| 			userId: userId, | ||||
| 		}); | ||||
|  | ||||
| 		const count = await this.announcementsRepository.countBy(reads.length > 0 ? { | ||||
| 			id: Not(In(reads.map(read => read.announcementId))), | ||||
| 		} : {}); | ||||
|  | ||||
| 		return count > 0; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async getHasUnreadAntenna(userId: User['id']): Promise<boolean> { | ||||
| 		/* | ||||
| @@ -385,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; | ||||
|  | ||||
| @@ -496,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), | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -3,8 +3,9 @@ | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { Entity, Index, Column, PrimaryColumn } from 'typeorm'; | ||||
| import { Entity, Index, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; | ||||
| import { id } from '../id.js'; | ||||
| import { User } from './User.js'; | ||||
|  | ||||
| @Entity() | ||||
| export class Announcement { | ||||
| @@ -38,6 +39,52 @@ export class Announcement { | ||||
| 	}) | ||||
| 	public imageUrl: string | null; | ||||
|  | ||||
| 	// info, warning, error, success | ||||
| 	@Column('varchar', { | ||||
| 		length: 256, nullable: false, | ||||
| 		default: 'info', | ||||
| 	}) | ||||
| 	public icon: string; | ||||
|  | ||||
| 	// normal ... お知らせページ掲載 | ||||
| 	// banner ... お知らせページ掲載 + バナー表示 | ||||
| 	// dialog ... お知らせページ掲載 + ダイアログ表示 | ||||
| 	@Column('varchar', { | ||||
| 		length: 256, nullable: false, | ||||
| 		default: 'normal', | ||||
| 	}) | ||||
| 	public display: string; | ||||
|  | ||||
| 	@Column('boolean', { | ||||
| 		default: false, | ||||
| 	}) | ||||
| 	public needConfirmationToRead: boolean; | ||||
|  | ||||
| 	@Index() | ||||
| 	@Column('boolean', { | ||||
| 		default: true, | ||||
| 	}) | ||||
| 	public isActive: boolean; | ||||
|  | ||||
| 	@Index() | ||||
| 	@Column('boolean', { | ||||
| 		default: false, | ||||
| 	}) | ||||
| 	public forExistingUsers: boolean; | ||||
|  | ||||
| 	@Index() | ||||
| 	@Column({ | ||||
| 		...id(), | ||||
| 		nullable: true, | ||||
| 	}) | ||||
| 	public userId: User['id'] | null; | ||||
|  | ||||
| 	@ManyToOne(type => User, { | ||||
| 		onDelete: 'CASCADE', | ||||
| 	}) | ||||
| 	@JoinColumn() | ||||
| 	public user: User | null; | ||||
|  | ||||
| 	constructor(data: Partial<Announcement>) { | ||||
| 		if (data == null) return; | ||||
|  | ||||
|   | ||||
							
								
								
									
										58
									
								
								packages/backend/src/models/json-schema/announcement.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								packages/backend/src/models/json-schema/announcement.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and other misskey contributors | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| export const packedAnnouncementSchema = { | ||||
| 	type: 'object', | ||||
| 	properties: { | ||||
| 		id: { | ||||
| 			type: 'string', | ||||
| 			optional: false, nullable: false, | ||||
| 			format: 'id', | ||||
| 			example: 'xxxxxxxxxx', | ||||
| 		}, | ||||
| 		createdAt: { | ||||
| 			type: 'string', | ||||
| 			optional: false, nullable: false, | ||||
| 			format: 'date-time', | ||||
| 		}, | ||||
| 		updatedAt: { | ||||
| 			type: 'string', | ||||
| 			optional: false, nullable: true, | ||||
| 			format: 'date-time', | ||||
| 		}, | ||||
| 		text: { | ||||
| 			type: 'string', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 		title: { | ||||
| 			type: 'string', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 		imageUrl: { | ||||
| 			type: 'string', | ||||
| 			optional: false, nullable: true, | ||||
| 		}, | ||||
| 		icon: { | ||||
| 			type: 'string', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 		display: { | ||||
| 			type: 'string', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 		forYou: { | ||||
| 			type: 'boolean', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 		needConfirmationToRead: { | ||||
| 			type: 'boolean', | ||||
| 			optional: false, nullable: false, | ||||
| 		}, | ||||
| 		isRead: { | ||||
| 			type: 'boolean', | ||||
| 			optional: true, nullable: false, | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
| @@ -3,11 +3,9 @@ | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import type { AnnouncementsRepository } from '@/models/index.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { AnnouncementService } from '@/core/AnnouncementService.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['admin'], | ||||
| @@ -57,6 +55,11 @@ export const paramDef = { | ||||
| 		title: { type: 'string', minLength: 1 }, | ||||
| 		text: { type: 'string', minLength: 1 }, | ||||
| 		imageUrl: { type: 'string', nullable: true, minLength: 1 }, | ||||
| 		icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'], default: 'info' }, | ||||
| 		display: { type: 'string', enum: ['normal', 'banner', 'dialog'], default: 'normal' }, | ||||
| 		forExistingUsers: { type: 'boolean', default: false }, | ||||
| 		needConfirmationToRead: { type: 'boolean', default: false }, | ||||
| 		userId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, | ||||
| 	}, | ||||
| 	required: ['title', 'text', 'imageUrl'], | ||||
| } as const; | ||||
| @@ -65,22 +68,23 @@ export const paramDef = { | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.announcementsRepository) | ||||
| 		private announcementsRepository: AnnouncementsRepository, | ||||
|  | ||||
| 		private idService: IdService, | ||||
| 		private announcementService: AnnouncementService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const announcement = await this.announcementsRepository.insert({ | ||||
| 				id: this.idService.genId(), | ||||
| 			const { raw, packed } = await this.announcementService.create({ | ||||
| 				createdAt: new Date(), | ||||
| 				updatedAt: null, | ||||
| 				title: ps.title, | ||||
| 				text: ps.text, | ||||
| 				imageUrl: ps.imageUrl, | ||||
| 			}).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0])); | ||||
| 				icon: ps.icon, | ||||
| 				display: ps.display, | ||||
| 				forExistingUsers: ps.forExistingUsers, | ||||
| 				needConfirmationToRead: ps.needConfirmationToRead, | ||||
| 				userId: ps.userId, | ||||
| 			}); | ||||
|  | ||||
| 			return Object.assign({}, announcement, { createdAt: announcement.createdAt.toISOString(), updatedAt: null }); | ||||
| 			return packed; | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -66,6 +66,7 @@ export const paramDef = { | ||||
| 		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, | ||||
| 		sinceId: { type: 'string', format: 'misskey:id' }, | ||||
| 		untilId: { type: 'string', format: 'misskey:id' }, | ||||
| 		userId: { type: 'string', format: 'misskey:id', nullable: true }, | ||||
| 	}, | ||||
| 	required: [], | ||||
| } as const; | ||||
| @@ -84,6 +85,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); | ||||
| 			if (ps.userId) { | ||||
| 				query.andWhere('announcement.userId = :userId', { userId: ps.userId }); | ||||
| 			} else { | ||||
| 				query.andWhere('announcement.userId IS NULL'); | ||||
| 			} | ||||
|  | ||||
| 			const announcements = await query.limit(ps.limit).getMany(); | ||||
|  | ||||
| @@ -102,6 +108,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 				title: announcement.title, | ||||
| 				text: announcement.text, | ||||
| 				imageUrl: announcement.imageUrl, | ||||
| 				icon: announcement.icon, | ||||
| 				display: announcement.display, | ||||
| 				isActive: announcement.isActive, | ||||
| 				forExistingUsers: announcement.forExistingUsers, | ||||
| 				needConfirmationToRead: announcement.needConfirmationToRead, | ||||
| 				userId: announcement.userId, | ||||
| 				reads: reads.get(announcement)!, | ||||
| 			})); | ||||
| 		}); | ||||
|   | ||||
| @@ -31,8 +31,13 @@ export const paramDef = { | ||||
| 		title: { type: 'string', minLength: 1 }, | ||||
| 		text: { type: 'string', minLength: 1 }, | ||||
| 		imageUrl: { type: 'string', nullable: true, minLength: 0 }, | ||||
| 		icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'] }, | ||||
| 		display: { type: 'string', enum: ['normal', 'banner', 'dialog'] }, | ||||
| 		forExistingUsers: { type: 'boolean' }, | ||||
| 		needConfirmationToRead: { type: 'boolean' }, | ||||
| 		isActive: { type: 'boolean' }, | ||||
| 	}, | ||||
| 	required: ['id', 'title', 'text', 'imageUrl'], | ||||
| 	required: ['id'], | ||||
| } as const; | ||||
|  | ||||
| // eslint-disable-next-line import/no-default-export | ||||
| @@ -53,6 +58,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 				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, | ||||
| 			}); | ||||
| 		}); | ||||
| 	} | ||||
|   | ||||
| @@ -4,8 +4,10 @@ | ||||
|  */ | ||||
|  | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Brackets } from 'typeorm'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { QueryService } from '@/core/QueryService.js'; | ||||
| import { AnnouncementService } from '@/core/AnnouncementService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/index.js'; | ||||
|  | ||||
| @@ -20,40 +22,7 @@ export const meta = { | ||||
| 		items: { | ||||
| 			type: 'object', | ||||
| 			optional: false, nullable: false, | ||||
| 			properties: { | ||||
| 				id: { | ||||
| 					type: 'string', | ||||
| 					optional: false, nullable: false, | ||||
| 					format: 'id', | ||||
| 					example: 'xxxxxxxxxx', | ||||
| 				}, | ||||
| 				createdAt: { | ||||
| 					type: 'string', | ||||
| 					optional: false, nullable: false, | ||||
| 					format: 'date-time', | ||||
| 				}, | ||||
| 				updatedAt: { | ||||
| 					type: 'string', | ||||
| 					optional: false, nullable: true, | ||||
| 					format: 'date-time', | ||||
| 				}, | ||||
| 				text: { | ||||
| 					type: 'string', | ||||
| 					optional: false, nullable: false, | ||||
| 				}, | ||||
| 				title: { | ||||
| 					type: 'string', | ||||
| 					optional: false, nullable: false, | ||||
| 				}, | ||||
| 				imageUrl: { | ||||
| 					type: 'string', | ||||
| 					optional: false, nullable: true, | ||||
| 				}, | ||||
| 				isRead: { | ||||
| 					type: 'boolean', | ||||
| 					optional: true, nullable: false, | ||||
| 				}, | ||||
| 			}, | ||||
| 			ref: 'Announcement', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
| @@ -62,9 +31,9 @@ export const paramDef = { | ||||
| 	type: 'object', | ||||
| 	properties: { | ||||
| 		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, | ||||
| 		withUnreads: { type: 'boolean', default: false }, | ||||
| 		sinceId: { type: 'string', format: 'misskey:id' }, | ||||
| 		untilId: { type: 'string', format: 'misskey:id' }, | ||||
| 		isActive: { type: 'boolean', default: true }, | ||||
| 	}, | ||||
| 	required: [], | ||||
| } as const; | ||||
| @@ -80,27 +49,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 		private announcementReadsRepository: AnnouncementReadsRepository, | ||||
|  | ||||
| 		private queryService: QueryService, | ||||
| 		private announcementService: AnnouncementService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId); | ||||
| 			const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId) | ||||
| 				.where('announcement.isActive = :isActive', { isActive: ps.isActive }) | ||||
| 				.andWhere(new Brackets(qb => { | ||||
| 					if (me) qb.orWhere('announcement.userId = :meId', { meId: me.id }); | ||||
| 					qb.orWhere('announcement.userId IS NULL'); | ||||
| 				})); | ||||
|  | ||||
| 			const announcements = await query.limit(ps.limit).getMany(); | ||||
|  | ||||
| 			if (me) { | ||||
| 				const reads = (await this.announcementReadsRepository.findBy({ | ||||
| 					userId: me.id, | ||||
| 				})).map(x => x.announcementId); | ||||
|  | ||||
| 				for (const announcement of announcements) { | ||||
| 					(announcement as any).isRead = reads.includes(announcement.id); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			return (ps.withUnreads ? announcements.filter((a: any) => !a.isRead) : announcements).map((a) => ({ | ||||
| 				...a, | ||||
| 				createdAt: a.createdAt.toISOString(), | ||||
| 				updatedAt: a.updatedAt?.toISOString() ?? null, | ||||
| 			})); | ||||
| 			return this.announcementService.packMany(announcements, me); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -3,14 +3,9 @@ | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { Endpoint } from '@/server/api/endpoint-base.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/index.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { UserEntityService } from '@/core/entities/UserEntityService.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { ApiError } from '../../error.js'; | ||||
| import { AnnouncementService } from '@/core/AnnouncementService.js'; | ||||
|  | ||||
| export const meta = { | ||||
| 	tags: ['account'], | ||||
| @@ -20,11 +15,6 @@ export const meta = { | ||||
| 	kind: 'write:account', | ||||
|  | ||||
| 	errors: { | ||||
| 		noSuchAnnouncement: { | ||||
| 			message: 'No such announcement.', | ||||
| 			code: 'NO_SUCH_ANNOUNCEMENT', | ||||
| 			id: '184663db-df88-4bc2-8b52-fb85f0681939', | ||||
| 		}, | ||||
| 	}, | ||||
| } as const; | ||||
|  | ||||
| @@ -40,47 +30,10 @@ export const paramDef = { | ||||
| @Injectable() | ||||
| export default class extends Endpoint<typeof meta, typeof paramDef> { | ||||
| 	constructor( | ||||
| 		@Inject(DI.announcementsRepository) | ||||
| 		private announcementsRepository: AnnouncementsRepository, | ||||
|  | ||||
| 		@Inject(DI.announcementReadsRepository) | ||||
| 		private announcementReadsRepository: AnnouncementReadsRepository, | ||||
|  | ||||
| 		private userEntityService: UserEntityService, | ||||
| 		private idService: IdService, | ||||
| 		private globalEventService: GlobalEventService, | ||||
| 		private announcementService: AnnouncementService, | ||||
| 	) { | ||||
| 		super(meta, paramDef, async (ps, me) => { | ||||
| 			// Check if announcement exists | ||||
| 			const announcementExist = await this.announcementsRepository.exist({ where: { id: ps.announcementId } }); | ||||
|  | ||||
| 			if (!announcementExist) { | ||||
| 				throw new ApiError(meta.errors.noSuchAnnouncement); | ||||
| 			} | ||||
|  | ||||
| 			// Check if already read | ||||
| 			const alreadyRead = await this.announcementReadsRepository.exist({ | ||||
| 				where: { | ||||
| 					announcementId: ps.announcementId, | ||||
| 					userId: me.id, | ||||
| 				}, | ||||
| 			}); | ||||
|  | ||||
| 			if (alreadyRead) { | ||||
| 				return; | ||||
| 			} | ||||
|  | ||||
| 			// Create read | ||||
| 			await this.announcementReadsRepository.insert({ | ||||
| 				id: this.idService.genId(), | ||||
| 				createdAt: new Date(), | ||||
| 				announcementId: ps.announcementId, | ||||
| 				userId: me.id, | ||||
| 			}); | ||||
|  | ||||
| 			if (!await this.userEntityService.getHasUnreadAnnouncement(me.id)) { | ||||
| 				this.globalEventService.publishMainStream(me.id, 'readAllAnnouncements'); | ||||
| 			} | ||||
| 			await this.announcementService.read(me, ps.announcementId); | ||||
| 		}); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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, []); | ||||
|   | ||||
							
								
								
									
										194
									
								
								packages/backend/test/unit/AnnouncementService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										194
									
								
								packages/backend/test/unit/AnnouncementService.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,194 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and other misskey contributors | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| process.env.NODE_ENV = 'test'; | ||||
|  | ||||
| import { jest } from '@jest/globals'; | ||||
| import { ModuleMocker } from 'jest-mock'; | ||||
| import { Test } from '@nestjs/testing'; | ||||
| import { GlobalModule } from '@/GlobalModule.js'; | ||||
| import { AnnouncementService } from '@/core/AnnouncementService.js'; | ||||
| import type { Announcement, AnnouncementsRepository, AnnouncementReadsRepository, UsersRepository, User } from '@/models/index.js'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import { genAid } from '@/misc/id/aid.js'; | ||||
| import { CacheService } from '@/core/CacheService.js'; | ||||
| import { IdService } from '@/core/IdService.js'; | ||||
| import { GlobalEventService } from '@/core/GlobalEventService.js'; | ||||
| import { secureRndstr } from '@/misc/secure-rndstr.js'; | ||||
| import type { TestingModule } from '@nestjs/testing'; | ||||
| import type { MockFunctionMetadata } from 'jest-mock'; | ||||
|  | ||||
| const moduleMocker = new ModuleMocker(global); | ||||
|  | ||||
| describe('AnnouncementService', () => { | ||||
| 	let app: TestingModule; | ||||
| 	let announcementService: AnnouncementService; | ||||
| 	let usersRepository: UsersRepository; | ||||
| 	let announcementsRepository: AnnouncementsRepository; | ||||
| 	let announcementReadsRepository: AnnouncementReadsRepository; | ||||
| 	let globalEventService: jest.Mocked<GlobalEventService>; | ||||
|  | ||||
| 	function createUser(data: Partial<User> = {}) { | ||||
| 		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<Announcement> = {}) { | ||||
| 		return announcementsRepository.insert({ | ||||
| 			id: genAid(new Date()), | ||||
| 			createdAt: new Date(), | ||||
| 			updatedAt: null, | ||||
| 			title: 'Title', | ||||
| 			text: 'Text', | ||||
| 			...data, | ||||
| 		}) | ||||
| 			.then(x => announcementsRepository.findOneByOrFail(x.identifiers[0])); | ||||
| 	} | ||||
|  | ||||
| 	beforeEach(async () => { | ||||
| 		app = await Test.createTestingModule({ | ||||
| 			imports: [ | ||||
| 				GlobalModule, | ||||
| 			], | ||||
| 			providers: [ | ||||
| 				AnnouncementService, | ||||
| 				CacheService, | ||||
| 				IdService, | ||||
| 			], | ||||
| 		}) | ||||
| 			.useMocker((token) => { | ||||
| 				if (token === GlobalEventService) { | ||||
| 					return { | ||||
| 						publishMainStream: jest.fn(), | ||||
| 						publishBroadcastStream: jest.fn(), | ||||
| 					}; | ||||
| 				} | ||||
| 				if (typeof token === 'function') { | ||||
| 					const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata<any, any>; | ||||
| 					const Mock = moduleMocker.generateFromMetadata(mockMetadata); | ||||
| 					return new Mock(); | ||||
| 				} | ||||
| 			}) | ||||
| 			.compile(); | ||||
|  | ||||
| 		app.enableShutdownHooks(); | ||||
|  | ||||
| 		announcementService = app.get<AnnouncementService>(AnnouncementService); | ||||
| 		usersRepository = app.get<UsersRepository>(DI.usersRepository); | ||||
| 		announcementsRepository = app.get<AnnouncementsRepository>(DI.announcementsRepository); | ||||
| 		announcementReadsRepository = app.get<AnnouncementReadsRepository>(DI.announcementReadsRepository); | ||||
| 		globalEventService = app.get<GlobalEventService>(GlobalEventService) as jest.Mocked<GlobalEventService>; | ||||
| 	}); | ||||
|  | ||||
| 	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 | ||||
| 	}); | ||||
| }); | ||||
|  | ||||
| @@ -96,7 +96,6 @@ export async function removeAccount(idOrToken: Account['id']) { | ||||
|  | ||||
| function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<Account> { | ||||
| 	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<Account | { error: Record<string, any> }>((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); | ||||
|   | ||||
| @@ -83,6 +83,21 @@ export async function mainBoot() { | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		for (const announcement of ($i.unreadAnnouncements ?? []).filter(x => x.display === 'dialog')) { | ||||
| 			popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { | ||||
| 				announcement, | ||||
| 			}, {}, 'closed'); | ||||
| 		} | ||||
|  | ||||
| 		stream.on('announcementCreated', (ev) => { | ||||
| 			const announcement = ev.announcement; | ||||
| 			if (announcement.display === 'dialog') { | ||||
| 				popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { | ||||
| 					announcement, | ||||
| 				}, {}, 'closed'); | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		if ($i.isDeleted) { | ||||
| 			alert({ | ||||
| 				type: 'warning', | ||||
|   | ||||
| @@ -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: '<MkAnnouncementDialog v-bind="props" />', | ||||
| 		}; | ||||
| 	}, | ||||
| 	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<typeof MkAnnouncementDialog>; | ||||
							
								
								
									
										104
									
								
								packages/frontend/src/components/MkAnnouncementDialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								packages/frontend/src/components/MkAnnouncementDialog.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,104 @@ | ||||
| <!-- | ||||
| SPDX-FileCopyrightText: syuilo and other misskey contributors | ||||
| SPDX-License-Identifier: AGPL-3.0-only | ||||
| --> | ||||
|  | ||||
| <template> | ||||
| <MkModal ref="modal" :zPriority="'middle'" @closed="$emit('closed')" @click="onBgClick"> | ||||
| 	<div ref="rootEl" :class="$style.root"> | ||||
| 		<div :class="$style.header"> | ||||
| 			<span :class="$style.icon"> | ||||
| 				<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> | ||||
| 				<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i> | ||||
| 				<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> | ||||
| 				<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> | ||||
| 			</span> | ||||
| 			<span :class="$style.title">{{ announcement.title }}</span> | ||||
| 		</div> | ||||
| 		<div :class="$style.text"><Mfm :text="announcement.text"/></div> | ||||
| 		<MkButton primary full @click="ok">{{ i18n.ts.ok }}</MkButton> | ||||
| 	</div> | ||||
| </MkModal> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { onMounted, shallowRef } from 'vue'; | ||||
| import * as misskey from 'misskey-js'; | ||||
| import * as os from '@/os'; | ||||
| import MkModal from '@/components/MkModal.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { $i, updateAccount } from '@/account'; | ||||
|  | ||||
| const props = withDefaults(defineProps<{ | ||||
| 	announcement: misskey.entities.Announcement; | ||||
| }>(), { | ||||
| }); | ||||
|  | ||||
| const rootEl = shallowRef<HTMLDivElement>(); | ||||
| const modal = shallowRef<InstanceType<typeof MkModal>>(); | ||||
|  | ||||
| async function ok() { | ||||
| 	if (props.announcement.needConfirmationToRead) { | ||||
| 		const confirm = await os.confirm({ | ||||
| 			type: 'question', | ||||
| 			title: i18n.ts._announcement.readConfirmTitle, | ||||
| 			text: i18n.t('_announcement.readConfirmText', { title: props.announcement.title }), | ||||
| 		}); | ||||
| 		if (confirm.canceled) return; | ||||
| 	} | ||||
|  | ||||
| 	modal.value.close(); | ||||
| 	os.api('i/read-announcement', { announcementId: props.announcement.id }); | ||||
| 	updateAccount({ | ||||
| 		unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== props.announcement.id), | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function onBgClick() { | ||||
| 	rootEl.value.animate([{ | ||||
| 		offset: 0, | ||||
| 		transform: 'scale(1)', | ||||
| 	}, { | ||||
| 		offset: 0.5, | ||||
| 		transform: 'scale(1.1)', | ||||
| 	}, { | ||||
| 		offset: 1, | ||||
| 		transform: 'scale(1)', | ||||
| 	}], { | ||||
| 		duration: 100, | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .root { | ||||
| 	margin: auto; | ||||
| 	position: relative; | ||||
| 	padding: 32px; | ||||
| 	min-width: 320px; | ||||
| 	max-width: 480px; | ||||
| 	box-sizing: border-box; | ||||
| 	background: var(--panel); | ||||
| 	border-radius: var(--radius); | ||||
| } | ||||
|  | ||||
| .header { | ||||
| 	font-size: 120%; | ||||
| } | ||||
|  | ||||
| .icon { | ||||
| 	margin-right: 0.5em; | ||||
| } | ||||
|  | ||||
| .title { | ||||
| 	font-weight: bold; | ||||
| } | ||||
|  | ||||
| .text { | ||||
| 	margin: 1em 0; | ||||
| } | ||||
| </style> | ||||
| @@ -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); | ||||
|   | ||||
| @@ -37,7 +37,6 @@ import { userPage } from '@/filters/user'; | ||||
|  | ||||
| defineProps<{ | ||||
| 	note: misskey.entities.Note; | ||||
| 	pinned?: boolean; | ||||
| }>(); | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -31,7 +31,6 @@ import { $i } from '@/account'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	note: misskey.entities.Note; | ||||
| 	pinned?: boolean; | ||||
| }>(); | ||||
|  | ||||
| const showContent = $ref(false); | ||||
|   | ||||
| @@ -17,7 +17,12 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 	</span> | ||||
| 	<span :class="$style.body"> | ||||
| 		<!-- TODO: 無名slotの方は廃止 --> | ||||
| 		<span :class="$style.label" @click="toggle"><slot name="label"></slot><slot></slot></span> | ||||
| 		<span :class="$style.label"> | ||||
| 			<span @click="toggle"> | ||||
| 				<slot name="label"></slot><slot></slot> | ||||
| 			</span> | ||||
| 			<span v-if="helpText" v-tooltip:dialog="helpText" class="_button _help" :class="$style.help"><i class="ti ti-help-circle"></i></span> | ||||
| 		</span> | ||||
| 		<p :class="$style.caption"><slot name="caption"></slot></p> | ||||
| 	</span> | ||||
| </div> | ||||
| @@ -30,6 +35,7 @@ import { i18n } from '@/i18n'; | ||||
| const props = defineProps<{ | ||||
| 	modelValue: boolean | Ref<boolean>; | ||||
| 	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) { | ||||
|  | ||||
| 	} | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| @@ -140,4 +142,10 @@ const toggle = () => { | ||||
| 		display: none; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .help { | ||||
| 	margin-left: 0.5em; | ||||
| 	font-size: 85%; | ||||
| 	vertical-align: top; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -0,0 +1,145 @@ | ||||
| <!-- | ||||
| SPDX-FileCopyrightText: syuilo and other misskey contributors | ||||
| SPDX-License-Identifier: AGPL-3.0-only | ||||
| --> | ||||
|  | ||||
| <template> | ||||
| <MkModalWindow | ||||
| 	ref="dialog" | ||||
| 	:width="400" | ||||
| 	@close="dialog.close()" | ||||
| 	@closed="$emit('closed')" | ||||
| > | ||||
| 	<template v-if="announcement" #header>:{{ announcement.title }}:</template> | ||||
| 	<template v-else #header>New announcement</template> | ||||
|  | ||||
| 	<div> | ||||
| 		<MkSpacer :marginMin="20" :marginMax="28"> | ||||
| 			<div class="_gaps_m"> | ||||
| 				<MkInput v-model="title"> | ||||
| 					<template #label>{{ i18n.ts.title }}</template> | ||||
| 				</MkInput> | ||||
| 				<MkTextarea v-model="text"> | ||||
| 					<template #label>{{ i18n.ts.text }}</template> | ||||
| 				</MkTextarea> | ||||
| 				<MkRadios v-model="icon"> | ||||
| 					<template #label>{{ i18n.ts.icon }}</template> | ||||
| 					<option value="info"><i class="ti ti-info-circle"></i></option> | ||||
| 					<option value="warning"><i class="ti ti-alert-triangle" style="color: var(--warn);"></i></option> | ||||
| 					<option value="error"><i class="ti ti-circle-x" style="color: var(--error);"></i></option> | ||||
| 					<option value="success"><i class="ti ti-check" style="color: var(--success);"></i></option> | ||||
| 				</MkRadios> | ||||
| 				<MkRadios v-model="display"> | ||||
| 					<template #label>{{ i18n.ts.display }}</template> | ||||
| 					<option value="normal">{{ i18n.ts.normal }}</option> | ||||
| 					<option value="banner">{{ i18n.ts.banner }}</option> | ||||
| 					<option value="dialog">{{ i18n.ts.dialog }}</option> | ||||
| 				</MkRadios> | ||||
| 				<MkSwitch v-model="needConfirmationToRead"> | ||||
| 					{{ i18n.ts._announcement.needConfirmationToRead }} | ||||
| 					<template #caption>{{ i18n.ts._announcement.needConfirmationToReadDescription }}</template> | ||||
| 				</MkSwitch> | ||||
| 				<MkButton v-if="announcement" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> | ||||
| 			</div> | ||||
| 		</MkSpacer> | ||||
| 		<div :class="$style.footer"> | ||||
| 			<MkButton primary rounded style="margin: 0 auto;" @click="done"><i class="ti ti-check"></i> {{ props.announcement ? i18n.ts.update : i18n.ts.create }}</MkButton> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </MkModalWindow> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { } from 'vue'; | ||||
| import * as misskey from 'misskey-js'; | ||||
| import MkModalWindow from '@/components/MkModalWindow.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
| import * as os from '@/os'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import MkTextarea from '@/components/MkTextarea.vue'; | ||||
| import MkSwitch from '@/components/MkSwitch.vue'; | ||||
| import MkRadios from '@/components/MkRadios.vue'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	user: misskey.entities.User, | ||||
| 	announcement?: any, | ||||
| }>(); | ||||
|  | ||||
| let dialog = $ref(null); | ||||
| let title: string = $ref(props.announcement ? props.announcement.title : ''); | ||||
| let text: string = $ref(props.announcement ? props.announcement.text : ''); | ||||
| let icon: string = $ref(props.announcement ? props.announcement.icon : 'info'); | ||||
| let display: string = $ref(props.announcement ? props.announcement.display : 'dialog'); | ||||
| let needConfirmationToRead = $ref(props.announcement ? props.announcement.needConfirmationToRead : false); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void, | ||||
| 	(ev: 'closed'): void | ||||
| }>(); | ||||
|  | ||||
| async function done() { | ||||
| 	const params = { | ||||
| 		title: title, | ||||
| 		text: text, | ||||
| 		icon: icon, | ||||
| 		imageUrl: null, | ||||
| 		display: display, | ||||
| 		needConfirmationToRead: needConfirmationToRead, | ||||
| 		userId: props.user.id, | ||||
| 	}; | ||||
|  | ||||
| 	if (props.announcement) { | ||||
| 		await os.apiWithDialog('admin/announcements/update', { | ||||
| 			id: props.announcement.id, | ||||
| 			...params, | ||||
| 		}); | ||||
|  | ||||
| 		emit('done', { | ||||
| 			updated: { | ||||
| 				id: props.announcement.id, | ||||
| 				...params, | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		dialog.close(); | ||||
| 	} else { | ||||
| 		const created = await os.apiWithDialog('admin/announcements/create', params); | ||||
|  | ||||
| 		emit('done', { | ||||
| 			created: created, | ||||
| 		}); | ||||
|  | ||||
| 		dialog.close(); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| async function del() { | ||||
| 	const { canceled } = await os.confirm({ | ||||
| 		type: 'warning', | ||||
| 		text: i18n.t('removeAreYouSure', { x: title }), | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
|  | ||||
| 	os.api('admin/announcements/delete', { | ||||
| 		id: props.announcement.id, | ||||
| 	}).then(() => { | ||||
| 		emit('done', { | ||||
| 			deleted: true, | ||||
| 		}); | ||||
| 		dialog.close(); | ||||
| 	}); | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .footer { | ||||
| 	position: sticky; | ||||
| 	bottom: 0; | ||||
| 	left: 0; | ||||
| 	padding: 12px; | ||||
| 	border-top: solid 0.5px var(--divider); | ||||
| 	-webkit-backdrop-filter: var(--blur, blur(15px)); | ||||
| 	backdrop-filter: var(--blur, blur(15px)); | ||||
| } | ||||
| </style> | ||||
| @@ -7,9 +7,20 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| <MkStickyContainer> | ||||
| 	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<MkSpacer :contentMax="900"> | ||||
| 		<div class="_gaps_m"> | ||||
| 			<section v-for="announcement in announcements" class=""> | ||||
| 				<div class="_panel _gaps_m" style="padding: 24px;"> | ||||
| 		<div class="_gaps"> | ||||
| 			<MkInfo v-if="announcements.length > 5" warn>{{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }}</MkInfo> | ||||
|  | ||||
| 			<MkFolder v-for="announcement in announcements" :key="announcement.id ?? announcement._id" :defaultOpen="announcement.id == null"> | ||||
| 				<template #label>{{ announcement.title }}</template> | ||||
| 				<template #icon> | ||||
| 					<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> | ||||
| 					<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i> | ||||
| 					<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> | ||||
| 					<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> | ||||
| 				</template> | ||||
| 				<template #caption>{{ announcement.text }}</template> | ||||
|  | ||||
| 				<div class="_gaps_m"> | ||||
| 					<MkInput v-model="announcement.title"> | ||||
| 						<template #label>{{ i18n.ts.title }}</template> | ||||
| 					</MkInput> | ||||
| @@ -19,13 +30,33 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 					<MkInput v-model="announcement.imageUrl"> | ||||
| 						<template #label>{{ i18n.ts.imageUrl }}</template> | ||||
| 					</MkInput> | ||||
| 					<MkRadios v-model="announcement.icon"> | ||||
| 						<template #label>{{ i18n.ts.icon }}</template> | ||||
| 						<option value="info"><i class="ti ti-info-circle"></i></option> | ||||
| 						<option value="warning"><i class="ti ti-alert-triangle" style="color: var(--warn);"></i></option> | ||||
| 						<option value="error"><i class="ti ti-circle-x" style="color: var(--error);"></i></option> | ||||
| 						<option value="success"><i class="ti ti-check" style="color: var(--success);"></i></option> | ||||
| 					</MkRadios> | ||||
| 					<MkRadios v-model="announcement.display"> | ||||
| 						<template #label>{{ i18n.ts.display }}</template> | ||||
| 						<option value="normal">{{ i18n.ts.normal }}</option> | ||||
| 						<option value="banner">{{ i18n.ts.banner }}</option> | ||||
| 						<option value="dialog">{{ i18n.ts.dialog }}</option> | ||||
| 					</MkRadios> | ||||
| 					<MkSwitch v-model="announcement.forExistingUsers" :helpText="i18n.ts._announcement.forExistingUsersDescription"> | ||||
| 						{{ i18n.ts._announcement.forExistingUsers }} | ||||
| 					</MkSwitch> | ||||
| 					<MkSwitch v-model="announcement.needConfirmationToRead" :helpText="i18n.ts._announcement.needConfirmationToReadDescription"> | ||||
| 						{{ i18n.ts._announcement.needConfirmationToRead }} | ||||
| 					</MkSwitch> | ||||
| 					<p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p> | ||||
| 					<div class="buttons _buttons"> | ||||
| 						<MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> | ||||
| 						<MkButton class="button" inline danger @click="remove(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> | ||||
| 						<MkButton v-if="announcement.id != null" class="button" inline @click="archive(announcement)"><i class="ti ti-check"></i> {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }})</MkButton> | ||||
| 						<MkButton v-if="announcement.id != null" class="button" inline danger @click="del(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</section> | ||||
| 			</MkFolder> | ||||
| 		</div> | ||||
| 	</MkSpacer> | ||||
| </MkStickyContainer> | ||||
| @@ -37,9 +68,13 @@ import XHeader from './_header_.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
| import MkTextarea from '@/components/MkTextarea.vue'; | ||||
| import MkSwitch from '@/components/MkSwitch.vue'; | ||||
| import MkRadios from '@/components/MkRadios.vue'; | ||||
| import MkInfo from '@/components/MkInfo.vue'; | ||||
| import * as os from '@/os'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
| import MkFolder from '@/components/MkFolder.vue'; | ||||
|  | ||||
| let announcements: any[] = $ref([]); | ||||
|  | ||||
| @@ -49,17 +84,22 @@ os.api('admin/announcements/list').then(announcementResponse => { | ||||
|  | ||||
| function add() { | ||||
| 	announcements.unshift({ | ||||
| 		_id: Math.random().toString(36), | ||||
| 		id: null, | ||||
| 		title: '', | ||||
| 		title: 'New announcement', | ||||
| 		text: '', | ||||
| 		imageUrl: null, | ||||
| 		icon: 'info', | ||||
| 		display: 'normal', | ||||
| 		forExistingUsers: false, | ||||
| 		needConfirmationToRead: false, | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function remove(announcement) { | ||||
| function del(announcement) { | ||||
| 	os.confirm({ | ||||
| 		type: 'warning', | ||||
| 		text: i18n.t('removeAreYouSure', { x: announcement.title }), | ||||
| 		text: i18n.t('deleteAreYouSure', { x: announcement.title }), | ||||
| 	}).then(({ canceled }) => { | ||||
| 		if (canceled) return; | ||||
| 		announcements = announcements.filter(x => x !== announcement); | ||||
| @@ -67,32 +107,20 @@ function remove(announcement) { | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function save(announcement) { | ||||
| async function archive(announcement) { | ||||
| 	await os.apiWithDialog('admin/announcements/update', { | ||||
| 		...announcement, | ||||
| 		isActive: false, | ||||
| 	}); | ||||
| 	refresh(); | ||||
| } | ||||
|  | ||||
| async function save(announcement) { | ||||
| 	if (announcement.id == null) { | ||||
| 		os.api('admin/announcements/create', announcement).then(() => { | ||||
| 			os.alert({ | ||||
| 				type: 'success', | ||||
| 				text: i18n.ts.saved, | ||||
| 			}); | ||||
| 			refresh(); | ||||
| 		}).catch(err => { | ||||
| 			os.alert({ | ||||
| 				type: 'error', | ||||
| 				text: err, | ||||
| 			}); | ||||
| 		}); | ||||
| 		await os.apiWithDialog('admin/announcements/create', announcement); | ||||
| 		refresh(); | ||||
| 	} else { | ||||
| 		os.api('admin/announcements/update', announcement).then(() => { | ||||
| 			os.alert({ | ||||
| 				type: 'success', | ||||
| 				text: i18n.ts.saved, | ||||
| 			}); | ||||
| 		}).catch(err => { | ||||
| 			os.alert({ | ||||
| 				type: 'error', | ||||
| 				text: err, | ||||
| 			}); | ||||
| 		}); | ||||
| 		os.apiWithDialog('admin/announcements/update', announcement); | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -5,20 +5,36 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
|  | ||||
| <template> | ||||
| <MkStickyContainer> | ||||
| 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<MkSpacer :contentMax="800"> | ||||
| 		<MkPagination v-slot="{items}" :pagination="pagination" class="ruryvtyk _gaps_m"> | ||||
| 			<section v-for="(announcement, i) in items" :key="announcement.id" class="announcement _panel"> | ||||
| 				<div class="header"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div> | ||||
| 				<div class="content"> | ||||
| 					<Mfm :text="announcement.text"/> | ||||
| 					<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> | ||||
| 				</div> | ||||
| 				<div v-if="$i && !announcement.isRead" class="footer"> | ||||
| 					<MkButton primary @click="read(items, announcement, i)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton> | ||||
| 				</div> | ||||
| 			</section> | ||||
| 		</MkPagination> | ||||
| 		<div class="_gaps"> | ||||
| 			<MkInfo v-if="$i && $i.hasUnreadAnnouncement && tab === 'current'" warn>{{ i18n.ts.youHaveUnreadAnnouncements }}</MkInfo> | ||||
| 			<MkPagination ref="paginationEl" :key="tab" v-slot="{items}" :pagination="tab === 'current' ? paginationCurrent : paginationPast" class="_gaps"> | ||||
| 				<section v-for="announcement in items" :key="announcement.id" class="_panel" :class="$style.announcement"> | ||||
| 					<div v-if="announcement.forYou" :class="$style.forYou"><i class="ti ti-pin"></i> {{ i18n.ts.forYou }}</div> | ||||
| 					<div :class="$style.header"> | ||||
| 						<span v-if="$i && !announcement.isRead" style="margin-right: 0.5em;">🆕</span> | ||||
| 						<span style="margin-right: 0.5em;"> | ||||
| 							<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> | ||||
| 							<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i> | ||||
| 							<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> | ||||
| 							<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> | ||||
| 						</span> | ||||
| 						<span>{{ announcement.title }}</span> | ||||
| 					</div> | ||||
| 					<div :class="$style.content"> | ||||
| 						<Mfm :text="announcement.text"/> | ||||
| 						<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> | ||||
| 						<div style="opacity: 0.7; font-size: 85%;"> | ||||
| 							<MkTime :time="announcement.updatedAt ?? announcement.createdAt" mode="detail"/> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 					<div v-if="tab !== 'past' && $i && !announcement.isRead" :class="$style.footer"> | ||||
| 						<MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton> | ||||
| 					</div> | ||||
| 				</section> | ||||
| 			</MkPagination> | ||||
| 		</div> | ||||
| 	</MkSpacer> | ||||
| </MkStickyContainer> | ||||
| </template> | ||||
| @@ -27,28 +43,64 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| import { } from 'vue'; | ||||
| import MkPagination from '@/components/MkPagination.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInfo from '@/components/MkInfo.vue'; | ||||
| import * as os from '@/os'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
| import { $i } from '@/account'; | ||||
| import { $i, updateAccount } from '@/account'; | ||||
|  | ||||
| const pagination = { | ||||
| const paginationCurrent = { | ||||
| 	endpoint: 'announcements' as const, | ||||
| 	limit: 10, | ||||
| 	params: { | ||||
| 		isActive: true, | ||||
| 	}, | ||||
| }; | ||||
|  | ||||
| // TODO: これは実質的に親コンポーネントから子コンポーネントのプロパティを変更してるのでなんとかしたい | ||||
| function read(items, announcement, i) { | ||||
| 	items[i] = { | ||||
| 		...announcement, | ||||
| 		isRead: true, | ||||
| 	}; | ||||
| const paginationPast = { | ||||
| 	endpoint: 'announcements' as const, | ||||
| 	limit: 10, | ||||
| 	params: { | ||||
| 		isActive: false, | ||||
| 	}, | ||||
| }; | ||||
|  | ||||
| const paginationEl = ref<InstanceType<typeof MkPagination>>(); | ||||
|  | ||||
| const tab = ref('current'); | ||||
|  | ||||
| async function read(announcement) { | ||||
| 	if (announcement.needConfirmationToRead) { | ||||
| 		const confirm = await os.confirm({ | ||||
| 			type: 'question', | ||||
| 			title: i18n.ts._announcement.readConfirmTitle, | ||||
| 			text: i18n.t('_announcement.readConfirmText', { title: announcement.title }), | ||||
| 		}); | ||||
| 		if (confirm.canceled) return; | ||||
| 	} | ||||
|  | ||||
| 	if (!paginationEl.value) return; | ||||
| 	paginationEl.value.updateItem(announcement.id, a => { | ||||
| 		a.isRead = true; | ||||
| 		return a; | ||||
| 	}); | ||||
| 	os.api('i/read-announcement', { announcementId: announcement.id }); | ||||
| 	updateAccount({ | ||||
| 		unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== announcement.id), | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| const headerActions = $computed(() => []); | ||||
|  | ||||
| const headerTabs = $computed(() => []); | ||||
| const headerTabs = $computed(() => [{ | ||||
| 	key: 'current', | ||||
| 	title: i18n.ts.currentAnnouncements, | ||||
| 	icon: 'ti ti-flare', | ||||
| }, { | ||||
| 	key: 'past', | ||||
| 	title: i18n.ts.pastAnnouncements, | ||||
| 	icon: 'ti ti-point', | ||||
| }]); | ||||
|  | ||||
| definePageMetadata({ | ||||
| 	title: i18n.ts.announcements, | ||||
| @@ -56,27 +108,34 @@ definePageMetadata({ | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .ruryvtyk { | ||||
| 	> .announcement { | ||||
| 		padding: 16px; | ||||
| <style lang="scss" module> | ||||
| .announcement { | ||||
| 	padding: 16px; | ||||
| } | ||||
|  | ||||
| 		> .header { | ||||
| 			margin-bottom: 16px; | ||||
| 			font-weight: bold; | ||||
| 		} | ||||
| .forYou { | ||||
| 	display: flex; | ||||
| 	align-items: center; | ||||
| 	line-height: 24px; | ||||
| 	font-size: 90%; | ||||
| 	white-space: pre; | ||||
| 	color: #d28a3f; | ||||
| } | ||||
|  | ||||
| 		> .content { | ||||
| 			> img { | ||||
| 				display: block; | ||||
| 				max-height: 300px; | ||||
| 				max-width: 100%; | ||||
| 			} | ||||
| 		} | ||||
| .header { | ||||
| 	margin-bottom: 16px; | ||||
| 	font-weight: bold; | ||||
| } | ||||
|  | ||||
| 		> .footer { | ||||
| 			margin-top: 16px; | ||||
| 		} | ||||
| .content { | ||||
| 	> img { | ||||
| 		display: block; | ||||
| 		max-height: 300px; | ||||
| 		max-width: 100%; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .footer { | ||||
| 	margin-top: 16px; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -80,7 +80,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 				</MkFolder> | ||||
| 				<MkSwitch v-model="isSensitive">isSensitive</MkSwitch> | ||||
| 				<MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch> | ||||
| 				<MkButton danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> | ||||
| 				<MkButton v-if="emoji" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> | ||||
| 			</div> | ||||
| 		</MkSpacer> | ||||
| 		<div :class="$style.footer"> | ||||
|   | ||||
| @@ -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', | ||||
| 	}, | ||||
|   | ||||
| @@ -133,6 +133,31 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 					</div> | ||||
| 				</MkFolder> | ||||
|  | ||||
| 				<MkFolder v-if="user.host == null && iAmModerator"> | ||||
| 					<template #icon><i class="ti ti-speakerphone"></i></template> | ||||
| 					<template #label>{{ i18n.ts.announcements }}</template> | ||||
| 					<div class="_gaps"> | ||||
| 						<MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.new }}</MkButton> | ||||
|  | ||||
| 						<MkPagination :pagination="announcementsPagination"> | ||||
| 							<template #default="{ items }"> | ||||
| 								<div class="_gaps_s"> | ||||
| 									<div v-for="announcement in items" :key="announcement.id" v-panel :class="$style.announcementItem" @click="editAnnouncement(announcement)"> | ||||
| 										<span style="margin-right: 0.5em;"> | ||||
| 											<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> | ||||
| 											<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i> | ||||
| 											<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> | ||||
| 											<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> | ||||
| 										</span> | ||||
| 										<span>{{ announcement.title }}</span> | ||||
| 										<span v-if="announcement.reads > 0" style="margin-left: auto; opacity: 0.7;">{{ i18n.ts.messageRead }}</span> | ||||
| 									</div> | ||||
| 								</div> | ||||
| 							</template> | ||||
| 						</MkPagination> | ||||
| 					</div> | ||||
| 				</MkFolder> | ||||
|  | ||||
| 				<MkFolder> | ||||
| 					<template #icon><i class="ti ti-password"></i></template> | ||||
| 					<template #label>IP</template> | ||||
| @@ -186,7 +211,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { computed, watch } from 'vue'; | ||||
| import { computed, defineAsyncComponent, watch } from 'vue'; | ||||
| import * as misskey from 'misskey-js'; | ||||
| import MkChart from '@/components/MkChart.vue'; | ||||
| import MkObjectView from '@/components/MkObjectView.vue'; | ||||
| @@ -208,6 +233,7 @@ import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { iAmAdmin, iAmModerator, $i } from '@/account'; | ||||
| import MkRolePreview from '@/components/MkRolePreview.vue'; | ||||
| import MkPagination, { Paging } from '@/components/MkPagination.vue'; | ||||
|  | ||||
| const props = withDefaults(defineProps<{ | ||||
| 	userId: string; | ||||
| @@ -235,6 +261,13 @@ const filesPagination = { | ||||
| 		userId: props.userId, | ||||
| 	})), | ||||
| }; | ||||
| const announcementsPagination = { | ||||
| 	endpoint: 'admin/announcements/list' as const, | ||||
| 	limit: 10, | ||||
| 	params: computed(() => ({ | ||||
| 		userId: props.userId, | ||||
| 	})), | ||||
| }; | ||||
| let expandedRoles = $ref([]); | ||||
|  | ||||
| function createFetcher() { | ||||
| @@ -409,6 +442,19 @@ function toggleRoleItem(role) { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| function createAnnouncement() { | ||||
| 	os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), { | ||||
| 		user, | ||||
| 	}, {}, 'closed'); | ||||
| } | ||||
|  | ||||
| function editAnnouncement(announcement) { | ||||
| 	os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), { | ||||
| 		user, | ||||
| 		announcement, | ||||
| 	}, {}, 'closed'); | ||||
| } | ||||
|  | ||||
| watch(() => props.userId, () => { | ||||
| 	init = createFetcher(); | ||||
| }, { | ||||
| @@ -577,4 +623,11 @@ definePageMetadata(computed(() => ({ | ||||
| 	margin-left: 8px; | ||||
| 	align-self: center; | ||||
| } | ||||
|  | ||||
| .announcementItem { | ||||
| 	display: flex; | ||||
| 	padding: 8px 12px; | ||||
| 	border-radius: 6px; | ||||
| 	cursor: pointer; | ||||
| } | ||||
| </style> | ||||
|   | ||||
							
								
								
									
										80
									
								
								packages/frontend/src/ui/_common_/announcements.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								packages/frontend/src/ui/_common_/announcements.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,80 @@ | ||||
| <!-- | ||||
| SPDX-FileCopyrightText: syuilo and other misskey contributors | ||||
| SPDX-License-Identifier: AGPL-3.0-only | ||||
| --> | ||||
|  | ||||
| <template> | ||||
| <div :class="$style.root"> | ||||
| 	<MkA | ||||
| 		v-for="announcement in $i.unreadAnnouncements.filter(x => x.display === 'banner')" | ||||
| 		:key="announcement.id" | ||||
| 		:class="$style.item" | ||||
| 		to="/announcements" | ||||
| 	> | ||||
| 		<span :class="$style.icon"> | ||||
| 			<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> | ||||
| 			<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i> | ||||
| 			<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> | ||||
| 			<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> | ||||
| 		</span> | ||||
| 		<span :class="$style.title">{{ announcement.title }}</span> | ||||
| 		<span :class="$style.body">{{ announcement.text }}</span> | ||||
| 	</MkA> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { $i } from '@/account'; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .root { | ||||
| 	font-size: 15px; | ||||
| 	background: var(--panel); | ||||
| } | ||||
|  | ||||
| .item { | ||||
| 	--height: 24px; | ||||
| 	font-size: 0.85em; | ||||
|  | ||||
| 	display: flex; | ||||
| 	vertical-align: bottom; | ||||
| 	width: 100%; | ||||
| 	line-height: var(--height); | ||||
| 	height: var(--height); | ||||
| 	overflow: clip; | ||||
| 	contain: strict; | ||||
| 	background: var(--accent); | ||||
| 	color: var(--fgOnAccent); | ||||
|  | ||||
| 	@container (max-width: 1000px) { | ||||
| 		display: block; | ||||
| 		text-align: center; | ||||
|  | ||||
| 		> .body { | ||||
| 			display: none; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .icon { | ||||
| 	margin-left: 10px; | ||||
| } | ||||
|  | ||||
| .title { | ||||
| 	padding: 0 10px; | ||||
| 	font-weight: bold; | ||||
|  | ||||
| 	&:empty { | ||||
| 		display: none; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .body { | ||||
| 	min-width: 0; | ||||
| 	flex: 1; | ||||
| 	overflow: clip; | ||||
| 	white-space: nowrap; | ||||
| 	text-overflow: ellipsis; | ||||
| } | ||||
| </style> | ||||
| @@ -8,6 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 	<XSidebar v-if="!isMobile"/> | ||||
|  | ||||
| 	<div :class="$style.main"> | ||||
| 		<XAnnouncements v-if="$i" :class="$style.announcements"/> | ||||
| 		<XStatusBars/> | ||||
| 		<div ref="columnsEl" :class="[$style.sections, { [$style.center]: deckStore.reactiveState.columnAlign.value === 'center', [$style.snapScroll]: snapScroll }]" @contextmenu.self.prevent="onContextmenu" @wheel.self="onWheel"> | ||||
| 			<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> | ||||
| @@ -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, | ||||
|   | ||||
| @@ -8,7 +8,12 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 	<XSidebar v-if="!isMobile" :class="$style.sidebar"/> | ||||
|  | ||||
| 	<MkStickyContainer ref="contents" :class="$style.contents" style="container-type: inline-size;" @contextmenu.stop="onContextmenu"> | ||||
| 		<template #header><XStatusBars :class="$style.statusbars"/></template> | ||||
| 		<template #header> | ||||
| 			<div> | ||||
| 				<XAnnouncements v-if="$i" :class="$style.announcements"/> | ||||
| 				<XStatusBars :class="$style.statusbars"/> | ||||
| 			</div> | ||||
| 		</template> | ||||
| 		<RouterView/> | ||||
| 		<div :class="$style.spacer"></div> | ||||
| 	</MkStickyContainer> | ||||
| @@ -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; | ||||
|   | ||||
| @@ -30,6 +30,10 @@ type Announcement = { | ||||
|     text: string; | ||||
|     title: string; | ||||
|     imageUrl: string | null; | ||||
|     display: 'normal' | 'banner' | 'dialog'; | ||||
|     icon: 'info' | 'warning' | 'error' | 'success'; | ||||
|     needConfirmationToRead: boolean; | ||||
|     forYou: boolean; | ||||
|     isRead?: boolean; | ||||
| }; | ||||
|  | ||||
| @@ -2473,6 +2477,7 @@ type MeDetailed = UserDetailed & { | ||||
|     noCrawle: boolean; | ||||
|     receiveAnnouncementEmail: boolean; | ||||
|     usePasswordLessLogin: boolean; | ||||
|     unreadAnnouncements: Announcement[]; | ||||
|     [other: string]: any; | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -106,6 +106,7 @@ export type MeDetailed = UserDetailed & { | ||||
| 	noCrawle: boolean; | ||||
| 	receiveAnnouncementEmail: boolean; | ||||
| 	usePasswordLessLogin: boolean; | ||||
| 	unreadAnnouncements: Announcement[]; | ||||
| 	[other: string]: any; | ||||
| }; | ||||
|  | ||||
| @@ -418,6 +419,10 @@ export type Announcement = { | ||||
| 	text: string; | ||||
| 	title: string; | ||||
| 	imageUrl: string | null; | ||||
| 	display: 'normal' | 'banner' | 'dialog'; | ||||
| 	icon: 'info' | 'warning' | 'error' | 'success'; | ||||
| 	needConfirmationToRead: boolean; | ||||
| 	forYou: boolean; | ||||
| 	isRead?: boolean; | ||||
| }; | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 まっちゃとーにゅ
					まっちゃとーにゅ