diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-08-13 20:12:29 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-08-13 20:12:29 +0900 |
| commit | 948785649540e08c1610b1dcce6b37e99b5e8039 (patch) | |
| tree | f5e57a0ecca79c8fb244b4c6af53be8e4b7a53fd | |
| parent | fix(frontend/MkUrlPreview): allow fullscreen from tweets (#11712) (diff) | |
| download | misskey-948785649540e08c1610b1dcce6b37e99b5e8039.tar.gz misskey-948785649540e08c1610b1dcce6b37e99b5e8039.tar.bz2 misskey-948785649540e08c1610b1dcce6b37e99b5e8039.zip | |
feat: refine announcement (#11497)
* wip
* Update read-announcement.ts
* wip
* wip
* wip
* Update index.d.ts
* wip
* Create 1691649257651-refine-announcement.js
* wip
* wip
* wip
* wip
* wip
* wip
* Update announcements.vue
* wip
* wip
* Update announcements.vue
* wip
* Update announcements.vue
* wip
* Update misskey-js.api.md
* Update users.ts
* Create MkAnnouncementDialog.stories.impl.ts
* wip
* wip
* Create AnnouncementService.ts
38 files changed, 1226 insertions, 223 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index a5c74258d5..ba9fb8cbcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ ### General - OAuth 2.0のサポート +- お知らせ機能の強化 + - ユーザー個別のお知らせを作成可能に + - お知らせのバナー表示やダイアログ表示が可能に + - お知らせのアイコンを設定可能に - チャンネルをセンシティブ指定できるようになりました ### Client diff --git a/locales/index.d.ts b/locales/index.d.ts index 4dceec6050..4984dd18ec 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1098,6 +1098,22 @@ export interface Locale { "doYouAgree": string; "beSureToReadThisAsItIsImportant": string; "iHaveReadXCarefullyAndAgree": string; + "dialog": string; + "icon": string; + "forYou": string; + "currentAnnouncements": string; + "pastAnnouncements": string; + "youHaveUnreadAnnouncements": string; + "_announcement": { + "forExistingUsers": string; + "forExistingUsersDescription": string; + "needConfirmationToRead": string; + "needConfirmationToReadDescription": string; + "end": string; + "tooManyActiveAnnouncementDescription": string; + "readConfirmTitle": string; + "readConfirmText": string; + }; "_initialAccountSetting": { "accountCreated": string; "letsStartAccountSetup": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index cab5c8f97a..9089cb1d3b 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -330,7 +330,7 @@ watch: "ウォッチ" unwatch: "ウォッチ解除" accept: "許可" reject: "拒否" -normal: "正常" +normal: "通常" instanceName: "サーバー名" instanceDescription: "サーバーの紹介" maintainerName: "管理者の名前" @@ -1095,6 +1095,22 @@ expired: "期限切れ" doYouAgree: "同意しますか?" beSureToReadThisAsItIsImportant: "重要ですので必ずお読みください。" iHaveReadXCarefullyAndAgree: "「{x}」の内容をよく読み、同意します。" +dialog: "ダイアログ" +icon: "アイコン" +forYou: "あなたへ" +currentAnnouncements: "現在のお知らせ" +pastAnnouncements: "過去のお知らせ" +youHaveUnreadAnnouncements: "未読のお知らせがあります。" + +_announcement: + forExistingUsers: "既存ユーザーのみ" + forExistingUsersDescription: "有効にすると、このお知らせ作成時点で存在するユーザーにのみお知らせが表示されます。無効にすると、このお知らせ作成後にアカウントを作成したユーザーにもお知らせが表示されます。" + needConfirmationToRead: "既読にするのに確認が必要" + needConfirmationToReadDescription: "有効にすると、このお知らせを既読にする際に確認ダイアログが表示されます。また、一括既読操作の対象になりません。" + end: "お知らせを終了" + tooManyActiveAnnouncementDescription: "アクティブなお知らせが多いため、UXが低下する可能性があります。終了したお知らせはアーカイブすることを検討してください。" + readConfirmTitle: "既読にしますか?" + readConfirmText: "「{title}」の内容を読み、既読にします。" _initialAccountSetting: accountCreated: "アカウントの作成が完了しました!" diff --git a/packages/backend/migration/1691649257651-refine-announcement.js b/packages/backend/migration/1691649257651-refine-announcement.js new file mode 100644 index 0000000000..d8d63f3103 --- /dev/null +++ b/packages/backend/migration/1691649257651-refine-announcement.js @@ -0,0 +1,27 @@ +export class RefineAnnouncement1691649257651 { + name = 'RefineAnnouncement1691649257651' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "announcement" ADD "display" character varying(256) NOT NULL DEFAULT 'normal'`); + await queryRunner.query(`ALTER TABLE "announcement" ADD "needConfirmationToRead" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "announcement" ADD "isActive" boolean NOT NULL DEFAULT true`); + await queryRunner.query(`ALTER TABLE "announcement" ADD "forExistingUsers" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "announcement" ADD "userId" character varying(32)`); + await queryRunner.query(`CREATE INDEX "IDX_bc1afcc8ef7e9400cdc3c0a87e" ON "announcement" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_da795d3a83187e8832005ba19d" ON "announcement" ("forExistingUsers") `); + await queryRunner.query(`CREATE INDEX "IDX_fd25dfe3da37df1715f11ba6ec" ON "announcement" ("userId") `); + await queryRunner.query(`ALTER TABLE "announcement" ADD CONSTRAINT "FK_fd25dfe3da37df1715f11ba6ec8" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "announcement" DROP CONSTRAINT "FK_fd25dfe3da37df1715f11ba6ec8"`); + await queryRunner.query(`DROP INDEX "public"."IDX_fd25dfe3da37df1715f11ba6ec"`); + await queryRunner.query(`DROP INDEX "public"."IDX_da795d3a83187e8832005ba19d"`); + await queryRunner.query(`DROP INDEX "public"."IDX_bc1afcc8ef7e9400cdc3c0a87e"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "userId"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "forExistingUsers"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "isActive"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "needConfirmationToRead"`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "display"`); + } +} diff --git a/packages/backend/migration/1691657412740-refine-announcement-2.js b/packages/backend/migration/1691657412740-refine-announcement-2.js new file mode 100644 index 0000000000..8791f99f44 --- /dev/null +++ b/packages/backend/migration/1691657412740-refine-announcement-2.js @@ -0,0 +1,11 @@ +export class RefineAnnouncement21691657412740 { + name = 'RefineAnnouncement21691657412740' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "announcement" ADD "icon" character varying(256) NOT NULL DEFAULT 'info'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "icon"`); + } +} diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts new file mode 100644 index 0000000000..482aeee39f --- /dev/null +++ b/packages/backend/src/core/AnnouncementService.ts @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { User } from '@/models/entities/User.js'; +import type { AnnouncementReadsRepository, AnnouncementsRepository, Announcement, AnnouncementRead } from '@/models/index.js'; +import { bindThis } from '@/decorators.js'; +import { Packed } from '@/misc/json-schema.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; + +@Injectable() +export class AnnouncementService { + constructor( + @Inject(DI.announcementsRepository) + private announcementsRepository: AnnouncementsRepository, + + @Inject(DI.announcementReadsRepository) + private announcementReadsRepository: AnnouncementReadsRepository, + + private idService: IdService, + private globalEventService: GlobalEventService, + ) { + } + + @bindThis + public async getReads(userId: User['id']): Promise<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), + })); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 9d6fb87923..51d4f9cfa9 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -7,6 +7,7 @@ import { Module } from '@nestjs/common'; import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; +import { AnnouncementService } from './AnnouncementService.js'; import { AntennaService } from './AntennaService.js'; import { AppLockService } from './AppLockService.js'; import { AchievementService } from './AchievementService.js'; @@ -130,6 +131,7 @@ const $LoggerService: Provider = { provide: 'LoggerService', useExisting: Logger const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService }; const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService }; const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; +const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExisting: AnnouncementService }; const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; @@ -257,6 +259,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AccountMoveService, AccountUpdateService, AiService, + AnnouncementService, AntennaService, AppLockService, AchievementService, @@ -377,6 +380,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AccountMoveService, $AccountUpdateService, $AiService, + $AnnouncementService, $AntennaService, $AppLockService, $AchievementService, @@ -498,6 +502,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AccountMoveService, AccountUpdateService, AiService, + AnnouncementService, AntennaService, AppLockService, AchievementService, @@ -617,6 +622,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AccountMoveService, $AccountUpdateService, $AiService, + $AnnouncementService, $AntennaService, $AppLockService, $AchievementService, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 1959224227..7be547fbb9 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -4,7 +4,6 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { In, Not } from 'typeorm'; import * as Redis from 'ioredis'; import _Ajv from 'ajv'; import { ModuleRef } from '@nestjs/core'; @@ -16,13 +15,13 @@ import { awaitAll } from '@/misc/prelude/await-all.js'; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; import type { LocalUser, PartialLocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; -import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, UserNotePiningsRepository, UserProfilesRepository, AnnouncementReadsRepository, AnnouncementsRepository, UserProfile, RenoteMutingsRepository, UserMemoRepository } from '@/models/index.js'; +import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, UserNotePiningsRepository, UserProfilesRepository, AnnouncementReadsRepository, AnnouncementsRepository, UserProfile, RenoteMutingsRepository, UserMemoRepository, Announcement } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import type { OnModuleInit } from '@nestjs/common'; -import type { AntennaService } from '../AntennaService.js'; +import type { AnnouncementService } from '../AnnouncementService.js'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { NoteEntityService } from './NoteEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js'; @@ -58,7 +57,7 @@ export class UserEntityService implements OnModuleInit { private driveFileEntityService: DriveFileEntityService; private pageEntityService: PageEntityService; private customEmojiService: CustomEmojiService; - private antennaService: AntennaService; + private announcementService: AnnouncementService; private roleService: RoleService; private federatedInstanceService: FederatedInstanceService; @@ -128,7 +127,7 @@ export class UserEntityService implements OnModuleInit { this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); this.pageEntityService = this.moduleRef.get('PageEntityService'); this.customEmojiService = this.moduleRef.get('CustomEmojiService'); - this.antennaService = this.moduleRef.get('AntennaService'); + this.announcementService = this.moduleRef.get('AnnouncementService'); this.roleService = this.moduleRef.get('RoleService'); this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); } @@ -209,19 +208,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> { /* const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); @@ -347,6 +333,7 @@ export class UserEntityService implements OnModuleInit { const isModerator = isMe && opts.detail ? this.roleService.isModerator(user) : null; const isAdmin = isMe && opts.detail ? this.roleService.isAdministrator(user) : null; + const unreadAnnouncements = isMe && opts.detail ? await this.announcementService.getUnreadAnnouncements(user) : null; const falsy = opts.detail ? false : undefined; @@ -456,7 +443,8 @@ export class UserEntityService implements OnModuleInit { where: { userId: user.id, isMentioned: true }, take: 1, }).then(count => count > 0), - hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id), + hasUnreadAnnouncement: unreadAnnouncements!.length > 0, + unreadAnnouncements, hasUnreadAntenna: this.getHasUnreadAntenna(user.id), hasUnreadChannel: false, // 後方互換性のため hasUnreadNotification: this.getHasUnreadNotification(user.id), diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index ca8a030a62..80c1041c62 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -35,6 +35,7 @@ import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js'; import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js'; import { packedFlashSchema } from '@/models/json-schema/flash.js'; +import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -46,6 +47,7 @@ export const refs = { User: packedUserSchema, UserList: packedUserListSchema, + Announcement: packedAnnouncementSchema, App: packedAppSchema, Note: packedNoteSchema, NoteReaction: packedNoteReactionSchema, diff --git a/packages/backend/src/models/entities/Announcement.ts b/packages/backend/src/models/entities/Announcement.ts index 99cdf89330..18c26faab0 100644 --- a/packages/backend/src/models/entities/Announcement.ts +++ b/packages/backend/src/models/entities/Announcement.ts @@ -3,8 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Entity, Index, Column, PrimaryColumn } from 'typeorm'; +import { Entity, Index, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; import { id } from '../id.js'; +import { User } from './User.js'; @Entity() export class Announcement { @@ -38,6 +39,52 @@ export class Announcement { }) public imageUrl: string | null; + // info, warning, error, success + @Column('varchar', { + length: 256, nullable: false, + default: 'info', + }) + public icon: string; + + // normal ... お知らせページ掲載 + // banner ... お知らせページ掲載 + バナー表示 + // dialog ... お知らせページ掲載 + ダイアログ表示 + @Column('varchar', { + length: 256, nullable: false, + default: 'normal', + }) + public display: string; + + @Column('boolean', { + default: false, + }) + public needConfirmationToRead: boolean; + + @Index() + @Column('boolean', { + default: true, + }) + public isActive: boolean; + + @Index() + @Column('boolean', { + default: false, + }) + public forExistingUsers: boolean; + + @Index() + @Column({ + ...id(), + nullable: true, + }) + public userId: User['id'] | null; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + constructor(data: Partial<Announcement>) { if (data == null) return; diff --git a/packages/backend/src/models/json-schema/announcement.ts b/packages/backend/src/models/json-schema/announcement.ts new file mode 100644 index 0000000000..c7e24c7f29 --- /dev/null +++ b/packages/backend/src/models/json-schema/announcement.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedAnnouncementSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + updatedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, + text: { + type: 'string', + optional: false, nullable: false, + }, + title: { + type: 'string', + optional: false, nullable: false, + }, + imageUrl: { + type: 'string', + optional: false, nullable: true, + }, + icon: { + type: 'string', + optional: false, nullable: false, + }, + display: { + type: 'string', + optional: false, nullable: false, + }, + forYou: { + type: 'boolean', + optional: false, nullable: false, + }, + needConfirmationToRead: { + type: 'boolean', + optional: false, nullable: false, + }, + isRead: { + type: 'boolean', + optional: true, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts index 8addffc6a8..6c5520c2ef 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -3,11 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { AnnouncementsRepository } from '@/models/index.js'; -import { IdService } from '@/core/IdService.js'; -import { DI } from '@/di-symbols.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; export const meta = { tags: ['admin'], @@ -57,6 +55,11 @@ export const paramDef = { title: { type: 'string', minLength: 1 }, text: { type: 'string', minLength: 1 }, imageUrl: { type: 'string', nullable: true, minLength: 1 }, + icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'], default: 'info' }, + display: { type: 'string', enum: ['normal', 'banner', 'dialog'], default: 'normal' }, + forExistingUsers: { type: 'boolean', default: false }, + needConfirmationToRead: { type: 'boolean', default: false }, + userId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, }, required: ['title', 'text', 'imageUrl'], } as const; @@ -65,22 +68,23 @@ export const paramDef = { @Injectable() export default class extends Endpoint<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; }); } } 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 ec0d4061a6..4da3f457f9 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -66,6 +66,7 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id', nullable: true }, }, required: [], } as const; @@ -84,6 +85,11 @@ export default class extends Endpoint<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)!, })); }); 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 b3df14320f..7efc7c0402 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts @@ -31,8 +31,13 @@ export const paramDef = { title: { type: 'string', minLength: 1 }, text: { type: 'string', minLength: 1 }, imageUrl: { type: 'string', nullable: true, minLength: 0 }, + icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'] }, + display: { type: 'string', enum: ['normal', 'banner', 'dialog'] }, + forExistingUsers: { type: 'boolean' }, + needConfirmationToRead: { type: 'boolean' }, + isActive: { type: 'boolean' }, }, - required: ['id', 'title', 'text', 'imageUrl'], + required: ['id'], } as const; // eslint-disable-next-line import/no-default-export @@ -53,6 +58,11 @@ export default class extends Endpoint<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, }); }); } diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts index df0a0afb09..070e6f0d77 100644 --- a/packages/backend/src/server/api/endpoints/announcements.ts +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -4,8 +4,10 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; import { DI } from '@/di-symbols.js'; import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/index.js'; @@ -20,40 +22,7 @@ export const meta = { items: { type: 'object', optional: false, nullable: false, - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - example: 'xxxxxxxxxx', - }, - createdAt: { - type: 'string', - optional: false, nullable: false, - format: 'date-time', - }, - updatedAt: { - type: 'string', - optional: false, nullable: true, - format: 'date-time', - }, - text: { - type: 'string', - optional: false, nullable: false, - }, - title: { - type: 'string', - optional: false, nullable: false, - }, - imageUrl: { - type: 'string', - optional: false, nullable: true, - }, - isRead: { - type: 'boolean', - optional: true, nullable: false, - }, - }, + ref: 'Announcement', }, }, } as const; @@ -62,9 +31,9 @@ export const paramDef = { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - withUnreads: { type: 'boolean', default: false }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + isActive: { type: 'boolean', default: true }, }, required: [], } as const; @@ -80,27 +49,19 @@ export default class extends Endpoint<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); }); } } 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 7bda6e2627..412532939c 100644 --- a/packages/backend/src/server/api/endpoints/i/read-announcement.ts +++ b/packages/backend/src/server/api/endpoints/i/read-announcement.ts @@ -3,14 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { IdService } from '@/core/IdService.js'; -import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/index.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; export const meta = { tags: ['account'], @@ -20,11 +15,6 @@ export const meta = { kind: 'write:account', errors: { - noSuchAnnouncement: { - message: 'No such announcement.', - code: 'NO_SUCH_ANNOUNCEMENT', - id: '184663db-df88-4bc2-8b52-fb85f0681939', - }, }, } as const; @@ -40,47 +30,10 @@ export const paramDef = { @Injectable() export default class extends Endpoint<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); }); } } diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index 82ccd91c88..751a23de8d 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -64,6 +64,9 @@ export interface BroadcastTypes { [other: string]: any; }[]; }; + announcementCreated: { + announcement: Packed<'Announcement'>; + }; } export interface MainStreamTypes { @@ -105,6 +108,9 @@ export interface MainStreamTypes { driveFileCreated: Packed<'DriveFile'>; readAntenna: Antenna; receiveFollowRequest: Packed<'User'>; + announcementCreated: { + announcement: Packed<'Announcement'>; + }; } export interface DriveStreamTypes { diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 0530b44ce4..8afbcbe322 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -160,6 +160,7 @@ describe('ユーザー', () => { hasUnreadChannel: user.hasUnreadChannel, hasUnreadNotification: user.hasUnreadNotification, hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest, + unreadAnnouncements: user.unreadAnnouncements, mutedWords: user.mutedWords, mutedInstances: user.mutedInstances, mutingNotificationTypes: user.mutingNotificationTypes, @@ -405,6 +406,7 @@ describe('ユーザー', () => { assert.strictEqual(response.hasUnreadChannel, false); assert.strictEqual(response.hasUnreadNotification, false); assert.strictEqual(response.hasPendingReceivedFollowRequest, false); + assert.deepStrictEqual(response.unreadAnnouncements, []); assert.deepStrictEqual(response.mutedWords, []); assert.deepStrictEqual(response.mutedInstances, []); assert.deepStrictEqual(response.mutingNotificationTypes, []); diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts new file mode 100644 index 0000000000..c97e081ba5 --- /dev/null +++ b/packages/backend/test/unit/AnnouncementService.ts @@ -0,0 +1,194 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { jest } from '@jest/globals'; +import { ModuleMocker } from 'jest-mock'; +import { Test } from '@nestjs/testing'; +import { GlobalModule } from '@/GlobalModule.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; +import type { Announcement, AnnouncementsRepository, AnnouncementReadsRepository, UsersRepository, User } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { genAid } from '@/misc/id/aid.js'; +import { CacheService } from '@/core/CacheService.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; +import type { TestingModule } from '@nestjs/testing'; +import type { MockFunctionMetadata } from 'jest-mock'; + +const moduleMocker = new ModuleMocker(global); + +describe('AnnouncementService', () => { + let app: TestingModule; + let announcementService: AnnouncementService; + let usersRepository: UsersRepository; + let announcementsRepository: AnnouncementsRepository; + let announcementReadsRepository: AnnouncementReadsRepository; + let globalEventService: jest.Mocked<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 + }); +}); + diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index c3f3337c9d..634084c750 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -96,7 +96,6 @@ export async function removeAccount(idOrToken: Account['id']) { function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<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); diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 7459ea0fa5..9ab1f6e14c 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -83,6 +83,21 @@ export async function mainBoot() { } }); + for (const announcement of ($i.unreadAnnouncements ?? []).filter(x => x.display === 'dialog')) { + popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { + announcement, + }, {}, 'closed'); + } + + stream.on('announcementCreated', (ev) => { + const announcement = ev.announcement; + if (announcement.display === 'dialog') { + popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { + announcement, + }, {}, 'closed'); + } + }); + if ($i.isDeleted) { alert({ type: 'warning', diff --git a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts new file mode 100644 index 0000000000..42cfb90f7c --- /dev/null +++ b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import MkAnnouncementDialog from './MkAnnouncementDialog.vue'; +export const Default = { + render(args) { + return { + components: { + MkAnnouncementDialog, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<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>; diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue new file mode 100644 index 0000000000..8e11053813 --- /dev/null +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -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> diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 8acd4e4707..2a00b8db8e 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -172,7 +172,6 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog'; const props = defineProps<{ note: misskey.entities.Note; - pinned?: boolean; }>(); const inChannel = inject('inChannel', null); diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 559133ef46..fcde45405e 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -37,7 +37,6 @@ import { userPage } from '@/filters/user'; defineProps<{ note: misskey.entities.Note; - pinned?: boolean; }>(); </script> diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index 9648b7230a..e3c9e2bd5f 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -31,7 +31,6 @@ import { $i } from '@/account'; const props = defineProps<{ note: misskey.entities.Note; - pinned?: boolean; }>(); const showContent = $ref(false); diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index 96e2bad49f..8e946e7437 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -12,10 +12,15 @@ SPDX-License-Identifier: AGPL-3.0-only :class="$style.input" @keydown.enter="toggle" > - <XButton :checked="checked" :disabled="disabled" @toggle="toggle" /> + <XButton :checked="checked" :disabled="disabled" @toggle="toggle"/> <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> @@ -28,6 +33,7 @@ import XButton from '@/components/MkSwitch.button.vue'; const props = defineProps<{ modelValue: boolean | Ref<boolean>; disabled?: boolean; + helpText?: string; }>(); const emit = defineEmits<{ @@ -38,10 +44,6 @@ const checked = toRefs(props).modelValue; const toggle = () => { if (props.disabled) return; emit('update:modelValue', !checked.value); - - if (!checked.value) { - - } }; </script> @@ -98,4 +100,10 @@ const toggle = () => { display: none; } } + +.help { + margin-left: 0.5em; + font-size: 85%; + vertical-align: top; +} </style> diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue new file mode 100644 index 0000000000..ce41b3116d --- /dev/null +++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue @@ -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> diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index 2e80d028f7..bb903af459 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -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); } } diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index dd166d3c51..9937eb4cbf 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -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 ref="paginationEl" v-slot="{items}" :pagination="pagination" class="ruryvtyk _gaps_m"> - <section v-for="announcement 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(announcement.id)"><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,30 +43,64 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } 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, + }, +}; + +const paginationPast = { + endpoint: 'announcements' as const, + limit: 10, + params: { + isActive: false, + }, }; const paginationEl = ref<InstanceType<typeof MkPagination>>(); -function read(id: string) { +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(id, announcement => { - announcement.isRead = true; - return announcement; + 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), }); - os.api('i/read-announcement', { announcementId: 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, @@ -58,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> diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index 19c63f8000..7e91805a12 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -63,7 +63,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"> diff --git a/packages/frontend/src/pages/gallery/index.vue b/packages/frontend/src/pages/gallery/index.vue index e7c4ae88b5..af0ac2191b 100644 --- a/packages/frontend/src/pages/gallery/index.vue +++ b/packages/frontend/src/pages/gallery/index.vue @@ -86,7 +86,7 @@ const tagUsersPagination = $computed(() => ({ endpoint: 'hashtags/users' as const, limit: 30, params: { - tag: this.tag, + tag: props.tag, origin: 'combined', sort: '+follower', }, diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue index 42b561cfe2..35cd116fad 100644 --- a/packages/frontend/src/pages/user-info.vue +++ b/packages/frontend/src/pages/user-info.vue @@ -132,6 +132,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> @@ -185,7 +210,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'; @@ -207,6 +232,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; @@ -233,6 +259,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() { @@ -406,6 +439,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(); }, { @@ -569,4 +615,11 @@ definePageMetadata(computed(() => ({ margin-left: 8px; align-self: center; } + +.announcementItem { + display: flex; + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; +} </style> diff --git a/packages/frontend/src/ui/_common_/announcements.vue b/packages/frontend/src/ui/_common_/announcements.vue new file mode 100644 index 0000000000..cd8b4e5275 --- /dev/null +++ b/packages/frontend/src/ui/_common_/announcements.vue @@ -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> diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index f047474741..3518891811 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -8,6 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <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, diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index 1c09df8394..d9cb81b5ef 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -8,7 +8,12 @@ SPDX-License-Identifier: AGPL-3.0-only <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; diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 9d872f98e7..9c598a090e 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -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; }; @@ -2453,6 +2457,7 @@ type MeDetailed = UserDetailed & { noCrawle: boolean; receiveAnnouncementEmail: boolean; usePasswordLessLogin: boolean; + unreadAnnouncements: Announcement[]; [other: string]: any; }; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index b9771d1964..f0c6104ed3 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -104,6 +104,7 @@ export type MeDetailed = UserDetailed & { noCrawle: boolean; receiveAnnouncementEmail: boolean; usePasswordLessLogin: boolean; + unreadAnnouncements: Announcement[]; [other: string]: any; }; @@ -413,6 +414,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; }; |