summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2023-12-21 10:39:11 +0900
committerGitHub <noreply@github.com>2023-12-21 10:39:11 +0900
commit15b0d2aff2011935f212db19feab3bec97979ae1 (patch)
treee3a386517aefbaad295f2bed66d74e70cf7c966f /packages
parentchore(workflows): use postgres 15 everywhere (#12726) (diff)
downloadsharkey-15b0d2aff2011935f212db19feab3bec97979ae1.tar.gz
sharkey-15b0d2aff2011935f212db19feab3bec97979ae1.tar.bz2
sharkey-15b0d2aff2011935f212db19feab3bec97979ae1.zip
enhance: ロールにアサインされたときの通知 (#12607)
* wip * Update misskey-js.api.md * Update CHANGELOG.md * Update RoleService.ts * Update locales/ja-JP.yml Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> * Update UserListService.ts * Update misskey-js.api.md * fix (#12724) --------- Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> Co-authored-by: おさむのひと <46447427+samunohito@users.noreply.github.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/backend/src/core/RoleService.ts29
-rw-r--r--packages/backend/src/core/UserListService.ts4
-rw-r--r--packages/backend/src/core/entities/NotificationEntityService.ts11
-rw-r--r--packages/backend/src/models/Notification.ts8
-rw-r--r--packages/backend/src/models/json-schema/user.ts2
-rw-r--r--packages/backend/src/types.ts17
-rw-r--r--packages/backend/test/unit/RoleService.ts54
-rw-r--r--packages/frontend/src/components/MkNotification.vue6
-rw-r--r--packages/frontend/src/const.ts16
-rw-r--r--packages/frontend/src/pages/settings/notifications.vue2
-rw-r--r--packages/misskey-js/etc/misskey-js.api.md11
-rw-r--r--packages/misskey-js/src/consts.ts2
12 files changed, 140 insertions, 22 deletions
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<MiRole[]>;
private roleAssignmentByUserIdCache: MemoryKVCache<MiRoleAssignment[]>;
+ 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<void> {
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';
@@ -69,6 +68,11 @@ export type MiNotification = {
createdAt: string;
notifierId: MiUser['id'];
} | {
+ type: 'roleAssigned';
+ id: string;
+ createdAt: string;
+ roleId: MiRole['id'];
+} | {
type: 'achievementEarned';
id: string;
createdAt: 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<MetaService>;
+ let notificationService: jest.Mocked<NotificationService>;
let clock: lolex.InstalledClock;
function createUser(data: Partial<MiUser> = {}) {
@@ -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<any, any>;
@@ -93,6 +97,7 @@ describe('RoleService', () => {
roleAssignmentsRepository = app.get<RoleAssignmentsRepository>(DI.roleAssignmentsRepository);
metaService = app.get<MetaService>(MetaService) as jest.Mocked<MetaService>;
+ notificationService = app.get<NotificationService>(NotificationService) as jest.Mocked<NotificationService>;
});
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
<div :class="$style.head">
<MkAvatar v-if="notification.type === 'pollEnded'" :class="$style.icon" :user="notification.note.user" link preview/>
<MkAvatar v-else-if="notification.type === 'note'" :class="$style.icon" :user="notification.note.user" link preview/>
+ <MkAvatar v-else-if="notification.type === 'roleAssigned'" :class="$style.icon" :user="$i" link preview/>
<MkAvatar v-else-if="notification.type === 'achievementEarned'" :class="$style.icon" :user="$i" link preview/>
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
@@ -36,6 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i>
<i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i>
<i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i>
+ <img v-else-if="notification.type === 'roleAssigned'" :src="notification.role.iconUrl" alt=""/>
<!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
<MkReactionIcon
v-else-if="notification.type === 'reaction'"
@@ -50,6 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<header :class="$style.header">
<span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span>
<span v-else-if="notification.type === 'note'">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span>
+ <span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span>
<span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span>
<span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span>
<MkA v-else-if="notification.user" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
@@ -86,6 +89,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
<i class="ti ti-quote" :class="$style.quote"></i>
</MkA>
+ <div v-else-if="notification.type === 'roleAssigned'" :class="$style.text">
+ {{ notification.role.name }}
+ </div>
<MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements">
{{ i18n.ts._achievements._types['_' + notification.achievement].title }}
</MkA>
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<InstanceType<typeof MkPushNotificationAllowButton>>();
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
@@ -1636,9 +1636,6 @@ type FetchLike = (input: string, init?: {
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'];
// @public (undocumented)
@@ -1678,6 +1675,9 @@ type FlashUnlikeRequest = operations['flash/unlike']['requestBody']['content']['
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'];
// @public (undocumented)
@@ -1726,6 +1726,9 @@ type FollowingUpdateRequest = operations['following/update']['requestBody']['con
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'];
// @public (undocumented)
@@ -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;