diff --git a/CHANGELOG.md b/CHANGELOG.md index 7251fd2219..9a62cbefa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ - Feat: メールアドレスの認証にverifymail.ioを使えるように (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/971ba07a44550f68d2ba31c62066db2d43a0caed) - Feat: モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能を追加 (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/e0eb5a752f6e5616d6312bb7c9790302f9dbff83) - Feat: TL上からノートが見えなくなるワードミュートであるハードミュートを追加 +- Enhance: 公開ロールにアサインされたときに通知が作成されるように - Enhance: アイコンデコレーションを複数設定できるように - Enhance: アイコンデコレーションの位置を微調整できるように - Enhance: つながりの公開範囲をフォロー/フォロワーで個別に設定可能に #12072 diff --git a/locales/index.d.ts b/locales/index.d.ts index 25a16d4a4d..f22b7f1c4a 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -2325,6 +2325,7 @@ export interface Locale { "pollEnded": string; "newNote": string; "unreadAntennaNote": string; + "roleAssigned": string; "emptyPushNotificationMessage": string; "achievementEarned": string; "testNotification": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 308b7ae67d..2185183c98 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2227,6 +2227,7 @@ _notification: pollEnded: "アンケートの結果が出ました" newNote: "新しい投稿" unreadAntennaNote: "アンテナ {name}" + roleAssigned: "ロールが付与されました" emptyPushNotificationMessage: "プッシュ通知の更新をしました" achievementEarned: "実績を獲得" testNotification: "通知テスト" diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 4de719d6a0..d354faa7c2 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -6,7 +6,14 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import { In } from 'typeorm'; -import type { MiRole, MiRoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/_.js'; +import { ModuleRef } from '@nestjs/core'; +import type { + MiRole, + MiRoleAssignment, + RoleAssignmentsRepository, + RolesRepository, + UsersRepository, +} from '@/models/_.js'; import { MemoryKVCache, MemorySingleCache } from '@/misc/cache.js'; import type { MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; @@ -16,12 +23,13 @@ import { CacheService } from '@/core/CacheService.js'; import type { RoleCondFormulaValue } from '@/models/Role.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; -import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { IdService } from '@/core/IdService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import type { Packed } from '@/misc/json-schema.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; -import type { OnApplicationShutdown } from '@nestjs/common'; +import { NotificationService } from '@/core/NotificationService.js'; +import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; export type RolePolicies = { gtlAvailable: boolean; @@ -78,14 +86,17 @@ export const DEFAULT_POLICIES: RolePolicies = { }; @Injectable() -export class RoleService implements OnApplicationShutdown { +export class RoleService implements OnApplicationShutdown, OnModuleInit { private rolesCache: MemorySingleCache; private roleAssignmentByUserIdCache: MemoryKVCache; + private notificationService: NotificationService; public static AlreadyAssignedError = class extends Error {}; public static NotAssignedError = class extends Error {}; constructor( + private moduleRef: ModuleRef, + @Inject(DI.redis) private redisClient: Redis.Redis, @@ -120,6 +131,10 @@ export class RoleService implements OnApplicationShutdown { this.redisForSub.on('message', this.onMessage); } + async onModuleInit() { + this.notificationService = this.moduleRef.get(NotificationService.name); + } + @bindThis private async onMessage(_: string, data: string): Promise { const obj = JSON.parse(data); @@ -427,6 +442,12 @@ export class RoleService implements OnApplicationShutdown { this.globalEventService.publishInternalEvent('userRoleAssigned', created); + if (role.isPublic) { + this.notificationService.createNotification(userId, 'roleAssigned', { + roleId: roleId, + }); + } + if (moderator) { const user = await this.usersRepository.findOneByOrFail({ id: userId }); this.moderationLogService.log(moderator, 'assignRole', { diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts index 702c731fc3..832b715d97 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -10,15 +10,15 @@ import type { MiUser } from '@/models/User.js'; import type { MiUserList } from '@/models/UserList.js'; import type { MiUserListMembership } from '@/models/UserListMembership.js'; import { IdService } from '@/core/IdService.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ProxyAccountService } from '@/core/ProxyAccountService.js'; import { bindThis } from '@/decorators.js'; -import { RoleService } from '@/core/RoleService.js'; import { QueueService } from '@/core/QueueService.js'; import { RedisKVCache } from '@/misc/cache.js'; -import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import { RoleService } from '@/core/RoleService.js'; @Injectable() export class UserListService implements OnApplicationShutdown { diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index e723ea5a55..f2124998ac 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -15,8 +15,8 @@ import type { Packed } from '@/misc/json-schema.js'; import { bindThis } from '@/decorators.js'; import { isNotNull } from '@/misc/is-not-null.js'; import { FilterUnionByProperty, notificationTypes } from '@/types.js'; +import { RoleEntityService } from './RoleEntityService.js'; import type { OnModuleInit } from '@nestjs/common'; -import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; @@ -27,7 +27,7 @@ const NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES = new Set(['note', 'mention', 're export class NotificationEntityService implements OnModuleInit { private userEntityService: UserEntityService; private noteEntityService: NoteEntityService; - private customEmojiService: CustomEmojiService; + private roleEntityService: RoleEntityService; constructor( private moduleRef: ModuleRef, @@ -43,14 +43,13 @@ export class NotificationEntityService implements OnModuleInit { //private userEntityService: UserEntityService, //private noteEntityService: NoteEntityService, - //private customEmojiService: CustomEmojiService, ) { } onModuleInit() { this.userEntityService = this.moduleRef.get('UserEntityService'); this.noteEntityService = this.moduleRef.get('NoteEntityService'); - this.customEmojiService = this.moduleRef.get('CustomEmojiService'); + this.roleEntityService = this.moduleRef.get('RoleEntityService'); } @bindThis @@ -81,6 +80,7 @@ export class NotificationEntityService implements OnModuleInit { detail: false, }) ) : undefined; + const role = notification.type === 'roleAssigned' ? await this.roleEntityService.pack(notification.roleId) : undefined; return await awaitAll({ id: notification.id, @@ -92,6 +92,9 @@ export class NotificationEntityService implements OnModuleInit { ...(notification.type === 'reaction' ? { reaction: notification.reaction, } : {}), + ...(notification.type === 'roleAssigned' ? { + role: role, + } : {}), ...(notification.type === 'achievementEarned' ? { achievement: notification.achievement, } : {}), diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index 1d5fc124e2..3bc2edaa0d 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -3,11 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { notificationTypes } from '@/types.js'; import { MiUser } from './User.js'; import { MiNote } from './Note.js'; -import { MiFollowRequest } from './FollowRequest.js'; import { MiAccessToken } from './AccessToken.js'; +import { MiRole } from './Role.js'; export type MiNotification = { type: 'note'; @@ -68,6 +67,11 @@ export type MiNotification = { id: string; createdAt: string; notifierId: MiUser['id']; +} | { + type: 'roleAssigned'; + id: string; + createdAt: string; + roleId: MiRole['id']; } | { type: 'achievementEarned'; id: string; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 1b86b1bf10..6a0d43b1ac 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -554,9 +554,7 @@ export const packedMeDetailedOnlySchema = { mention: notificationRecieveConfig, reaction: notificationRecieveConfig, pollEnded: notificationRecieveConfig, - achievementEarned: notificationRecieveConfig, receiveFollowRequest: notificationRecieveConfig, - followRequestAccepted: notificationRecieveConfig, }, }, emailNotificationTypes: { diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index e085407de0..361a4931eb 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -14,11 +14,26 @@ * pollEnded - 自分のアンケートもしくは自分が投票したアンケートが終了した * receiveFollowRequest - フォローリクエストされた * followRequestAccepted - 自分の送ったフォローリクエストが承認された + * roleAssigned - ロールが付与された * achievementEarned - 実績を獲得 * app - アプリ通知 * test - テスト通知(サーバー側) */ -export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app', 'test'] as const; +export const notificationTypes = [ + 'note', + 'follow', + 'mention', + 'reply', + 'renote', + 'quote', + 'reaction', + 'pollEnded', + 'receiveFollowRequest', + 'followRequestAccepted', + 'roleAssigned', + 'achievementEarned', + 'app', + 'test'] as const; export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const; export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index f644312bc9..99c6912116 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -19,6 +19,7 @@ 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 { NotificationService } from '@/core/NotificationService.js'; import { sleep } from '../utils.js'; import type { TestingModule } from '@nestjs/testing'; import type { MockFunctionMetadata } from 'jest-mock'; @@ -32,6 +33,7 @@ describe('RoleService', () => { let rolesRepository: RolesRepository; let roleAssignmentsRepository: RoleAssignmentsRepository; let metaService: jest.Mocked; + let notificationService: jest.Mocked; let clock: lolex.InstalledClock; function createUser(data: Partial = {}) { @@ -76,6 +78,8 @@ describe('RoleService', () => { .useMocker((token) => { if (token === MetaService) { return { fetch: jest.fn() }; + } else if (token === NotificationService) { + return { createNotification: jest.fn() }; } if (typeof token === 'function') { const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; @@ -93,6 +97,7 @@ describe('RoleService', () => { roleAssignmentsRepository = app.get(DI.roleAssignmentsRepository); metaService = app.get(MetaService) as jest.Mocked; + notificationService = app.get(NotificationService) as jest.Mocked; }); afterEach(async () => { @@ -273,4 +278,53 @@ describe('RoleService', () => { expect(resultAfter25hAgain.canManageCustomEmojis).toBe(true); }); }); + + describe('assign', () => { + test('公開ロールの場合は通知される', async () => { + const user = await createUser(); + const role = await createRole({ + isPublic: true, + }); + + await roleService.assign(user.id, role.id); + + await sleep(100); + + const assignments = await roleAssignmentsRepository.find({ + where: { + userId: user.id, + roleId: role.id, + }, + }); + expect(assignments).toHaveLength(1); + + expect(notificationService.createNotification).toHaveBeenCalled(); + expect(notificationService.createNotification.mock.lastCall![0]).toBe(user.id); + expect(notificationService.createNotification.mock.lastCall![1]).toBe('roleAssigned'); + expect(notificationService.createNotification.mock.lastCall![2]).toBe({ + roleId: role.id, + }); + }); + + test('非公開ロールの場合は通知されない', async () => { + const user = await createUser(); + const role = await createRole({ + isPublic: false, + }); + + await roleService.assign(user.id, role.id); + + await sleep(100); + + const assignments = await roleAssignmentsRepository.find({ + where: { + userId: user.id, + roleId: role.id, + }, + }); + expect(assignments).toHaveLength(1); + + expect(notificationService.createNotification).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index fcf4791240..2b9af26654 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -8,6 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+
@@ -36,6 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only + {{ i18n.ts._notification.pollEnded }} {{ i18n.ts._notification.newNote }}: + {{ i18n.ts._notification.roleAssigned }} {{ i18n.ts._notification.achievementEarned }} {{ i18n.ts._notification.testNotification }} @@ -86,6 +89,9 @@ SPDX-License-Identifier: AGPL-3.0-only +
+ {{ notification.role.name }} +
{{ i18n.ts._achievements._types['_' + notification.achievement].title }} diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index f016b7aa02..01c224ae2d 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -54,7 +54,21 @@ https://github.com/sindresorhus/file-type/blob/main/core.js https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers */ -export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] as const; +export const notificationTypes = [ + 'note', + 'follow', + 'mention', + 'reply', + 'renote', + 'quote', + 'reaction', + 'pollEnded', + 'receiveFollowRequest', + 'followRequestAccepted', + 'roleAssigned', + 'achievementEarned', + 'app', +] as const; export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const; export const ROLE_POLICIES = [ diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index 394e428eda..def8fd3e69 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -68,7 +68,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; import { notificationTypes } from '@/const.js'; -const nonConfigurableNotificationTypes = ['note']; +const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'achievementEarned']; const allowButton = shallowRef>(); const pushRegistrationInServer = computed(() => allowButton.value?.pushRegistrationInServer); diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index abb3cae4b1..ea4e0c4163 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1635,9 +1635,6 @@ type FetchLike = (input: string, init?: { // @public (undocumented) type FetchRssRequest = operations['fetch-rss']['requestBody']['content']['application/json']; -// @public (undocumented) -export const ffVisibility: readonly ["public", "followers", "private"]; - // @public (undocumented) type Flash = components['schemas']['Flash']; @@ -1677,6 +1674,9 @@ type FlashUnlikeRequest = operations['flash/unlike']['requestBody']['content'][' // @public (undocumented) type FlashUpdateRequest = operations['flash/update']['requestBody']['content']['application/json']; +// @public (undocumented) +export const followersVisibilities: readonly ["public", "followers", "private"]; + // @public (undocumented) type Following = components['schemas']['Following']; @@ -1725,6 +1725,9 @@ type FollowingUpdateRequest = operations['following/update']['requestBody']['con // @public (undocumented) type FollowingUpdateResponse = operations['following/update']['responses']['200']['content']['application/json']; +// @public (undocumented) +export const followingVisibilities: readonly ["public", "followers", "private"]; + // @public (undocumented) type GalleryFeaturedRequest = operations['gallery/featured']['requestBody']['content']['application/json']; @@ -2337,7 +2340,7 @@ type Notification_2 = components['schemas']['Notification']; type NotificationsCreateRequest = operations['notifications/create']['requestBody']['content']['application/json']; // @public (undocumented) -export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "achievementEarned"]; +export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "achievementEarned"]; // @public (undocumented) type Page = components['schemas']['Page']; diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index 83d313a5fe..e769bb9e6d 100644 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -1,4 +1,4 @@ -export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'achievementEarned'] as const; +export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'roleAssigned', 'achievementEarned'] as const; export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;