diff --git a/CHANGELOG.md b/CHANGELOG.md index d24364c57..b5dea03e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### General - Feat: ノートの編集をできるように - ロールで編集可否を設定可能 +- Feat: 通知を種類ごとに 全員から受け取る/フォロー中のユーザーのみ受け取る/フォロワーのみ受け取る/相互のみ受け取る/指定したリストのメンバーのみ受け取る/受け取らない から選べるように - Enhance: タイムラインからRenoteを除外するオプションを追加 - Enhance: ユーザーページのノート一覧でRenoteを除外できるように diff --git a/locales/index.d.ts b/locales/index.d.ts index 75f867e69..b971e8905 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1126,6 +1126,8 @@ export interface Locale { "dateAndTime": string; "showRenotes": string; "edited": string; + "notificationRecieveConfig": string; + "mutualFollow": string; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index cfa9143dd..5822ba8fb 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1123,6 +1123,8 @@ authenticationRequiredToContinue: "続けるには認証を行ってください dateAndTime: "日時" showRenotes: "リノートを表示" edited: "編集済み" +notificationRecieveConfig: "通知の受信設定" +mutualFollow: "相互フォロー" _announcement: forExistingUsers: "既存ユーザーのみ" diff --git a/packages/backend/migration/1695944637565-notificationRecieveConfig.js b/packages/backend/migration/1695944637565-notificationRecieveConfig.js new file mode 100644 index 000000000..42d3dce5d --- /dev/null +++ b/packages/backend/migration/1695944637565-notificationRecieveConfig.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class NotificationRecieveConfig1695944637565 { + name = 'NotificationRecieveConfig1695944637565' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "mutingNotificationTypes"`); + await queryRunner.query(`ALTER TABLE "user_profile" ADD "notificationRecieveConfig" jsonb NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "notificationRecieveConfig"`); + await queryRunner.query(`ALTER TABLE "user_profile" ADD "mutingNotificationTypes" "public"."user_profile_notificationrecieveconfig_enum" array NOT NULL DEFAULT '{}'`); + } +} diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 841ce4b84..d9f27b8c6 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -15,7 +15,7 @@ import { DI } from '@/di-symbols.js'; import type { AntennasRepository, UserListJoiningsRepository } from '@/models/_.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; -import { StreamMessages } from '@/server/api/stream/types.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() @@ -50,7 +50,7 @@ export class AntennaService implements OnApplicationShutdown { const obj = JSON.parse(data); if (obj.channel === 'internal') { - const { type, body } = obj.message as StreamMessages['internal']['payload']; + const { type, body } = obj.message as GlobalEvents['internal']['payload']; switch (type) { case 'antennaCreated': this.antennas.push({ diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 6ca684d53..561979c4b 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -11,7 +11,7 @@ import type { MiLocalUser, MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; -import { StreamMessages } from '@/server/api/stream/types.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() @@ -160,7 +160,7 @@ export class CacheService implements OnApplicationShutdown { const obj = JSON.parse(data); if (obj.channel === 'internal') { - const { type, body } = obj.message as StreamMessages['internal']['payload']; + const { type, body } = obj.message as GlobalEvents['internal']['payload']; switch (type) { case 'userChangeSuspendedState': case 'remoteUserUpdated': { diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 1b545a124..9661a0aea 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -17,7 +17,7 @@ import { bindThis } from '@/decorators.js'; import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; import { UtilityService } from '@/core/UtilityService.js'; import { query } from '@/misc/prelude/url.js'; -import type { Serialized } from '@/server/api/stream/types.js'; +import type { Serialized } from '@/types.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/; diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 4bc4f54c2..b74fbbe58 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -5,27 +5,254 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; +import type { MiChannel } from '@/models/Channel.js'; import type { MiUser } from '@/models/User.js'; +import type { MiUserProfile } from '@/models/UserProfile.js'; import type { MiNote } from '@/models/Note.js'; -import type { MiUserList } from '@/models/UserList.js'; import type { MiAntenna } from '@/models/Antenna.js'; -import type { - StreamChannels, - AdminStreamTypes, - AntennaStreamTypes, - BroadcastTypes, - DriveStreamTypes, - InternalStreamTypes, - MainStreamTypes, - NoteStreamTypes, - UserListStreamTypes, - RoleTimelineStreamTypes, -} from '@/server/api/stream/types.js'; +import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { MiDriveFolder } from '@/models/DriveFolder.js'; +import type { MiUserList } from '@/models/UserList.js'; +import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; +import type { MiSignin } from '@/models/Signin.js'; +import type { MiPage } from '@/models/Page.js'; +import type { MiWebhook } from '@/models/Webhook.js'; +import type { MiMeta } from '@/models/Meta.js'; +import { MiRole, MiRoleAssignment } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; -import { MiRole } from '@/models/_.js'; +import { Serialized } from '@/types.js'; +import type Emitter from 'strict-event-emitter-types'; +import type { EventEmitter } from 'events'; + +//#region Stream type-body definitions +export interface BroadcastTypes { + emojiAdded: { + emoji: Packed<'EmojiDetailed'>; + }; + emojiUpdated: { + emojis: Packed<'EmojiDetailed'>[]; + }; + emojiDeleted: { + emojis: { + id?: string; + name: string; + [other: string]: any; + }[]; + }; + announcementCreated: { + announcement: Packed<'Announcement'>; + }; +} + +export interface MainEventTypes { + notification: Packed<'Notification'>; + mention: Packed<'Note'>; + reply: Packed<'Note'>; + renote: Packed<'Note'>; + follow: Packed<'UserDetailedNotMe'>; + followed: Packed<'User'>; + unfollow: Packed<'User'>; + meUpdated: Packed<'User'>; + pageEvent: { + pageId: MiPage['id']; + event: string; + var: any; + userId: MiUser['id']; + user: Packed<'User'>; + }; + urlUploadFinished: { + marker?: string | null; + file: Packed<'DriveFile'>; + }; + readAllNotifications: undefined; + unreadNotification: Packed<'Notification'>; + unreadMention: MiNote['id']; + readAllUnreadMentions: undefined; + unreadSpecifiedNote: MiNote['id']; + readAllUnreadSpecifiedNotes: undefined; + readAllAntennas: undefined; + unreadAntenna: MiAntenna; + readAllAnnouncements: undefined; + myTokenRegenerated: undefined; + signin: MiSignin; + registryUpdated: { + scope?: string[]; + key: string; + value: any | null; + }; + driveFileCreated: Packed<'DriveFile'>; + readAntenna: MiAntenna; + receiveFollowRequest: Packed<'User'>; + announcementCreated: { + announcement: Packed<'Announcement'>; + }; +} + +export interface DriveEventTypes { + fileCreated: Packed<'DriveFile'>; + fileDeleted: MiDriveFile['id']; + fileUpdated: Packed<'DriveFile'>; + folderCreated: Packed<'DriveFolder'>; + folderDeleted: MiDriveFolder['id']; + folderUpdated: Packed<'DriveFolder'>; +} + +export interface NoteEventTypes { + pollVoted: { + choice: number; + userId: MiUser['id']; + }; + deleted: { + deletedAt: Date; + }; + updated: { + cw: string | null; + text: string; + }; + reacted: { + reaction: string; + emoji?: { + name: string; + url: string; + } | null; + userId: MiUser['id']; + }; + unreacted: { + reaction: string; + userId: MiUser['id']; + }; +} +type NoteStreamEventTypes = { + [key in keyof NoteEventTypes]: { + id: MiNote['id']; + body: NoteEventTypes[key]; + }; +}; + +export interface UserListEventTypes { + userAdded: Packed<'User'>; + userRemoved: Packed<'User'>; +} + +export interface AntennaEventTypes { + note: MiNote; +} + +export interface RoleTimelineEventTypes { + note: Packed<'Note'>; +} + +export interface AdminEventTypes { + newAbuseUserReport: { + id: MiAbuseUserReport['id']; + targetUserId: MiUser['id'], + reporterId: MiUser['id'], + comment: string; + }; +} +//#endregion + +// 辞書(interface or type)から{ type, body }ユニオンを定義 +// https://stackoverflow.com/questions/49311989/can-i-infer-the-type-of-a-value-using-extends-keyof-type +// VS Codeの展開を防止するためにEvents型を定義 +type Events = { [K in keyof T]: { type: K; body: T[K]; } }; +type EventUnionFromDictionary< + T extends object, + U = Events +> = U[keyof U]; + +type SerializedAll = { + [K in keyof T]: Serialized; +}; + +export interface InternalEventTypes { + userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; }; + userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; }; + remoteUserUpdated: { id: MiUser['id']; }; + follow: { followerId: MiUser['id']; followeeId: MiUser['id']; }; + unfollow: { followerId: MiUser['id']; followeeId: MiUser['id']; }; + blockingCreated: { blockerId: MiUser['id']; blockeeId: MiUser['id']; }; + blockingDeleted: { blockerId: MiUser['id']; blockeeId: MiUser['id']; }; + policiesUpdated: MiRole['policies']; + roleCreated: MiRole; + roleDeleted: MiRole; + roleUpdated: MiRole; + userRoleAssigned: MiRoleAssignment; + userRoleUnassigned: MiRoleAssignment; + webhookCreated: MiWebhook; + webhookDeleted: MiWebhook; + webhookUpdated: MiWebhook; + antennaCreated: MiAntenna; + antennaDeleted: MiAntenna; + antennaUpdated: MiAntenna; + metaUpdated: MiMeta; + followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; + unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; + updateUserProfile: MiUserProfile; + mute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; + unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; + userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; }; + userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; }; +} + +// name/messages(spec) pairs dictionary +export type GlobalEvents = { + internal: { + name: 'internal'; + payload: EventUnionFromDictionary>; + }; + broadcast: { + name: 'broadcast'; + payload: EventUnionFromDictionary>; + }; + main: { + name: `mainStream:${MiUser['id']}`; + payload: EventUnionFromDictionary>; + }; + drive: { + name: `driveStream:${MiUser['id']}`; + payload: EventUnionFromDictionary>; + }; + note: { + name: `noteStream:${MiNote['id']}`; + payload: EventUnionFromDictionary>; + }; + userList: { + name: `userListStream:${MiUserList['id']}`; + payload: EventUnionFromDictionary>; + }; + roleTimeline: { + name: `roleTimelineStream:${MiRole['id']}`; + payload: EventUnionFromDictionary>; + }; + antenna: { + name: `antennaStream:${MiAntenna['id']}`; + payload: EventUnionFromDictionary>; + }; + admin: { + name: `adminStream:${MiUser['id']}`; + payload: EventUnionFromDictionary>; + }; + notes: { + name: 'notesStream'; + payload: Serialized>; + }; +}; + +// API event definitions +// ストリームごとのEmitterの辞書を用意 +type EventEmitterDictionary = { [x in keyof GlobalEvents]: Emitter.default void }> }; +// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; +// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする +export type StreamEventEmitter = UnionToIntersection; +// { [y in name]: (e: spec) => void }をまとめてその交差型をEmitterにかけるとts(2590)にひっかかる + +// provide stream channels union +export type StreamChannels = GlobalEvents[keyof GlobalEvents]['name']; @Injectable() export class GlobalEventService { @@ -51,7 +278,7 @@ export class GlobalEventService { } @bindThis - public publishInternalEvent(type: K, value?: InternalStreamTypes[K]): void { + public publishInternalEvent(type: K, value?: InternalEventTypes[K]): void { this.publish('internal', type, typeof value === 'undefined' ? null : value); } @@ -61,17 +288,17 @@ export class GlobalEventService { } @bindThis - public publishMainStream(userId: MiUser['id'], type: K, value?: MainStreamTypes[K]): void { + public publishMainStream(userId: MiUser['id'], type: K, value?: MainEventTypes[K]): void { this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value); } @bindThis - public publishDriveStream(userId: MiUser['id'], type: K, value?: DriveStreamTypes[K]): void { + public publishDriveStream(userId: MiUser['id'], type: K, value?: DriveEventTypes[K]): void { this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value); } @bindThis - public publishNoteStream(noteId: MiNote['id'], type: K, value?: NoteStreamTypes[K]): void { + public publishNoteStream(noteId: MiNote['id'], type: K, value?: NoteEventTypes[K]): void { this.publish(`noteStream:${noteId}`, type, { id: noteId, body: value, @@ -79,17 +306,17 @@ export class GlobalEventService { } @bindThis - public publishUserListStream(listId: MiUserList['id'], type: K, value?: UserListStreamTypes[K]): void { + public publishUserListStream(listId: MiUserList['id'], type: K, value?: UserListEventTypes[K]): void { this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value); } @bindThis - public publishAntennaStream(antennaId: MiAntenna['id'], type: K, value?: AntennaStreamTypes[K]): void { + public publishAntennaStream(antennaId: MiAntenna['id'], type: K, value?: AntennaEventTypes[K]): void { this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); } @bindThis - public publishRoleTimelineStream(roleId: MiRole['id'], type: K, value?: RoleTimelineStreamTypes[K]): void { + public publishRoleTimelineStream(roleId: MiRole['id'], type: K, value?: RoleTimelineEventTypes[K]): void { this.publish(`roleTimelineStream:${roleId}`, type, typeof value === 'undefined' ? null : value); } @@ -99,7 +326,7 @@ export class GlobalEventService { } @bindThis - public publishAdminStream(userId: MiUser['id'], type: K, value?: AdminStreamTypes[K]): void { + public publishAdminStream(userId: MiUser['id'], type: K, value?: AdminEventTypes[K]): void { this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value); } } diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts index 00e1e3c1f..508544dc0 100644 --- a/packages/backend/src/core/MetaService.ts +++ b/packages/backend/src/core/MetaService.ts @@ -10,7 +10,7 @@ import { DI } from '@/di-symbols.js'; import { MiMeta } from '@/models/Meta.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { bindThis } from '@/decorators.js'; -import { StreamMessages } from '@/server/api/stream/types.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() @@ -46,7 +46,7 @@ export class MetaService implements OnApplicationShutdown { const obj = JSON.parse(data); if (obj.channel === 'internal') { - const { type, body } = obj.message as StreamMessages['internal']['payload']; + const { type, body } = obj.message as GlobalEvents['internal']['payload']; switch (type) { case 'metaUpdated': { this.cache = body; diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 972319ddc..f20727ce4 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -110,9 +110,8 @@ class NotificationManager { // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する if (!mentioneesMutedUserIds.includes(this.notifier.id)) { this.notificationService.createNotification(x.target, x.reason, { - notifierId: this.notifier.id, noteId: this.note.id, - }); + }, this.notifier.id); } } } @@ -515,9 +514,8 @@ export class NoteCreateService implements OnApplicationShutdown { }).then(followings => { for (const following of followings) { this.notificationService.createNotification(following.followerId, 'note', { - notifierId: user.id, noteId: note.id, - }); + }, user.id); } }); } diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 258ae44f7..ba8798f18 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -18,6 +18,7 @@ import { NotificationEntityService } from '@/core/entities/NotificationEntitySer import { IdService } from '@/core/IdService.js'; import { CacheService } from '@/core/CacheService.js'; import type { Config } from '@/config.js'; +import { UserListService } from '@/core/UserListService.js'; @Injectable() export class NotificationService implements OnApplicationShutdown { @@ -38,6 +39,7 @@ export class NotificationService implements OnApplicationShutdown { private globalEventService: GlobalEventService, private pushNotificationService: PushNotificationService, private cacheService: CacheService, + private userListService: UserListService, ) { } @@ -74,27 +76,56 @@ export class NotificationService implements OnApplicationShutdown { public async createNotification( notifieeId: MiUser['id'], type: MiNotification['type'], - data: Partial, + data: Omit, 'notifierId'>, + notifierId?: MiUser['id'] | null, ): Promise { const profile = await this.cacheService.userProfileCache.fetch(notifieeId); - const isMuted = profile.mutingNotificationTypes.includes(type); - if (isMuted) return null; + const recieveConfig = profile.notificationRecieveConfig[type]; + if (recieveConfig?.type === 'never') { + return null; + } - if (data.notifierId) { - if (notifieeId === data.notifierId) { + if (notifierId) { + if (notifieeId === notifierId) { return null; } const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId); - if (mutings.has(data.notifierId)) { + if (mutings.has(notifierId)) { return null; } + + if (recieveConfig?.type === 'following') { + const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)); + if (!isFollowing) { + return null; + } + } else if (recieveConfig?.type === 'follower') { + const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)); + if (!isFollower) { + return null; + } + } else if (recieveConfig?.type === 'mutualFollow') { + const [isFollowing, isFollower] = await Promise.all([ + this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)), + this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)), + ]); + if (!isFollowing && !isFollower) { + return null; + } + } else if (recieveConfig?.type === 'list') { + const isMember = await this.userListService.membersCache.fetch(recieveConfig.userListId).then(members => members.has(notifierId)); + if (!isMember) { + return null; + } + } } const notification = { id: this.idService.genId(), createdAt: new Date(), type: type, + notifierId: notifierId, ...data, } as MiNotification; @@ -117,8 +148,8 @@ export class NotificationService implements OnApplicationShutdown { this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); - if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); - if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); + if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: notifierId! })); + if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: notifierId! })); }, () => { /* aborted, ignore it */ }); return notification; diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index d9bde502c..25464b19a 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -219,10 +219,9 @@ export class ReactionService { // リアクションされたユーザーがローカルユーザーなら通知を作成 if (note.userHost === null) { this.notificationService.createNotification(note.userId, 'reaction', { - notifierId: user.id, noteId: note.id, reaction: reaction, - }); + }, user.id); } //#region 配信 diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index ec4d80421..c3445abc5 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -15,7 +15,7 @@ import { MetaService } from '@/core/MetaService.js'; import { CacheService } from '@/core/CacheService.js'; import type { RoleCondFormulaValue } from '@/models/Role.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { StreamMessages } from '@/server/api/stream/types.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; @@ -116,7 +116,7 @@ export class RoleService implements OnApplicationShutdown { const obj = JSON.parse(data); if (obj.channel === 'internal') { - const { type, body } = obj.message as StreamMessages['internal']['payload']; + const { type, body } = obj.message as GlobalEvents['internal']['payload']; switch (type) { case 'roleCreated': { const cached = this.rolesCache.get(); diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index 5b2b0205d..230f6ef26 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -230,8 +230,7 @@ export class UserFollowingService implements OnModuleInit { // 通知を作成 this.notificationService.createNotification(follower.id, 'followRequestAccepted', { - notifierId: followee.id, - }); + }, followee.id); } if (alreadyFollowed) return; @@ -304,8 +303,7 @@ export class UserFollowingService implements OnModuleInit { // 通知を作成 this.notificationService.createNotification(followee.id, 'follow', { - notifierId: follower.id, - }); + }, follower.id); } } @@ -488,9 +486,8 @@ export class UserFollowingService implements OnModuleInit { // 通知を作成 this.notificationService.createNotification(followee.id, 'receiveFollowRequest', { - notifierId: follower.id, followRequestId: followRequest.id, - }); + }, follower.id); } if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts index a71d50bba..93dc5edbb 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import * as Redis from 'ioredis'; import type { UserListJoiningsRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; import type { MiUserList } from '@/models/UserList.js'; @@ -16,12 +17,22 @@ 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'; @Injectable() -export class UserListService { +export class UserListService implements OnApplicationShutdown { public static TooManyUsersError = class extends Error {}; + public membersCache: RedisKVCache>; + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + @Inject(DI.userListJoiningsRepository) private userListJoiningsRepository: UserListJoiningsRepository, @@ -32,10 +43,48 @@ export class UserListService { private proxyAccountService: ProxyAccountService, private queueService: QueueService, ) { + this.membersCache = new RedisKVCache>(this.redisClient, 'userListMembers', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.userListJoiningsRepository.find({ where: { userListId: key }, select: ['userId'] }).then(xs => new Set(xs.map(x => x.userId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.redisForSub.on('message', this.onMessage); } @bindThis - public async push(target: MiUser, list: MiUserList, me: MiUser) { + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'userListMemberAdded': { + const { userListId, memberId } = body; + const members = await this.membersCache.get(userListId); + if (members) { + members.add(memberId); + } + break; + } + case 'userListMemberRemoved': { + const { userListId, memberId } = body; + const members = await this.membersCache.get(userListId); + if (members) { + members.delete(memberId); + } + break; + } + default: + break; + } + } + } + + @bindThis + public async addMember(target: MiUser, list: MiUserList, me: MiUser) { const currentCount = await this.userListJoiningsRepository.countBy({ userListId: list.id, }); @@ -50,6 +99,7 @@ export class UserListService { userListId: list.id, } as MiUserListJoining); + this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id }); this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target)); // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする @@ -60,4 +110,26 @@ export class UserListService { } } } + + @bindThis + public async removeMember(target: MiUser, list: MiUserList) { + await this.userListJoiningsRepository.delete({ + userId: target.id, + userListId: list.id, + }); + + this.globalEventService.publishInternalEvent('userListMemberRemoved', { userListId: list.id, memberId: target.id }); + this.globalEventService.publishUserListStream(list.id, 'userRemoved', await this.userEntityService.pack(target)); + } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + this.membersCache.dispose(); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } } diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts index 1344f0ac9..ff70f7bc0 100644 --- a/packages/backend/src/core/WebhookService.ts +++ b/packages/backend/src/core/WebhookService.ts @@ -9,7 +9,7 @@ import type { WebhooksRepository } from '@/models/_.js'; import type { MiWebhook } from '@/models/Webhook.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; -import { StreamMessages } from '@/server/api/stream/types.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() @@ -45,7 +45,7 @@ export class WebhookService implements OnApplicationShutdown { const obj = JSON.parse(data); if (obj.channel === 'internal') { - const { type, body } = obj.message as StreamMessages['internal']['payload']; + const { type, body } = obj.message as GlobalEvents['internal']['payload']; switch (type) { case 'webhookCreated': if (body.active) { diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 3dd64ce62..47fc98f47 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -452,7 +452,7 @@ export class UserEntityService implements OnModuleInit { hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), mutedWords: profile!.mutedWords, mutedInstances: profile!.mutedInstances, - mutingNotificationTypes: profile!.mutingNotificationTypes, + notificationRecieveConfig: profile!.notificationRecieveConfig, emailNotificationTypes: profile!.emailNotificationTypes, achievements: profile!.achievements, loggedInDays: profile!.loggedInDates.length, diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index e4405c9da..d6d85c560 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -8,6 +8,7 @@ import { obsoleteNotificationTypes, ffVisibility, notificationTypes } from '@/ty import { id } from './util/id.js'; import { MiUser } from './User.js'; import { MiPage } from './Page.js'; +import { MiUserList } from './UserList.js'; // TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも // ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン @@ -222,16 +223,25 @@ export class MiUserProfile { }) public mutedInstances: string[]; - @Column('enum', { - enum: [ - ...notificationTypes, - // マイグレーションで削除が困難なので古いenumは残しておく - ...obsoleteNotificationTypes, - ], - array: true, - default: [], + @Column('jsonb', { + default: {}, }) - public mutingNotificationTypes: typeof notificationTypes[number][]; + public notificationRecieveConfig: { + [notificationType in typeof notificationTypes[number]]?: { + type: 'all'; + } | { + type: 'never'; + } | { + type: 'following'; + } | { + type: 'follower'; + } | { + type: 'mutualFollow'; + } | { + type: 'list'; + userListId: MiUserList['id']; + }; + }; @Column('varchar', { length: 32, array: true, default: '{}', diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index f15b225a3..0181ea50e 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -387,13 +387,9 @@ export const packedMeDetailedOnlySchema = { nullable: false, optional: false, }, }, - mutingNotificationTypes: { - type: 'array', - nullable: true, optional: false, - items: { - type: 'string', - nullable: false, optional: false, - }, + notificationRecieveConfig: { + type: 'object', + nullable: false, optional: false, }, emailNotificationTypes: { type: 'array', diff --git a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts index 54ca1a86d..60a0d1605 100644 --- a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts @@ -101,7 +101,7 @@ export class ImportUserListsProcessorService { if (await this.userListJoiningsRepository.findOneBy({ userListId: list!.id, userId: target.id }) != null) continue; - this.userListService.push(target, list!, user); + this.userListService.addMember(target, list!, user); } catch (e) { this.logger.warn(`Error in line:${linenum} ${e}`); } diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index e065b99e9..345459753 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -81,7 +81,7 @@ export default class extends Endpoint { // eslint- receiveAnnouncementEmail: profile.receiveAnnouncementEmail, mutedWords: profile.mutedWords, mutedInstances: profile.mutedInstances, - mutingNotificationTypes: profile.mutingNotificationTypes, + notificationRecieveConfig: profile.notificationRecieveConfig, isModerator: isModerator, isSilenced: isSilenced, isSuspended: user.isSuspended, diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index b11e09195..431bb4c60 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -165,9 +165,7 @@ export const paramDef = { mutedInstances: { type: 'array', items: { type: 'string', } }, - mutingNotificationTypes: { type: 'array', items: { - type: 'string', enum: notificationTypes, - } }, + notificationRecieveConfig: { type: 'object' }, emailNotificationTypes: { type: 'array', items: { type: 'string', } }, @@ -248,7 +246,7 @@ export default class extends Endpoint { // eslint- profileUpdates.enableWordMute = ps.mutedWords.length > 0; } if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances; - if (ps.mutingNotificationTypes !== undefined) profileUpdates.mutingNotificationTypes = ps.mutingNotificationTypes as typeof notificationTypes[number][]; + if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig; if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable; if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus; diff --git a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts index fd1bb48a4..eae55905d 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts @@ -144,7 +144,7 @@ export default class extends Endpoint { // eslint- } try { - await this.userListService.push(currentUser, userList, me); + await this.userListService.addMember(currentUser, userList, me); } catch (err) { if (err instanceof UserListService.TooManyUsersError) { throw new ApiError(meta.errors.tooManyUsers); diff --git a/packages/backend/src/server/api/endpoints/users/lists/pull.ts b/packages/backend/src/server/api/endpoints/users/lists/pull.ts index 0b0106174..e90122224 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/pull.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/pull.ts @@ -4,12 +4,11 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { UserListsRepository, UserListJoiningsRepository } from '@/models/_.js'; +import type { UserListsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { GetterService } from '@/server/api/GetterService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; +import { UserListService } from '@/core/UserListService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -53,12 +52,8 @@ export default class extends Endpoint { // eslint- @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, - - private userEntityService: UserEntityService, + private userListService: UserListService, private getterService: GetterService, - private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { // Fetch the list @@ -77,10 +72,7 @@ export default class extends Endpoint { // eslint- throw err; }); - // Pull the user - await this.userListJoiningsRepository.delete({ userListId: userList.id, userId: user.id }); - - this.globalEventService.publishUserListStream(userList.id, 'userRemoved', await this.userEntityService.pack(user)); + await this.userListService.removeMember(user, userList); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts index 9bb1a71f5..72a6a7380 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/push.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts @@ -127,7 +127,7 @@ export default class extends Endpoint { // eslint- } try { - await this.userListService.push(user, userList, me); + await this.userListService.addMember(user, userList, me); } catch (err) { if (err instanceof UserListService.TooManyUsersError) { throw new ApiError(meta.errors.tooManyUsers); diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index fd91681fc..a73071ea9 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -12,10 +12,10 @@ import type { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; import { MiUserProfile } from '@/models/_.js'; +import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js'; import type { ChannelsService } from './ChannelsService.js'; import type { EventEmitter } from 'events'; import type Channel from './channel.js'; -import type { StreamEventEmitter, StreamMessages } from './types.js'; /** * Main stream connection @@ -122,7 +122,7 @@ export default class Connection { } @bindThis - private onBroadcastMessage(data: StreamMessages['broadcast']['payload']) { + private onBroadcastMessage(data: GlobalEvents['broadcast']['payload']) { this.sendMessageToWs(data.type, data.body); } @@ -196,7 +196,7 @@ export default class Connection { } @bindThis - private async onNoteStreamMessage(data: StreamMessages['note']['payload']) { + private async onNoteStreamMessage(data: GlobalEvents['note']['payload']) { this.sendMessageToWs('noteUpdated', { id: data.body.id, type: data.type, diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index 87648a3a7..a48e6ba5c 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -7,8 +7,8 @@ import { Injectable } from '@nestjs/common'; import { isUserRelated } from '@/misc/is-user-related.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; import Channel from '../channel.js'; -import type { StreamMessages } from '../types.js'; class AntennaChannel extends Channel { public readonly chName = 'antenna'; @@ -35,7 +35,7 @@ class AntennaChannel extends Channel { } @bindThis - private async onEvent(data: StreamMessages['antenna']['payload']) { + private async onEvent(data: GlobalEvents['antenna']['payload']) { if (data.type === 'note') { const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true }); diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts index 76b587534..38d3604cc 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -9,8 +9,8 @@ import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; import Channel from '../channel.js'; -import { StreamMessages } from '../types.js'; class RoleTimelineChannel extends Channel { public readonly chName = 'roleTimeline'; @@ -37,7 +37,7 @@ class RoleTimelineChannel extends Channel { } @bindThis - private async onEvent(data: StreamMessages['roleTimeline']['payload']) { + private async onEvent(data: GlobalEvents['roleTimeline']['payload']) { if (data.type === 'note') { const note = data.body; diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts deleted file mode 100644 index 2436750cd..000000000 --- a/packages/backend/src/server/api/stream/types.ts +++ /dev/null @@ -1,259 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and other misskey contributors - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import type { MiChannel } from '@/models/Channel.js'; -import type { MiUser } from '@/models/User.js'; -import type { MiUserProfile } from '@/models/UserProfile.js'; -import type { MiNote } from '@/models/Note.js'; -import type { MiAntenna } from '@/models/Antenna.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiDriveFolder } from '@/models/DriveFolder.js'; -import type { MiUserList } from '@/models/UserList.js'; -import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; -import type { MiSignin } from '@/models/Signin.js'; -import type { MiPage } from '@/models/Page.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { MiWebhook } from '@/models/Webhook.js'; -import type { MiMeta } from '@/models/Meta.js'; -import { MiRole, MiRoleAssignment } from '@/models/_.js'; -import type Emitter from 'strict-event-emitter-types'; -import type { EventEmitter } from 'events'; - -//#region Stream type-body definitions -export interface InternalStreamTypes { - userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; }; - userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; }; - remoteUserUpdated: { id: MiUser['id']; }; - follow: { followerId: MiUser['id']; followeeId: MiUser['id']; }; - unfollow: { followerId: MiUser['id']; followeeId: MiUser['id']; }; - blockingCreated: { blockerId: MiUser['id']; blockeeId: MiUser['id']; }; - blockingDeleted: { blockerId: MiUser['id']; blockeeId: MiUser['id']; }; - policiesUpdated: MiRole['policies']; - roleCreated: MiRole; - roleDeleted: MiRole; - roleUpdated: MiRole; - userRoleAssigned: MiRoleAssignment; - userRoleUnassigned: MiRoleAssignment; - webhookCreated: MiWebhook; - webhookDeleted: MiWebhook; - webhookUpdated: MiWebhook; - antennaCreated: MiAntenna; - antennaDeleted: MiAntenna; - antennaUpdated: MiAntenna; - metaUpdated: MiMeta; - followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; - unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; - updateUserProfile: MiUserProfile; - mute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; - unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; -} - -export interface BroadcastTypes { - emojiAdded: { - emoji: Packed<'EmojiDetailed'>; - }; - emojiUpdated: { - emojis: Packed<'EmojiDetailed'>[]; - }; - emojiDeleted: { - emojis: { - id?: string; - name: string; - [other: string]: any; - }[]; - }; - announcementCreated: { - announcement: Packed<'Announcement'>; - }; -} - -export interface MainStreamTypes { - notification: Packed<'Notification'>; - mention: Packed<'Note'>; - reply: Packed<'Note'>; - renote: Packed<'Note'>; - follow: Packed<'UserDetailedNotMe'>; - followed: Packed<'User'>; - unfollow: Packed<'User'>; - meUpdated: Packed<'User'>; - pageEvent: { - pageId: MiPage['id']; - event: string; - var: any; - userId: MiUser['id']; - user: Packed<'User'>; - }; - urlUploadFinished: { - marker?: string | null; - file: Packed<'DriveFile'>; - }; - readAllNotifications: undefined; - unreadNotification: Packed<'Notification'>; - unreadMention: MiNote['id']; - readAllUnreadMentions: undefined; - unreadSpecifiedNote: MiNote['id']; - readAllUnreadSpecifiedNotes: undefined; - readAllAntennas: undefined; - unreadAntenna: MiAntenna; - readAllAnnouncements: undefined; - myTokenRegenerated: undefined; - signin: MiSignin; - registryUpdated: { - scope?: string[]; - key: string; - value: any | null; - }; - driveFileCreated: Packed<'DriveFile'>; - readAntenna: MiAntenna; - receiveFollowRequest: Packed<'User'>; - announcementCreated: { - announcement: Packed<'Announcement'>; - }; -} - -export interface DriveStreamTypes { - fileCreated: Packed<'DriveFile'>; - fileDeleted: MiDriveFile['id']; - fileUpdated: Packed<'DriveFile'>; - folderCreated: Packed<'DriveFolder'>; - folderDeleted: MiDriveFolder['id']; - folderUpdated: Packed<'DriveFolder'>; -} - -export interface NoteStreamTypes { - pollVoted: { - choice: number; - userId: MiUser['id']; - }; - deleted: { - deletedAt: Date; - }; - updated: { - cw: string | null; - text: string; - }; - reacted: { - reaction: string; - emoji?: { - name: string; - url: string; - } | null; - userId: MiUser['id']; - }; - unreacted: { - reaction: string; - userId: MiUser['id']; - }; -} -type NoteStreamEventTypes = { - [key in keyof NoteStreamTypes]: { - id: MiNote['id']; - body: NoteStreamTypes[key]; - }; -}; - -export interface UserListStreamTypes { - userAdded: Packed<'User'>; - userRemoved: Packed<'User'>; -} - -export interface AntennaStreamTypes { - note: MiNote; -} - -export interface RoleTimelineStreamTypes { - note: Packed<'Note'>; -} - -export interface AdminStreamTypes { - newAbuseUserReport: { - id: MiAbuseUserReport['id']; - targetUserId: MiUser['id'], - reporterId: MiUser['id'], - comment: string; - }; -} -//#endregion - -// 辞書(interface or type)から{ type, body }ユニオンを定義 -// https://stackoverflow.com/questions/49311989/can-i-infer-the-type-of-a-value-using-extends-keyof-type -// VS Codeの展開を防止するためにEvents型を定義 -type Events = { [K in keyof T]: { type: K; body: T[K]; } }; -type EventUnionFromDictionary< - T extends object, - U = Events -> = U[keyof U]; - -// redis通すとDateのインスタンスはstringに変換されるので -export type Serialized = { - [K in keyof T]: - T[K] extends Date - ? string - : T[K] extends (Date | null) - ? (string | null) - : T[K] extends Record - ? Serialized - : T[K]; -}; - -type SerializedAll = { - [K in keyof T]: Serialized; -}; - -// name/messages(spec) pairs dictionary -export type StreamMessages = { - internal: { - name: 'internal'; - payload: EventUnionFromDictionary>; - }; - broadcast: { - name: 'broadcast'; - payload: EventUnionFromDictionary>; - }; - main: { - name: `mainStream:${MiUser['id']}`; - payload: EventUnionFromDictionary>; - }; - drive: { - name: `driveStream:${MiUser['id']}`; - payload: EventUnionFromDictionary>; - }; - note: { - name: `noteStream:${MiNote['id']}`; - payload: EventUnionFromDictionary>; - }; - userList: { - name: `userListStream:${MiUserList['id']}`; - payload: EventUnionFromDictionary>; - }; - roleTimeline: { - name: `roleTimelineStream:${MiRole['id']}`; - payload: EventUnionFromDictionary>; - }; - antenna: { - name: `antennaStream:${MiAntenna['id']}`; - payload: EventUnionFromDictionary>; - }; - admin: { - name: `adminStream:${MiUser['id']}`; - payload: EventUnionFromDictionary>; - }; - notes: { - name: 'notesStream'; - payload: Serialized>; - }; -}; - -// API event definitions -// ストリームごとのEmitterの辞書を用意 -type EventEmitterDictionary = { [x in keyof StreamMessages]: Emitter.default void }> }; -// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection -type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; -// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする -export type StreamEventEmitter = UnionToIntersection; -// { [y in name]: (e: spec) => void }をまとめてその交差型をEmitterにかけるとts(2590)にひっかかる - -// provide stream channels union -export type StreamChannels = StreamMessages[keyof StreamMessages]['name']; diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 7b928263a..9e06d30aa 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -203,3 +203,14 @@ export type ModerationLogPayloads = { invitations: any[]; }; }; + +export type Serialized = { + [K in keyof T]: + T[K] extends Date + ? string + : T[K] extends (Date | null) + ? (string | null) + : T[K] extends Record + ? Serialized + : T[K]; +}; diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 0b3f20026..2a3a694cf 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -166,7 +166,7 @@ describe('ユーザー', () => { unreadAnnouncements: user.unreadAnnouncements, mutedWords: user.mutedWords, mutedInstances: user.mutedInstances, - mutingNotificationTypes: user.mutingNotificationTypes, + notificationRecieveConfig: user.notificationRecieveConfig, emailNotificationTypes: user.emailNotificationTypes, achievements: user.achievements, loggedInDays: user.loggedInDays, @@ -414,7 +414,7 @@ describe('ユーザー', () => { assert.deepStrictEqual(response.unreadAnnouncements, []); assert.deepStrictEqual(response.mutedWords, []); assert.deepStrictEqual(response.mutedInstances, []); - assert.deepStrictEqual(response.mutingNotificationTypes, []); + assert.deepStrictEqual(response.notificationRecieveConfig, {}); assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']); assert.deepStrictEqual(response.achievements, []); assert.deepStrictEqual(response.loggedInDays, 0); @@ -495,8 +495,8 @@ describe('ユーザー', () => { { parameters: (): object => ({ mutedWords: [] }) }, { parameters: (): object => ({ mutedInstances: ['xxxx.xxxxx'] }) }, { parameters: (): object => ({ mutedInstances: [] }) }, - { parameters: (): object => ({ mutingNotificationTypes: ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] }) }, - { parameters: (): object => ({ mutingNotificationTypes: [] }) }, + { parameters: (): object => ({ notificationRecieveConfig: { mention: { type: 'following' } } }) }, + { parameters: (): object => ({ notificationRecieveConfig: {} }) }, { parameters: (): object => ({ emailNotificationTypes: ['mention', 'reply', 'quote', 'follow', 'receiveFollowRequest'] }) }, { parameters: (): object => ({ emailNotificationTypes: [] }) }, ] as const)('を書き換えることができる($#)', async ({ parameters }) => { diff --git a/packages/frontend/src/components/MkNotificationSelectWindow.vue b/packages/frontend/src/components/MkNotificationSelectWindow.vue new file mode 100644 index 000000000..3d5a56975 --- /dev/null +++ b/packages/frontend/src/components/MkNotificationSelectWindow.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/packages/frontend/src/components/MkNotificationSettingWindow.vue b/packages/frontend/src/components/MkNotificationSettingWindow.vue deleted file mode 100644 index a25914b21..000000000 --- a/packages/frontend/src/components/MkNotificationSettingWindow.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - - - diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index ad1cb92ce..6ba2e513c 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -30,11 +30,11 @@ import MkNote from '@/components/MkNote.vue'; import { useStream } from '@/stream.js'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; -import { notificationTypes } from '@/const'; +import { notificationTypes } from '@/const.js'; import { infoImageUrl } from '@/instance.js'; const props = defineProps<{ - includeTypes?: typeof notificationTypes[number][]; + excludeTypes?: typeof notificationTypes[number][]; }>(); const pagingComponent = shallowRef>(); @@ -43,13 +43,12 @@ const pagination: Paging = { endpoint: 'i/notifications' as const, limit: 10, params: computed(() => ({ - includeTypes: props.includeTypes ?? undefined, - excludeTypes: props.includeTypes ? undefined : $i.mutingNotificationTypes, + excludeTypes: props.excludeTypes ?? undefined, })), }; const onNotification = (notification) => { - const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type); + const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false; if (isMuted || document.visibilityState === 'visible') { useStream().send('readNotification'); } diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue index 3ae6319c7..8d2475b08 100644 --- a/packages/frontend/src/pages/notifications.vue +++ b/packages/frontend/src/pages/notifications.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
@@ -27,10 +27,11 @@ import MkNotes from '@/components/MkNotes.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { notificationTypes } from '@/const'; +import { notificationTypes } from '@/const.js'; let tab = $ref('all'); let includeTypes = $ref(null); +const excludeTypes = $computed(() => includeTypes ? notificationTypes.filter(t => !includeTypes.includes(t)) : null); const mentionsPagination = { endpoint: 'notes/mentions' as const, diff --git a/packages/frontend/src/pages/settings/notifications.notification-config.vue b/packages/frontend/src/pages/settings/notifications.notification-config.vue new file mode 100644 index 000000000..c1f107c2b --- /dev/null +++ b/packages/frontend/src/pages/settings/notifications.notification-config.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index 5b3c29e7c..88e02af1f 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -5,7 +5,26 @@ SPDX-License-Identifier: AGPL-3.0-only