diff options
| author | Mar0xy <marie@kaifa.ch> | 2023-10-03 15:20:49 +0200 |
|---|---|---|
| committer | Mar0xy <marie@kaifa.ch> | 2023-10-03 15:20:49 +0200 |
| commit | 38e35e14726ba5540a52c894131b38ee41f80b91 (patch) | |
| tree | 3deb7aa93df80b4403308d0d07ad81574f5b2899 /packages/backend/src/core | |
| parent | merge: increase comment length for files (#45) (diff) | |
| parent | fix: deck uiでuser listを見たときにリプライが表示されない (... (diff) | |
| download | sharkey-38e35e14726ba5540a52c894131b38ee41f80b91.tar.gz sharkey-38e35e14726ba5540a52c894131b38ee41f80b91.tar.bz2 sharkey-38e35e14726ba5540a52c894131b38ee41f80b91.zip | |
merge: upstream
Diffstat (limited to 'packages/backend/src/core')
21 files changed, 939 insertions, 231 deletions
diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index ec1d013922..ba3413007d 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -9,7 +9,7 @@ import { IsNull, In, MoreThan, Not } from 'typeorm'; import { bindThis } from '@/decorators.js'; import { DI } from '@/di-symbols.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; -import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListJoiningsRepository, UsersRepository } from '@/models/_.js'; +import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListMembershipsRepository, UsersRepository } from '@/models/_.js'; import type { RelationshipJobData, ThinUser } from '@/queue/types.js'; import { IdService } from '@/core/IdService.js'; @@ -42,8 +42,8 @@ export class AccountMoveService { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, @@ -215,40 +215,40 @@ export class AccountMoveService { @bindThis public async updateLists(src: ThinUser, dst: MiUser): Promise<void> { // Return if there is no list to be updated. - const oldJoinings = await this.userListJoiningsRepository.find({ + const oldMemberships = await this.userListMembershipsRepository.find({ where: { userId: src.id, }, }); - if (oldJoinings.length === 0) return; + if (oldMemberships.length === 0) return; - const existingUserListIds = await this.userListJoiningsRepository.find({ + const existingUserListIds = await this.userListMembershipsRepository.find({ where: { userId: dst.id, }, - }).then(joinings => joinings.map(joining => joining.userListId)); + }).then(memberships => memberships.map(membership => membership.userListId)); - const newJoinings: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map(); + const newMemberships: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map(); // 重複しないようにIDを生成 const genId = (): string => { let id: string; do { id = this.idService.genId(); - } while (newJoinings.has(id)); + } while (newMemberships.has(id)); return id; }; - for (const joining of oldJoinings) { - if (existingUserListIds.includes(joining.userListId)) continue; // skip if dst exists in this user's list - newJoinings.set(genId(), { + for (const membership of oldMemberships) { + if (existingUserListIds.includes(membership.userListId)) continue; // skip if dst exists in this user's list + newMemberships.set(genId(), { createdAt: new Date(), userId: dst.id, - userListId: joining.userListId, + userListId: membership.userListId, }); } - const arrayToInsert = Array.from(newJoinings.entries()).map(entry => ({ ...entry[1], id: entry[0] })); - await this.userListJoiningsRepository.insert(arrayToInsert); + const arrayToInsert = Array.from(newMemberships.entries()).map(entry => ({ ...entry[1], id: entry[0] })); + await this.userListMembershipsRepository.insert(arrayToInsert); // Have the proxy account follow the new account in the same way as UserListService.push if (this.userEntityService.isRemoteUser(dst)) { diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 841ce4b84a..95712b35b7 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -12,10 +12,10 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import * as Acct from '@/misc/acct.js'; import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; -import type { AntennasRepository, UserListJoiningsRepository } from '@/models/_.js'; +import type { AntennasRepository, UserListMembershipsRepository } 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() @@ -24,8 +24,8 @@ export class AntennaService implements OnApplicationShutdown { private antennas: MiAntenna[]; constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, @Inject(DI.redisForSub) private redisForSub: Redis.Redis, @@ -33,8 +33,8 @@ export class AntennaService implements OnApplicationShutdown { @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, private utilityService: UtilityService, private globalEventService: GlobalEventService, @@ -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({ @@ -81,7 +81,7 @@ export class AntennaService implements OnApplicationShutdown { const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const))); const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna); - const redisPipeline = this.redisClient.pipeline(); + const redisPipeline = this.redisForTimelines.pipeline(); for (const antenna of matchedAntennas) { redisPipeline.xadd( @@ -108,7 +108,7 @@ export class AntennaService implements OnApplicationShutdown { if (antenna.src === 'home') { // TODO } else if (antenna.src === 'list') { - const listUsers = (await this.userListJoiningsRepository.findBy({ + const listUsers = (await this.userListMembershipsRepository.findBy({ userListId: antenna.userListId!, })).map(x => x.userId); diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 6ca684d53c..22c510cc37 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -5,13 +5,13 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; -import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; 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() @@ -25,7 +25,7 @@ export class CacheService implements OnApplicationShutdown { public userBlockingCache: RedisKVCache<Set<string>>; public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ public renoteMutingsCache: RedisKVCache<Set<string>>; - public userFollowingsCache: RedisKVCache<Set<string>>; + public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>; public userFollowingChannelsCache: RedisKVCache<Set<string>>; constructor( @@ -136,12 +136,18 @@ export class CacheService implements OnApplicationShutdown { fromRedisConverter: (value) => new Set(JSON.parse(value)), }); - this.userFollowingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowings', { + this.userFollowingsCache = new RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>(this.redisClient, 'userFollowings', { lifetime: 1000 * 60 * 30, // 30m memoryCacheLifetime: 1000 * 60, // 1m - fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))), - toRedisConverter: (value) => JSON.stringify(Array.from(value)), - fromRedisConverter: (value) => new Set(JSON.parse(value)), + fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId', 'withReplies'] }).then(xs => { + const obj: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {}; + for (const x of xs) { + obj[x.followeeId] = { withReplies: x.withReplies }; + } + return obj; + }), + toRedisConverter: (value) => JSON.stringify(value), + fromRedisConverter: (value) => JSON.parse(value), }); this.userFollowingChannelsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowingChannels', { @@ -160,7 +166,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': { @@ -188,6 +194,7 @@ export class CacheService implements OnApplicationShutdown { if (follower) follower.followingCount++; const followee = this.userByIdCache.get(body.followeeId); if (followee) followee.followersCount++; + this.userFollowingsCache.delete(body.followerId); break; } default: diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 7d6b76e9c2..955b9fdcf6 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -47,6 +47,7 @@ import { SignupService } from './SignupService.js'; import { WebAuthnService } from './WebAuthnService.js'; import { UserBlockingService } from './UserBlockingService.js'; import { CacheService } from './CacheService.js'; +import { UserService } from './UserService.js'; import { UserFollowingService } from './UserFollowingService.js'; import { UserKeypairService } from './UserKeypairService.js'; import { UserListService } from './UserListService.js'; @@ -175,6 +176,7 @@ const $SignupService: Provider = { provide: 'SignupService', useExisting: Signup const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService }; const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService }; const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService }; +const $UserService: Provider = { provide: 'UserService', useExisting: UserService }; const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService }; const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService }; const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService }; @@ -306,6 +308,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting WebAuthnService, UserBlockingService, CacheService, + UserService, UserFollowingService, UserKeypairService, UserListService, @@ -430,6 +433,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $WebAuthnService, $UserBlockingService, $CacheService, + $UserService, $UserFollowingService, $UserKeypairService, $UserListService, @@ -555,6 +559,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting WebAuthnService, UserBlockingService, CacheService, + UserService, UserFollowingService, UserKeypairService, UserListService, @@ -678,6 +683,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $WebAuthnService, $UserBlockingService, $CacheService, + $UserService, $UserFollowingService, $UserKeypairService, $UserListService, diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index f9368eb6d3..d1cd2e97c3 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'; import type { Config } from '@/config.js'; 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..8fb34fd637 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -5,7 +5,7 @@ import { setImmediate } from 'node:timers/promises'; import * as mfm from 'mfm-js'; -import { In, DataSource } from 'typeorm'; +import { In, DataSource, IsNull, LessThan } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import RE2 from 're2'; @@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import type { ChannelsRepository, FollowingsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiApp } from '@/models/App.js'; import { concat } from '@/misc/prelude/array.js'; @@ -54,8 +54,6 @@ import { RoleService } from '@/core/RoleService.js'; import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; -const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5); - type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; class NotificationManager { @@ -110,9 +108,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); } } } @@ -158,8 +155,8 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.db) private db: DataSource, - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -176,8 +173,8 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - @Inject(DI.mutedNotesRepository) - private mutedNotesRepository: MutedNotesRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, @@ -188,6 +185,9 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, private idService: IdService, @@ -335,7 +335,7 @@ export class NoteCreateService implements OnApplicationShutdown { const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); if (data.channel) { - this.redisClient.xadd( + this.redisForTimelines.xadd( `channelTimeline:${data.channel.id}`, 'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(), '*', @@ -481,26 +481,13 @@ export class NoteCreateService implements OnApplicationShutdown { // Increment notes count (user) this.incNotesCountOfUser(user); - // Word mute - mutedWordsCache.fetch(() => this.userProfilesRepository.find({ - where: { - enableWordMute: true, - }, - select: ['userId', 'mutedWords'], - })).then(us => { - for (const u of us) { - checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => { - if (shouldMute) { - this.mutedNotesRepository.insert({ - id: this.idService.genId(), - userId: u.userId, - noteId: note.id, - reason: 'word', - }); - } - }); - } - }); + if (data.visibility === 'public' || data.visibility === 'home') { + this.pushToTl(note, user); + } else if (data.visibility === 'followers') { + this.pushToTl(note, user); + } else if (data.visibility === 'specified') { + // TODO + } this.antennaService.addNoteToAntennas(note, user); @@ -509,15 +496,16 @@ export class NoteCreateService implements OnApplicationShutdown { } if (data.reply == null) { + // TODO: キャッシュ this.followingsRepository.findBy({ followeeId: user.id, notify: 'normal', }).then(followings => { for (const following of followings) { + // TODO: ワードミュート考慮 this.notificationService.createNotification(following.followerId, 'note', { - notifierId: user.id, noteId: note.id, - }); + }, user.id); } }); } @@ -814,6 +802,205 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis + private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { + const redisPipeline = this.redisForTimelines.pipeline(); + + if (note.channelId) { + const channelFollowings = await this.channelFollowingsRepository.find({ + where: { + followeeId: note.channelId, + }, + select: ['followerId'], + }); + + for (const channelFollowing of channelFollowings) { + redisPipeline.xadd( + `homeTimeline:${channelFollowing.followerId}`, + 'MAXLEN', '~', '200', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + `homeTimelineWithFiles:${channelFollowing.followerId}`, + 'MAXLEN', '~', '100', + '*', + 'note', note.id); + } + } + } else { + // TODO: キャッシュ? + const followings = await this.followingsRepository.find({ + where: { + followeeId: user.id, + followerHost: IsNull(), + isFollowerHibernated: false, + }, + select: ['followerId', 'withReplies'], + }); + + const userListMemberships = await this.userListMembershipsRepository.find({ + where: { + userId: user.id, + }, + select: ['userListId', 'withReplies'], + }); + + // TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする + for (const following of followings) { + // 自分自身以外への返信 + if (note.replyId && note.replyUserId !== note.userId) { + if (!following.withReplies) continue; + } + + redisPipeline.xadd( + `homeTimeline:${following.followerId}`, + 'MAXLEN', '~', '200', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + `homeTimelineWithFiles:${following.followerId}`, + 'MAXLEN', '~', '100', + '*', + 'note', note.id); + } + } + + // TODO + //if (note.visibility === 'followers') { + // // TODO: 重そうだから何とかしたい Set 使う? + // userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListUserId)); + //} + + for (const userListMembership of userListMemberships) { + // 自分自身以外への返信 + if (note.replyId && note.replyUserId !== note.userId) { + if (!userListMembership.withReplies) continue; + } + + redisPipeline.xadd( + `userListTimeline:${userListMembership.userListId}`, + 'MAXLEN', '~', '200', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + `userListTimelineWithFiles:${userListMembership.userListId}`, + 'MAXLEN', '~', '100', + '*', + 'note', note.id); + } + } + + { // 自分自身のHTL + redisPipeline.xadd( + `homeTimeline:${user.id}`, + 'MAXLEN', '~', '200', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + `homeTimelineWithFiles:${user.id}`, + 'MAXLEN', '~', '100', + '*', + 'note', note.id); + } + } + + if (note.visibility === 'public' || note.visibility === 'home') { + // 自分自身以外への返信 + if (note.replyId && note.replyUserId !== note.userId) { + redisPipeline.xadd( + `userTimelineWithReplies:${user.id}`, + 'MAXLEN', '~', '1000', + '*', + 'note', note.id); + } else { + redisPipeline.xadd( + `userTimeline:${user.id}`, + 'MAXLEN', '~', '1000', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + `userTimelineWithFiles:${user.id}`, + 'MAXLEN', '~', '500', + '*', + 'note', note.id); + } + + if (note.visibility === 'public' && note.userHost == null) { + redisPipeline.xadd( + 'localTimeline', + 'MAXLEN', '~', '1000', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + 'localTimelineWithFiles', + 'MAXLEN', '~', '500', + '*', + 'note', note.id); + } + } + } + } + + if (Math.random() < 0.1) { + process.nextTick(() => { + this.checkHibernation(followings); + }); + } + } + + redisPipeline.exec(); + } + + @bindThis + public async checkHibernation(followings: MiFollowing[]) { + if (followings.length === 0) return; + + const shuffle = (array: MiFollowing[]) => { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; + }; + + // ランダムに最大1000件サンプリング + const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000)); + + const hibernatedUsers = await this.usersRepository.find({ + where: { + id: In(samples.map(x => x.followerId)), + lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))), + }, + select: ['id'], + }); + + if (hibernatedUsers.length > 0) { + this.usersRepository.update({ + id: In(hibernatedUsers.map(x => x.id)), + }, { + isHibernated: true, + }); + + this.followingsRepository.update({ + followerId: In(hibernatedUsers.map(x => x.id)), + }, { + isFollowerHibernated: true, + }); + } + } + + @bindThis public dispose(): void { this.#shutdownController.abort(); } diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index 81a4930135..f331c8a9a8 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -5,7 +5,7 @@ import { setImmediate } from 'node:timers/promises'; import * as mfm from 'mfm-js'; -import { In } from 'typeorm'; +import { In, LessThan } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import RE2 from 're2'; @@ -14,7 +14,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import type { NoteEditRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { NoteEditRepository, ChannelsRepository, InstancesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository, UserListMembershipsRepository, ChannelFollowingsRepository, MiFollowing } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiApp } from '@/models/App.js'; import { concat } from '@/misc/prelude/array.js'; @@ -49,8 +49,6 @@ import { RoleService } from '@/core/RoleService.js'; import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; -const mutedWordsCache = new MemorySingleCache<{ userId: MiUserProfile['userId']; mutedWords: MiUserProfile['mutedWords']; }[]>(1000 * 60 * 5); - type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; class NotificationManager { @@ -105,9 +103,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); } } } @@ -152,8 +149,8 @@ export class NoteEditService implements OnApplicationShutdown { @Inject(DI.config) private config: Config, - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.redisForTimelines) + private redisForTimelines: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -170,12 +167,15 @@ export class NoteEditService implements OnApplicationShutdown { @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - @Inject(DI.mutedNotesRepository) - private mutedNotesRepository: MutedNotesRepository, - @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + @Inject(DI.noteThreadMutingsRepository) private noteThreadMutingsRepository: NoteThreadMutingsRepository, @@ -423,7 +423,7 @@ export class NoteEditService implements OnApplicationShutdown { await this.notesRepository.update(oldnote.id, note); if (data.channel) { - this.redisClient.xadd( + this.redisForTimelines.xadd( `channelTimeline:${data.channel.id}`, 'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(), '*', @@ -461,27 +461,6 @@ export class NoteEditService implements OnApplicationShutdown { this.hashtagService.updateHashtags(user, tags); } - // Word mute - mutedWordsCache.fetch(() => this.userProfilesRepository.find({ - where: { - enableWordMute: true, - }, - select: ['userId', 'mutedWords'], - })).then(us => { - for (const u of us) { - checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => { - if (shouldMute) { - this.mutedNotesRepository.insert({ - id: this.idService.genId(), - userId: u.userId, - noteId: note.id, - reason: 'word', - }); - } - }); - } - }); - if (data.poll && data.poll.expiresAt) { const delay = data.poll.expiresAt.getTime() - Date.now(); this.queueService.endedPollNotificationQueue.add(note.id, { @@ -492,6 +471,14 @@ export class NoteEditService implements OnApplicationShutdown { }); } + if (data.visibility === 'public' || data.visibility === 'home') { + this.pushToTl(note, user); + } else if (data.visibility === 'followers') { + this.pushToTl(note, user); + } else if (data.visibility === 'specified') { + // TODO + } + if (!silent) { if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); @@ -524,7 +511,8 @@ export class NoteEditService implements OnApplicationShutdown { const noteObj = await this.noteEntityService.pack(note); this.globalEventService.publishNoteStream(note.id, 'updated', { - updatedAt: note.updatedAt!, + cw: note.cw, + text: note.text!, }); this.roleService.addNoteToRoleTimeline(noteObj); @@ -652,6 +640,163 @@ export class NoteEditService implements OnApplicationShutdown { } @bindThis + private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { + const redisPipeline = this.redisForTimelines.pipeline(); + + if (note.channelId) { + const channelFollowings = await this.channelFollowingsRepository.find({ + where: { + followeeId: note.channelId, + }, + select: ['followerId'], + }); + + for (const channelFollowing of channelFollowings) { + redisPipeline.xadd( + `homeTimeline:${channelFollowing.followerId}`, + 'MAXLEN', '~', '200', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + `homeTimelineWithFiles:${channelFollowing.followerId}`, + 'MAXLEN', '~', '100', + '*', + 'note', note.id); + } + } + } else { + // TODO: キャッシュ? + + const userListMemberships = await this.userListMembershipsRepository.find({ + where: { + userId: user.id, + }, + select: ['userListId', 'withReplies'], + }); + + // TODO + //if (note.visibility === 'followers') { + // // TODO: 重そうだから何とかしたい Set 使う? + // userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListUserId)); + //} + + for (const userListMembership of userListMemberships) { + // 自分自身以外への返信 + if (note.replyId && note.replyUserId !== note.userId) { + if (!userListMembership.withReplies) continue; + } + + redisPipeline.xadd( + `userListTimeline:${userListMembership.userListId}`, + 'MAXLEN', '~', '200', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + `userListTimelineWithFiles:${userListMembership.userListId}`, + 'MAXLEN', '~', '100', + '*', + 'note', note.id); + } + } + + { // 自分自身のHTL + redisPipeline.xadd( + `homeTimeline:${user.id}`, + 'MAXLEN', '~', '200', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + `homeTimelineWithFiles:${user.id}`, + 'MAXLEN', '~', '100', + '*', + 'note', note.id); + } + } + + if (note.visibility === 'public' || note.visibility === 'home') { + // 自分自身以外への返信 + if (note.replyId && note.replyUserId !== note.userId) { + redisPipeline.xadd( + `userTimelineWithReplies:${user.id}`, + 'MAXLEN', '~', '1000', + '*', + 'note', note.id); + } else { + redisPipeline.xadd( + `userTimeline:${user.id}`, + 'MAXLEN', '~', '1000', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + `userTimelineWithFiles:${user.id}`, + 'MAXLEN', '~', '500', + '*', + 'note', note.id); + } + + if (note.visibility === 'public' && note.userHost == null) { + redisPipeline.xadd( + 'localTimeline', + 'MAXLEN', '~', '1000', + '*', + 'note', note.id); + + if (note.fileIds.length > 0) { + redisPipeline.xadd( + 'localTimelineWithFiles', + 'MAXLEN', '~', '500', + '*', + 'note', note.id); + } + } + } + } + } + + redisPipeline.exec(); + } + + @bindThis + public async checkHibernation(followings: MiFollowing[]) { + if (followings.length === 0) return; + + const shuffle = (array: MiFollowing[]) => { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; + }; + + // ランダムに最大1000件サンプリング + const samples = shuffle(followings).slice(0, Math.min(followings.length, 1000)); + + const hibernatedUsers = await this.usersRepository.find({ + where: { + id: In(samples.map(x => x.followerId)), + lastActiveDate: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 50))), + }, + select: ['id'], + }); + + if (hibernatedUsers.length > 0) { + this.usersRepository.update({ + id: In(hibernatedUsers.map(x => x.id)), + }, { + isHibernated: true, + }); + } + } + + @bindThis private isSensitive(note: Option, sensitiveWord: string[]): boolean { if (sensitiveWord.length > 0) { const text = note.cw ?? note.text ?? ''; diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 258ae44f7d..32d54d2576 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,59 @@ 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; - if (data.notifierId) { - if (notifieeId === data.notifierId) { + // 古いMisskeyバージョンのキャッシュが残っている可能性がある + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const recieveConfig = (profile.notificationRecieveConfig ?? {})[type]; + if (recieveConfig?.type === 'never') { + return null; + } + + 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 => Object.hasOwn(followings, notifierId)); + if (!isFollowing) { + return null; + } + } else if (recieveConfig?.type === 'follower') { + const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)); + if (!isFollower) { + return null; + } + } else if (recieveConfig?.type === 'mutualFollow') { + const [isFollowing, isFollower] = await Promise.all([ + this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)), + this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, 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 +151,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/QueryService.ts b/packages/backend/src/core/QueryService.ts index 9145726f86..18bd49286e 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets, ObjectLiteral } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { MiUser } from '@/models/User.js'; -import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js'; +import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import type { SelectQueryBuilder } from 'typeorm'; @@ -23,9 +23,6 @@ export class QueryService { @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, - @Inject(DI.mutedNotesRepository) - private mutedNotesRepository: MutedNotesRepository, - @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, @@ -109,39 +106,6 @@ export class QueryService { } @bindThis - public generateChannelQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void { - if (me == null) { - q.andWhere('note.channelId IS NULL'); - } else { - q.leftJoinAndSelect('note.channel', 'channel'); - - const channelFollowingQuery = this.channelFollowingsRepository.createQueryBuilder('channelFollowing') - .select('channelFollowing.followeeId') - .where('channelFollowing.followerId = :followerId', { followerId: me.id }); - - q.andWhere(new Brackets(qb => { qb - // チャンネルのノートではない - .where('note.channelId IS NULL') - // または自分がフォローしているチャンネルのノート - .orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`); - })); - - q.setParameters(channelFollowingQuery.getParameters()); - } - } - - @bindThis - public generateMutedNoteQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { - const mutedQuery = this.mutedNotesRepository.createQueryBuilder('muted') - .select('muted.noteId') - .where('muted.userId = :userId', { userId: me.id }); - - q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); - - q.setParameters(mutedQuery.getParameters()); - } - - @bindThis public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') .select('threadMuted.threadId') @@ -213,32 +177,6 @@ export class QueryService { } @bindThis - public generateRepliesQuery(q: SelectQueryBuilder<any>, withReplies: boolean, me?: Pick<MiUser, 'id'> | null): void { - if (me == null) { - q.andWhere(new Brackets(qb => { qb - .where('note.replyId IS NULL') // 返信ではない - .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = note.userId'); - })); - })); - } else if (!withReplies) { - q.andWhere(new Brackets(qb => { qb - .where('note.replyId IS NULL') // 返信ではない - .orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信 - .orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.userId = :meId', { meId: me.id }); - })) - .orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信 - .where('note.replyId IS NOT NULL') - .andWhere('note.replyUserId = note.userId'); - })); - })); - } - } - - @bindThis public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void { // This code must always be synchronized with the checks in Notes.isVisibleForMe. if (me == null) { 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 934b7d676b..f2bd9de5ee 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'; @@ -32,6 +32,7 @@ export type RolePolicies = { inviteExpirationTime: number; canManageCustomEmojis: boolean; canSearchNotes: boolean; + canUseTranslator: boolean; canHideAds: boolean; driveCapacityMb: number; alwaysMarkNsfw: boolean; @@ -56,6 +57,7 @@ export const DEFAULT_POLICIES: RolePolicies = { inviteExpirationTime: 0, canManageCustomEmojis: false, canSearchNotes: false, + canUseTranslator: true, canHideAds: false, driveCapacityMb: 100, alwaysMarkNsfw: false, @@ -114,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(); @@ -300,6 +302,7 @@ export class RoleService implements OnApplicationShutdown { inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)), canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)), canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), + canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts index 37031e341e..087dfd9214 100644 --- a/packages/backend/src/core/UserBlockingService.ts +++ b/packages/backend/src/core/UserBlockingService.ts @@ -11,7 +11,7 @@ import type { MiBlocking } from '@/models/Blocking.js'; import { QueueService } from '@/core/QueueService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; -import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/_.js'; +import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListMembershipsRepository } from '@/models/_.js'; import Logger from '@/logger.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; @@ -38,8 +38,8 @@ export class UserBlockingService implements OnModuleInit { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, private cacheService: CacheService, private userEntityService: UserEntityService, @@ -149,7 +149,7 @@ export class UserBlockingService implements OnModuleInit { }); for (const userList of userLists) { - await this.userListJoiningsRepository.delete({ + await this.userListMembershipsRepository.delete({ userListId: userList.id, userId: user.id, }); diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index 5b2b0205d9..beffcc2e9c 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -123,7 +123,11 @@ export class UserFollowingService implements OnModuleInit { // フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or // フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである // 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく - if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee))) { + if ( + followee.isLocked || + (followeeProfile.carefulBot && follower.isBot) || + (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee) && process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING !== 'true') + ) { let autoAccept = false; // 鍵アカウントであっても、既にフォローされていた場合はスルー @@ -230,8 +234,7 @@ export class UserFollowingService implements OnModuleInit { // 通知を作成 this.notificationService.createNotification(follower.id, 'followRequestAccepted', { - notifierId: followee.id, - }); + }, followee.id); } if (alreadyFollowed) return; @@ -304,8 +307,7 @@ export class UserFollowingService implements OnModuleInit { // 通知を作成 this.notificationService.createNotification(followee.id, 'follow', { - notifierId: follower.id, - }); + }, follower.id); } } @@ -488,9 +490,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..bece1e442e 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -3,11 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; -import type { UserListJoiningsRepository } from '@/models/_.js'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import type { UserListMembershipsRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; import type { MiUserList } from '@/models/UserList.js'; -import type { MiUserListJoining } from '@/models/UserListJoining.js'; +import type { MiUserListMembership } from '@/models/UserListMembership.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; @@ -16,14 +17,24 @@ 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.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, private userEntityService: UserEntityService, private idService: IdService, @@ -32,24 +43,63 @@ 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.userListMembershipsRepository.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) { - const currentCount = await this.userListJoiningsRepository.countBy({ + 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.userListMembershipsRepository.countBy({ userListId: list.id, }); if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) { throw new UserListService.TooManyUsersError(); } - await this.userListJoiningsRepository.insert({ + await this.userListMembershipsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), userId: target.id, userListId: list.id, - } as MiUserListJoining); + } as MiUserListMembership); + this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id }); this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target)); // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする @@ -60,4 +110,44 @@ export class UserListService { } } } + + @bindThis + public async removeMember(target: MiUser, list: MiUserList) { + await this.userListMembershipsRepository.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 async updateMembership(target: MiUser, list: MiUserList, options: { withReplies?: boolean }) { + const membership = await this.userListMembershipsRepository.findOneBy({ + userId: target.id, + userListId: list.id, + }); + + if (membership == null) { + throw new Error('User is not a member of the list'); + } + + await this.userListMembershipsRepository.update({ + id: membership.id, + }, { + withReplies: options.withReplies, + }); + } + + @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/UserService.ts b/packages/backend/src/core/UserService.ts new file mode 100644 index 0000000000..d16e1be615 --- /dev/null +++ b/packages/backend/src/core/UserService.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { FollowingsRepository, UsersRepository } from '@/models/_.js'; +import type { MiUser } from '@/models/User.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; + +@Injectable() +export class UserService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + ) { + } + + @bindThis + public async updateLastActiveDate(user: MiUser): Promise<void> { + if (user.isHibernated) { + const result = await this.usersRepository.createQueryBuilder().update() + .set({ + lastActiveDate: new Date(), + }) + .where('id = :id', { id: user.id }) + .returning('*') + .execute() + .then((response) => { + return response.raw[0]; + }); + const wokeUp = result.isHibernated; + if (wokeUp) { + this.usersRepository.update(user.id, { + isHibernated: false, + }); + this.followingsRepository.update({ + followerId: user.id, + }, { + isFollowerHibernated: false, + }); + } + } else { + this.usersRepository.update(user.id, { + lastActiveDate: new Date(), + }); + } + } +} 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/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index b3e140e588..4444e4118d 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -102,13 +102,13 @@ export class NoteEntityService implements OnModuleInit { } else if (meId === packedNote.userId) { hide = false; } else if (packedNote.reply && (meId === packedNote.reply.userId)) { - // 自分の投稿に対するリプライ + // 自分の投稿に対するリプライ hide = false; } else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) { - // 自分へのメンション + // 自分へのメンション hide = false; } else { - // フォロワーかどうか + // フォロワーかどうか const isFollowing = await this.followingsRepository.exist({ where: { followeeId: packedNote.userId, @@ -312,6 +312,7 @@ export class NoteEntityService implements OnModuleInit { const packed: Packed<'Note'> = await awaitAll({ id: note.id, createdAt: note.createdAt.toISOString(), + updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined, userId: note.userId, user: this.userEntityService.pack(note.user ?? note.userId, me, { detail: false, @@ -342,7 +343,6 @@ export class NoteEntityService implements OnModuleInit { mentions: note.mentions && note.mentions.length > 0 ? note.mentions : undefined, uri: note.uri ?? undefined, url: note.url ?? undefined, - updatedAt: note.updatedAt != null ? note.updatedAt.toISOString() : undefined, ...(meId ? { myReaction: this.populateMyReaction(note, meId, options?._hint_), } : {}), diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index e1ba71ffd1..b8fb9f8db7 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -458,7 +458,8 @@ export class UserEntityService implements OnModuleInit { hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), mutedWords: profile!.mutedWords, mutedInstances: profile!.mutedInstances, - mutingNotificationTypes: profile!.mutingNotificationTypes, + mutingNotificationTypes: [], // 後方互換性のため + notificationRecieveConfig: profile!.notificationRecieveConfig, emailNotificationTypes: profile!.emailNotificationTypes, achievements: profile!.achievements, loggedInDays: profile!.loggedInDates.length, @@ -492,6 +493,7 @@ export class UserEntityService implements OnModuleInit { isMuted: relation.isMuted, isRenoteMuted: relation.isRenoteMuted, notify: relation.following?.notify ?? 'none', + withReplies: relation.following?.withReplies ?? false, } : {}), } as Promiseable<Packed<'User'>> as Promiseable<IsMeAndIsUserDetailed<ExpectsMe, D>>; diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts index a7f2885194..06b6e852b1 100644 --- a/packages/backend/src/core/entities/UserListEntityService.ts +++ b/packages/backend/src/core/entities/UserListEntityService.ts @@ -5,11 +5,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { UserListJoiningsRepository, UserListsRepository } from '@/models/_.js'; +import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/Blocking.js'; import type { MiUserList } from '@/models/UserList.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class UserListEntityService { @@ -17,8 +18,10 @@ export class UserListEntityService { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - @Inject(DI.userListJoiningsRepository) - private userListJoiningsRepository: UserListJoiningsRepository, + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, + + private userEntityService: UserEntityService, ) { } @@ -28,7 +31,7 @@ export class UserListEntityService { ): Promise<Packed<'UserList'>> { const userList = typeof src === 'object' ? src : await this.userListsRepository.findOneByOrFail({ id: src }); - const users = await this.userListJoiningsRepository.findBy({ + const users = await this.userListMembershipsRepository.findBy({ userListId: userList.id, }); @@ -40,5 +43,18 @@ export class UserListEntityService { isPublic: userList.isPublic, }; } + + @bindThis + public async packMembershipsMany( + memberships: MiUserListMembership[], + ) { + return Promise.all(memberships.map(async x => ({ + id: x.id, + createdAt: x.createdAt.toISOString(), + userId: x.userId, + user: await this.userEntityService.pack(x.userId), + withReplies: x.withReplies, + }))); + } } |