Skip to content

Commit

Permalink
feat: refine announcement (#11497)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
syuilo authored Aug 13, 2023
1 parent 2896fc6 commit 9487856
Show file tree
Hide file tree
Showing 38 changed files with 1,229 additions and 226 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@

### General
- OAuth 2.0のサポート
- お知らせ機能の強化
- ユーザー個別のお知らせを作成可能に
- お知らせのバナー表示やダイアログ表示が可能に
- お知らせのアイコンを設定可能に
- チャンネルをセンシティブ指定できるようになりました

### Client
Expand Down
16 changes: 16 additions & 0 deletions locales/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 17 additions & 1 deletion locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ watch: "ウォッチ"
unwatch: "ウォッチ解除"
accept: "許可"
reject: "拒否"
normal: "正常"
normal: "通常"
instanceName: "サーバー名"
instanceDescription: "サーバーの紹介"
maintainerName: "管理者の名前"
Expand Down Expand Up @@ -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: "アカウントの作成が完了しました!"
Expand Down
27 changes: 27 additions & 0 deletions packages/backend/migration/1691649257651-refine-announcement.js
Original file line number Diff line number Diff line change
@@ -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"`);
}
}
11 changes: 11 additions & 0 deletions packages/backend/migration/1691657412740-refine-announcement-2.js
Original file line number Diff line number Diff line change
@@ -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 changes: 135 additions & 0 deletions packages/backend/src/core/AnnouncementService.ts
Original file line number Diff line number Diff line change
@@ -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),
}));
}
}
6 changes: 6 additions & 0 deletions packages/backend/src/core/CoreModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -257,6 +259,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AccountMoveService,
AccountUpdateService,
AiService,
AnnouncementService,
AntennaService,
AppLockService,
AchievementService,
Expand Down Expand Up @@ -377,6 +380,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AccountMoveService,
$AccountUpdateService,
$AiService,
$AnnouncementService,
$AntennaService,
$AppLockService,
$AchievementService,
Expand Down Expand Up @@ -498,6 +502,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AccountMoveService,
AccountUpdateService,
AiService,
AnnouncementService,
AntennaService,
AppLockService,
AchievementService,
Expand Down Expand Up @@ -617,6 +622,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AccountMoveService,
$AccountUpdateService,
$AiService,
$AnnouncementService,
$AntennaService,
$AppLockService,
$AchievementService,
Expand Down
26 changes: 7 additions & 19 deletions packages/backend/src/core/entities/UserEntityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -208,19 +207,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> {
/*
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/misc/json-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -46,6 +47,7 @@ export const refs = {
User: packedUserSchema,

UserList: packedUserListSchema,
Announcement: packedAnnouncementSchema,
App: packedAppSchema,
Note: packedNoteSchema,
NoteReaction: packedNoteReactionSchema,
Expand Down
Loading

1 comment on commit 9487856

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chromatic detects changes. Please review the changes on Chromatic.

Please sign in to comment.