diff --git a/CHANGELOG.md b/CHANGELOG.md index d24364c57c..b5dea03e63 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 75f867e691..b971e8905b 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 cfa9143dde..5822ba8fba 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 0000000000..42d3dce5d6 --- /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 841ce4b84a..d9f27b8c63 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 6ca684d53c..561979c4bf 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 1b545a124e..9661a0aea3 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 4bc4f54c21..b74fbbe584 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<T extends object> = { [K in keyof T]: { type: K; body: T[K]; } }; +type EventUnionFromDictionary< + T extends object, + U = Events<T> +> = U[keyof U]; + +type SerializedAll<T> = { + [K in keyof T]: Serialized<T[K]>; +}; + +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<SerializedAll<InternalEventTypes>>; + }; + broadcast: { + name: 'broadcast'; + payload: EventUnionFromDictionary<SerializedAll<BroadcastTypes>>; + }; + main: { + name: `mainStream:${MiUser['id']}`; + payload: EventUnionFromDictionary<SerializedAll<MainEventTypes>>; + }; + drive: { + name: `driveStream:${MiUser['id']}`; + payload: EventUnionFromDictionary<SerializedAll<DriveEventTypes>>; + }; + note: { + name: `noteStream:${MiNote['id']}`; + payload: EventUnionFromDictionary<SerializedAll<NoteStreamEventTypes>>; + }; + userList: { + name: `userListStream:${MiUserList['id']}`; + payload: EventUnionFromDictionary<SerializedAll<UserListEventTypes>>; + }; + roleTimeline: { + name: `roleTimelineStream:${MiRole['id']}`; + payload: EventUnionFromDictionary<SerializedAll<RoleTimelineEventTypes>>; + }; + antenna: { + name: `antennaStream:${MiAntenna['id']}`; + payload: EventUnionFromDictionary<SerializedAll<AntennaEventTypes>>; + }; + admin: { + name: `adminStream:${MiUser['id']}`; + payload: EventUnionFromDictionary<SerializedAll<AdminEventTypes>>; + }; + notes: { + name: 'notesStream'; + payload: Serialized<Packed<'Note'>>; + }; +}; + +// API event definitions +// ストリームごとのEmitterの辞書を用意 +type EventEmitterDictionary = { [x in keyof GlobalEvents]: Emitter.default<EventEmitter, { [y in GlobalEvents[x]['name']]: (e: GlobalEvents[x]['payload']) => void }> }; +// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection +type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; +// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする +export type StreamEventEmitter = UnionToIntersection<EventEmitterDictionary[keyof GlobalEvents]>; +// { [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<K extends keyof InternalStreamTypes>(type: K, value?: InternalStreamTypes[K]): void { + public publishInternalEvent<K extends keyof InternalEventTypes>(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<K extends keyof MainStreamTypes>(userId: MiUser['id'], type: K, value?: MainStreamTypes[K]): void { + public publishMainStream<K extends keyof MainEventTypes>(userId: MiUser['id'], type: K, value?: MainEventTypes[K]): void { this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value); } @bindThis - public publishDriveStream<K extends keyof DriveStreamTypes>(userId: MiUser['id'], type: K, value?: DriveStreamTypes[K]): void { + public publishDriveStream<K extends keyof DriveEventTypes>(userId: MiUser['id'], type: K, value?: DriveEventTypes[K]): void { this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value); } @bindThis - public publishNoteStream<K extends keyof NoteStreamTypes>(noteId: MiNote['id'], type: K, value?: NoteStreamTypes[K]): void { + public publishNoteStream<K extends keyof NoteEventTypes>(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<K extends keyof UserListStreamTypes>(listId: MiUserList['id'], type: K, value?: UserListStreamTypes[K]): void { + public publishUserListStream<K extends keyof UserListEventTypes>(listId: MiUserList['id'], type: K, value?: UserListEventTypes[K]): void { this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value); } @bindThis - public publishAntennaStream<K extends keyof AntennaStreamTypes>(antennaId: MiAntenna['id'], type: K, value?: AntennaStreamTypes[K]): void { + public publishAntennaStream<K extends keyof AntennaEventTypes>(antennaId: MiAntenna['id'], type: K, value?: AntennaEventTypes[K]): void { this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); } @bindThis - public publishRoleTimelineStream<K extends keyof RoleTimelineStreamTypes>(roleId: MiRole['id'], type: K, value?: RoleTimelineStreamTypes[K]): void { + public publishRoleTimelineStream<K extends keyof RoleTimelineEventTypes>(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<K extends keyof AdminStreamTypes>(userId: MiUser['id'], type: K, value?: AdminStreamTypes[K]): void { + public publishAdminStream<K extends keyof AdminEventTypes>(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 00e1e3c1fc..508544dc07 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 972319ddcf..f20727ce41 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 258ae44f7d..ba8798f181 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<MiNotification>, + data: Omit<Partial<MiNotification>, 'notifierId'>, + notifierId?: MiUser['id'] | null, ): Promise<MiNotification | null> { 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 d9bde502c8..25464b19a8 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 ec4d804219..c3445abc53 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 5b2b0205d9..230f6ef261 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 a71d50bba5..93dc5edbba 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<Set<string>>; + 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<Set<string>>(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<void> { + 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 1344f0ac97..ff70f7bc0c 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 3dd64ce625..47fc98f479 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 e4405c9da7..d6d85c5609 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 f15b225a30..0181ea50e8 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 54ca1a86df..60a0d1605f 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 e065b99e93..3454597532 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<typeof meta, typeof paramDef> { // 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 b11e091957..431bb4c60a 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<typeof meta, typeof paramDef> { // 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 fd1bb48a4e..eae55905d3 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<typeof meta, typeof paramDef> { // 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 0b01061740..e90122224c 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<typeof meta, typeof paramDef> { // 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<typeof meta, typeof paramDef> { // 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 9bb1a71f58..72a6a7380d 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<typeof meta, typeof paramDef> { // 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 fd91681fc1..a73071ea99 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 87648a3a77..a48e6ba5c6 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 76b5875343..38d3604cc5 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 2436750cd6..0000000000 --- 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<T extends object> = { [K in keyof T]: { type: K; body: T[K]; } }; -type EventUnionFromDictionary< - T extends object, - U = Events<T> -> = U[keyof U]; - -// redis通すとDateのインスタンスはstringに変換されるので -export type Serialized<T> = { - [K in keyof T]: - T[K] extends Date - ? string - : T[K] extends (Date | null) - ? (string | null) - : T[K] extends Record<string, any> - ? Serialized<T[K]> - : T[K]; -}; - -type SerializedAll<T> = { - [K in keyof T]: Serialized<T[K]>; -}; - -// name/messages(spec) pairs dictionary -export type StreamMessages = { - internal: { - name: 'internal'; - payload: EventUnionFromDictionary<SerializedAll<InternalStreamTypes>>; - }; - broadcast: { - name: 'broadcast'; - payload: EventUnionFromDictionary<SerializedAll<BroadcastTypes>>; - }; - main: { - name: `mainStream:${MiUser['id']}`; - payload: EventUnionFromDictionary<SerializedAll<MainStreamTypes>>; - }; - drive: { - name: `driveStream:${MiUser['id']}`; - payload: EventUnionFromDictionary<SerializedAll<DriveStreamTypes>>; - }; - note: { - name: `noteStream:${MiNote['id']}`; - payload: EventUnionFromDictionary<SerializedAll<NoteStreamEventTypes>>; - }; - userList: { - name: `userListStream:${MiUserList['id']}`; - payload: EventUnionFromDictionary<SerializedAll<UserListStreamTypes>>; - }; - roleTimeline: { - name: `roleTimelineStream:${MiRole['id']}`; - payload: EventUnionFromDictionary<SerializedAll<RoleTimelineStreamTypes>>; - }; - antenna: { - name: `antennaStream:${MiAntenna['id']}`; - payload: EventUnionFromDictionary<SerializedAll<AntennaStreamTypes>>; - }; - admin: { - name: `adminStream:${MiUser['id']}`; - payload: EventUnionFromDictionary<SerializedAll<AdminStreamTypes>>; - }; - notes: { - name: 'notesStream'; - payload: Serialized<Packed<'Note'>>; - }; -}; - -// API event definitions -// ストリームごとのEmitterの辞書を用意 -type EventEmitterDictionary = { [x in keyof StreamMessages]: Emitter.default<EventEmitter, { [y in StreamMessages[x]['name']]: (e: StreamMessages[x]['payload']) => void }> }; -// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection -type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; -// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする -export type StreamEventEmitter = UnionToIntersection<EventEmitterDictionary[keyof StreamMessages]>; -// { [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 7b928263ab..9e06d30aac 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<T> = { + [K in keyof T]: + T[K] extends Date + ? string + : T[K] extends (Date | null) + ? (string | null) + : T[K] extends Record<string, any> + ? Serialized<T[K]> + : T[K]; +}; diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 0b3f200260..2a3a694cf5 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 0000000000..3d5a56975b --- /dev/null +++ b/packages/frontend/src/components/MkNotificationSelectWindow.vue @@ -0,0 +1,78 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialog" + :width="400" + :height="450" + :withOkButton="true" + :okButtonDisabled="false" + @ok="ok()" + @close="dialog?.close()" + @closed="emit('closed')" +> + <template #header>{{ i18n.ts.notificationSetting }}</template> + + <MkSpacer :marginMin="20" :marginMax="28"> + <div class="_gaps_m"> + <MkInfo>{{ i18n.ts.notificationSettingDesc }}</MkInfo> + <div class="_buttons"> + <MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton> + <MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton> + </div> + <MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype].value">{{ i18n.t(`_notification._types.${ntype}`) }}</MkSwitch> + </div> + </MkSpacer> +</MkModalWindow> +</template> + +<script lang="ts" setup> +import { ref, Ref } from 'vue'; +import MkSwitch from './MkSwitch.vue'; +import MkInfo from './MkInfo.vue'; +import MkButton from './MkButton.vue'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import { notificationTypes } from '@/const.js'; +import { i18n } from '@/i18n.js'; + +type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>> + +const emit = defineEmits<{ + (ev: 'done', v: { excludeTypes: string[] }): void, + (ev: 'closed'): void, +}>(); + +const props = withDefaults(defineProps<{ + excludeTypes?: typeof notificationTypes[number][]; +}>(), { + excludeTypes: () => [], +}); + +const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); + +const typesMap: TypesMap = notificationTypes.reduce((p, t) => ({ ...p, [t]: ref<boolean>(!props.excludeTypes.includes(t)) }), {} as any); + +function ok() { + emit('done', { + excludeTypes: (Object.keys(typesMap) as typeof notificationTypes[number][]) + .filter(type => !typesMap[type].value), + }); + + if (dialog) dialog.close(); +} + +function disableAll() { + for (const type of notificationTypes) { + typesMap[type].value = false; + } +} + +function enableAll() { + for (const type of notificationTypes) { + typesMap[type].value = true; + } +} +</script> diff --git a/packages/frontend/src/components/MkNotificationSettingWindow.vue b/packages/frontend/src/components/MkNotificationSettingWindow.vue deleted file mode 100644 index a25914b214..0000000000 --- a/packages/frontend/src/components/MkNotificationSettingWindow.vue +++ /dev/null @@ -1,95 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and other misskey contributors -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<MkModalWindow - ref="dialog" - :width="400" - :height="450" - :withOkButton="true" - :okButtonDisabled="false" - @ok="ok()" - @close="dialog?.close()" - @closed="emit('closed')" -> - <template #header>{{ i18n.ts.notificationSetting }}</template> - - <MkSpacer :marginMin="20" :marginMax="28"> - <div class="_gaps_m"> - <template v-if="showGlobalToggle"> - <MkSwitch v-model="useGlobalSetting"> - {{ i18n.ts.useGlobalSetting }} - <template #caption>{{ i18n.ts.useGlobalSettingDesc }}</template> - </MkSwitch> - </template> - <template v-if="!useGlobalSetting"> - <MkInfo>{{ i18n.ts.notificationSettingDesc }}</MkInfo> - <div class="_buttons"> - <MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton> - <MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton> - </div> - <MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype].value">{{ i18n.t(`_notification._types.${ntype}`) }}</MkSwitch> - </template> - </div> - </MkSpacer> -</MkModalWindow> -</template> - -<script lang="ts" setup> -import { ref, Ref } from 'vue'; -import MkSwitch from './MkSwitch.vue'; -import MkInfo from './MkInfo.vue'; -import MkButton from './MkButton.vue'; -import MkModalWindow from '@/components/MkModalWindow.vue'; -import { notificationTypes } from '@/const'; -import { i18n } from '@/i18n.js'; - -type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>> - -const emit = defineEmits<{ - (ev: 'done', v: { includingTypes: string[] | null }): void, - (ev: 'closed'): void, -}>(); - -const props = withDefaults(defineProps<{ - includingTypes?: typeof notificationTypes[number][] | null; - showGlobalToggle?: boolean; -}>(), { - includingTypes: () => [], - showGlobalToggle: true, -}); - -let includingTypes = $computed(() => props.includingTypes?.filter(x => notificationTypes.includes(x)) ?? []); - -const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); - -const typesMap: TypesMap = notificationTypes.reduce((p, t) => ({ ...p, [t]: ref<boolean>(includingTypes.includes(t)) }), {} as any); -let useGlobalSetting = $ref((includingTypes === null || includingTypes.length === 0) && props.showGlobalToggle); - -function ok() { - if (useGlobalSetting) { - emit('done', { includingTypes: null }); - } else { - emit('done', { - includingTypes: (Object.keys(typesMap) as typeof notificationTypes[number][]) - .filter(type => typesMap[type].value), - }); - } - - if (dialog) dialog.close(); -} - -function disableAll() { - for (const type of notificationTypes) { - typesMap[type].value = false; - } -} - -function enableAll() { - for (const type of notificationTypes) { - typesMap[type].value = true; - } -} -</script> diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index ad1cb92ce1..6ba2e513c5 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<InstanceType<typeof MkPagination>>(); @@ -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 3ae6319c7e..8d2475b085 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 <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="800"> <div v-if="tab === 'all'"> - <XNotifications class="notifications" :includeTypes="includeTypes"/> + <XNotifications class="notifications" :excludeTypes="excludeTypes"/> </div> <div v-else-if="tab === 'mentions'"> <MkNotes :pagination="mentionsPagination"/> @@ -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<string[] | null>(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 0000000000..c1f107c2b8 --- /dev/null +++ b/packages/frontend/src/pages/settings/notifications.notification-config.vue @@ -0,0 +1,50 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div class="_gaps_m"> + <MkSelect v-model="type"> + <option value="all">{{ i18n.ts.all }}</option> + <option value="following">{{ i18n.ts.following }}</option> + <option value="follower">{{ i18n.ts.followers }}</option> + <option value="mutualFollow">{{ i18n.ts.mutualFollow }}</option> + <option value="list">{{ i18n.ts.userList }}</option> + <option value="never">{{ i18n.ts.none }}</option> + </MkSelect> + + <MkSelect v-if="type === 'list'" v-model="userListId"> + <template #label>{{ i18n.ts.userList }}</template> + <option v-for="list in props.userLists" :key="list.id" :value="list.id">{{ list.name }}</option> + </MkSelect> + + <div class="_buttons"> + <MkButton inline primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkSelect from '@/components/MkSelect.vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; + +const props = defineProps<{ + value: any; + userLists: Misskey.entities.UserList[]; +}>(); + +const emit = defineEmits<{ + (ev: 'update', result: any): void; +}>(); + +let type = $ref(props.value.type); +let userListId = $ref(props.value.userListId); + +function save() { + emit('update', { type, userListId }); +} +</script> diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index 5b3c29e7c8..88e02af1f4 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 <template> <div class="_gaps_m"> - <FormLink @click="configure"><template #icon><i class="ti ti-settings"></i></template>{{ i18n.ts.notificationSetting }}</FormLink> + <FormSection first> + <template #label>{{ i18n.ts.notificationRecieveConfig }}</template> + <div class="_gaps_s"> + <MkFolder v-for="type in notificationTypes" :key="type"> + <template #label>{{ i18n.t('_notification._types.' + type) }}</template> + <template #suffix> + {{ + $i.notificationRecieveConfig[type]?.type === 'never' ? i18n.ts.none : + $i.notificationRecieveConfig[type]?.type === 'following' ? i18n.ts.following : + $i.notificationRecieveConfig[type]?.type === 'follower' ? i18n.ts.followers : + $i.notificationRecieveConfig[type]?.type === 'mutualFollow' ? i18n.ts.mutualFollow : + $i.notificationRecieveConfig[type]?.type === 'list' ? i18n.ts.userList : + i18n.ts.all + }} + </template> + + <XNotificationConfig :userLists="userLists" :value="$i.notificationRecieveConfig[type] ?? { type: 'all' }" @update="(res) => updateReceiveConfig(type, res)"/> + </MkFolder> + </div> + </FormSection> <FormSection> <div class="_gaps_m"> <FormLink @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink> @@ -37,19 +56,22 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { defineAsyncComponent } from 'vue'; +import XNotificationConfig from './notifications.notification-config.vue'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; +import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import * as os from '@/os.js'; import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; -import { notificationTypes } from '@/const'; +import { notificationTypes } from '@/const.js'; let allowButton = $shallowRef<InstanceType<typeof MkPushNotificationAllowButton>>(); let pushRegistrationInServer = $computed(() => allowButton?.pushRegistrationInServer); let sendReadMessage = $computed(() => pushRegistrationInServer?.sendReadMessage || false); +const userLists = await os.api('users/lists/list'); async function readAllUnreadNotes() { await os.api('i/read-all-unread-notes'); @@ -59,21 +81,15 @@ async function readAllNotifications() { await os.api('notifications/mark-all-as-read'); } -function configure() { - const includingTypes = notificationTypes.filter(x => !$i!.mutingNotificationTypes.includes(x)); - os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), { - includingTypes, - showGlobalToggle: false, - }, { - done: async (res) => { - const { includingTypes: value } = res; - await os.apiWithDialog('i/update', { - mutingNotificationTypes: notificationTypes.filter(x => !value.includes(x)), - }).then(i => { - $i!.mutingNotificationTypes = i.mutingNotificationTypes; - }); +async function updateReceiveConfig(type, value) { + await os.apiWithDialog('i/update', { + notificationRecieveConfig: { + ...$i!.notificationRecieveConfig, + [type]: value, }, - }, 'closed'); + }).then(i => { + $i!.notificationRecieveConfig = i.notificationRecieveConfig; + }); } function onChangeSendReadMessage(v: boolean) { diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index 10972f3bc1..732a4cfeaa 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -65,9 +65,7 @@ const dev = _DEV_; let notifications = $ref<Misskey.entities.Notification[]>([]); -function onNotification(notification: Misskey.entities.Notification, isClient: boolean = false) { - if ($i.mutingNotificationTypes.includes(notification.type)) return; - +function onNotification(notification: Misskey.entities.Notification, isClient = false) { if (document.visibilityState === 'visible') { if (!isClient) { useStream().send('readNotification'); diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts index 034d675b0e..fcd595661b 100644 --- a/packages/frontend/src/ui/deck/deck-store.ts +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -28,7 +28,7 @@ export type Column = { listId?: string; channelId?: string; roleId?: string; - includingTypes?: typeof notificationTypes[number][]; + excludeTypes?: typeof notificationTypes[number][]; tl?: 'home' | 'local' | 'social' | 'global'; withRenotes?: boolean; withReplies?: boolean; diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue index 0455d43deb..b6bbf1fb55 100644 --- a/packages/frontend/src/ui/deck/notifications-column.vue +++ b/packages/frontend/src/ui/deck/notifications-column.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XColumn :column="column" :isStacked="isStacked" :menu="menu"> <template #header><i class="ti ti-bell" style="margin-right: 8px;"></i>{{ column.name }}</template> - <XNotifications :includeTypes="column.includingTypes"/> + <XNotifications :excludeTypes="props.column.excludeTypes"/> </XColumn> </template> @@ -25,13 +25,13 @@ const props = defineProps<{ }>(); function func() { - os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), { - includingTypes: props.column.includingTypes, + os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSelectWindow.vue')), { + excludeTypes: props.column.excludeTypes, }, { done: async (res) => { - const { includingTypes } = res; + const { excludeTypes } = res; updateColumn(props.column.id, { - includingTypes: includingTypes, + excludeTypes: excludeTypes, }); }, }, 'closed'); diff --git a/packages/frontend/src/widgets/WidgetNotifications.vue b/packages/frontend/src/widgets/WidgetNotifications.vue index def908362a..796578395f 100644 --- a/packages/frontend/src/widgets/WidgetNotifications.vue +++ b/packages/frontend/src/widgets/WidgetNotifications.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configureNotification()"><i class="ti ti-settings"></i></button></template> <div> - <XNotifications :includeTypes="widgetProps.includingTypes"/> + <XNotifications :excludeTypes="widgetProps.excludeTypes"/> </div> </MkContainer> </template> @@ -35,10 +35,10 @@ const widgetPropsDef = { type: 'number' as const, default: 300, }, - includingTypes: { + excludeTypes: { type: 'array' as const, hidden: true, - default: null, + default: [], }, }; @@ -54,12 +54,12 @@ const { widgetProps, configure, save } = useWidgetPropsManager(name, ); const configureNotification = () => { - os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), { - includingTypes: widgetProps.includingTypes, + os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSelectWindow.vue')), { + excludeTypes: widgetProps.excludeTypes, }, { done: async (res) => { - const { includingTypes } = res; - widgetProps.includingTypes = includingTypes; + const { excludeTypes } = res; + widgetProps.excludeTypes = excludeTypes; save(); }, }, 'closed'); diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index fbcebbebae..97b43ee635 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1545,7 +1545,7 @@ export type Endpoints = { receiveAnnouncementEmail?: boolean; alwaysMarkNsfw?: boolean; mutedWords?: string[][]; - mutingNotificationTypes?: Notification_2['type'][]; + notificationRecieveConfig?: any; emailNotificationTypes?: string[]; alsoKnownAs?: string[]; }; @@ -2475,7 +2475,22 @@ type MeDetailed = UserDetailed & { isDeleted: boolean; isExplorable: boolean; mutedWords: string[][]; - mutingNotificationTypes: string[]; + notificationRecieveConfig: { + [notificationType in typeof notificationTypes_2[number]]?: { + type: 'all'; + } | { + type: 'never'; + } | { + type: 'following'; + } | { + type: 'follower'; + } | { + type: 'mutualFollow'; + } | { + type: 'list'; + userListId: string; + }; + }; noCrawle: boolean; receiveAnnouncementEmail: boolean; usePasswordLessLogin: boolean; @@ -2958,7 +2973,8 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u // src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts // src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts // src/api.types.ts:631:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts -// src/entities.ts:580:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts +// src/entities.ts:107:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts +// src/entities.ts:595:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts index 46d790fe31..e69d8324a1 100644 --- a/packages/misskey-js/src/api.types.ts +++ b/packages/misskey-js/src/api.types.ts @@ -430,7 +430,7 @@ export type Endpoints = { receiveAnnouncementEmail?: boolean; alwaysMarkNsfw?: boolean; mutedWords?: string[][]; - mutingNotificationTypes?: Notification['type'][]; + notificationRecieveConfig?: any; emailNotificationTypes?: string[]; alsoKnownAs?: string[]; }; res: MeDetailed; }; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 63da881b39..41035ba2bf 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -1,4 +1,4 @@ -import { ModerationLogPayloads } from './consts.js'; +import { ModerationLogPayloads, notificationTypes } from './consts.js'; export type ID = string; export type DateString = string; @@ -104,7 +104,22 @@ export type MeDetailed = UserDetailed & { isDeleted: boolean; isExplorable: boolean; mutedWords: string[][]; - mutingNotificationTypes: string[]; + notificationRecieveConfig: { + [notificationType in typeof notificationTypes[number]]?: { + type: 'all'; + } | { + type: 'never'; + } | { + type: 'following'; + } | { + type: 'follower'; + } | { + type: 'mutualFollow'; + } | { + type: 'list'; + userListId: string; + }; + }; noCrawle: boolean; receiveAnnouncementEmail: boolean; usePasswordLessLogin: boolean;