diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index e7847ba74e..c33b897ca1 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -17,6 +17,7 @@ import { DebounceLoader } from '@/misc/loader.js'; import { IdService } from '@/core/IdService.js'; import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; +import { CacheService } from '@/core/CacheService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { ReactionService } from '../ReactionService.js'; @@ -101,6 +102,7 @@ export class NoteEntityService implements OnModuleInit { //private reactionService: ReactionService, //private reactionsBufferingService: ReactionsBufferingService, //private idService: IdService, + private cacheService: CacheService, ) { } @@ -376,7 +378,46 @@ export class NoteEntityService implements OnModuleInit { : this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.get(note.id) : { deltas: {}, pairs: [] }; - const reactions = this.reactionService.convertLegacyReactions(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions.deltas ?? {})); + + let reactions = this.reactionService.convertLegacyReactions(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions.deltas ?? {})); + if (meId) { + // ログインユーザーがいる場合のみ、ブロック/ミュートユーザーを除外して集計し直す + // 1. ブロック・ミュートリストを取得 + const [mutedIds, blockedIds] = await Promise.all([ + this.cacheService.userMutingsCache.fetch(meId), + this.cacheService.userBlockingCache.fetch(meId), + ]); + + // 2. DBとバッファから、フィルタリングに必要な全ユーザー/リアクションペアを取得 + // DBからの全リアクションレコードを取得 + const dbReactions = await this.noteReactionsRepository.findBy({ noteId: note.id }); + + // バッファリングされたペアを追加 + const bufferedPairs = bufferedReactions.pairs ?? []; // pairs: ([MiUser['id'], string])[] + + // 3. フィルタリングして再集計 + const filteredReactions: Record = {}; + + // 3a. DBからのリアクションをフィルタリング + for (const reaction of dbReactions) { + const isBlockedOrMuted = blockedIds.has(reaction.userId) || mutedIds.has(reaction.userId); + if (!isBlockedOrMuted) { + const reactionName = this.reactionService.convertLegacyReaction(reaction.reaction); + filteredReactions[reactionName] = (filteredReactions[reactionName] || 0) + 1; + } + } + + // 3b. バッファからのリアクションをフィルタリング + for (const [userId, reactionName] of bufferedPairs) { + const isBlockedOrMuted = blockedIds.has(userId) || mutedIds.has(userId); + if (!isBlockedOrMuted) { + const normalizedReaction = this.reactionService.convertLegacyReaction(reactionName); + filteredReactions[normalizedReaction] = (filteredReactions[normalizedReaction] || 0) + 1; + } + } + + reactions = filteredReactions; + } const reactionAndUserPairCache = note.reactionAndUserPairCache.concat(bufferedReactions.pairs.map(x => x.join('/'))); @@ -600,7 +641,7 @@ export class NoteEntityService implements OnModuleInit { } @bindThis - public async fetchDiffs(noteIds: MiNote['id'][]) { + public async fetchDiffs(noteIds: MiNote['id'][], meId: MiUser['id'] | null) { if (noteIds.length === 0) return []; const notes = await this.notesRepository.find({ @@ -617,12 +658,43 @@ export class NoteEntityService implements OnModuleInit { const bufferedReactionsMap = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany(noteIds) : null; - const packings = notes.map(note => { + const packings = notes.map(async note => { const bufferedReactions = bufferedReactionsMap?.get(note.id); //const reactionAndUserPairCache = note.reactionAndUserPairCache.concat(bufferedReactions.pairs.map(x => x.join('/'))); - const reactions = this.reactionService.convertLegacyReactions(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.deltas ?? {})); + let reactions = this.reactionService.convertLegacyReactions(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.deltas ?? {})); + if (meId) { + const [mutedIds, blockedIds] = await Promise.all([ + this.cacheService.userMutingsCache.fetch(meId), + this.cacheService.userBlockingCache.fetch(meId), + ]); + + // 2. DBとバッファから、フィルタリングに必要な全ユーザー/リアクションペアを取得 + const dbReactions = await this.noteReactionsRepository.findBy({ noteId: note.id }); + const bufferedPairs = bufferedReactions?.pairs ?? []; + + const filteredReactions: Record = {}; + + // 3a. DBからのリアクションをフィルタリング + for (const reaction of dbReactions) { + const isBlockedOrMuted = blockedIds.has(reaction.userId) || mutedIds.has(reaction.userId); + if (!isBlockedOrMuted) { + const reactionName = this.reactionService.convertLegacyReaction(reaction.reaction); + filteredReactions[reactionName] = (filteredReactions[reactionName] || 0) + 1; + } + } + // 3b. バッファからのリアクションをフィルタリング + for (const [userId, reactionName] of bufferedPairs) { + const isBlockedOrMuted = blockedIds.has(userId) || mutedIds.has(userId); + if (!isBlockedOrMuted) { + const normalizedReaction = this.reactionService.convertLegacyReaction(reactionName); + filteredReactions[normalizedReaction] = (filteredReactions[normalizedReaction] || 0) + 1; + } + } + + reactions = filteredReactions; + } const reactionEmojiNames = Object.keys(reactions) .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); diff --git a/packages/backend/src/core/entities/NoteReactionEntityService.ts b/packages/backend/src/core/entities/NoteReactionEntityService.ts index fe4926bfe3..80e4b5ac1e 100644 --- a/packages/backend/src/core/entities/NoteReactionEntityService.ts +++ b/packages/backend/src/core/entities/NoteReactionEntityService.ts @@ -9,10 +9,11 @@ import type { NoteReactionsRepository } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; -import type { OnModuleInit } from '@nestjs/common'; -import type { } from '@/models/Blocking.js'; +import { CacheService } from '@/core/CacheService.js'; import type { MiUser } from '@/models/User.js'; import type { MiNoteReaction } from '@/models/NoteReaction.js'; +import type { OnModuleInit } from '@nestjs/common'; +import type { } from '@/models/Blocking.js'; import type { ReactionService } from '../ReactionService.js'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; @@ -24,6 +25,7 @@ export class NoteReactionEntityService implements OnModuleInit { private noteEntityService: NoteEntityService; private reactionService: ReactionService; private idService: IdService; + private cacheService: CacheService; constructor( private moduleRef: ModuleRef, @@ -35,6 +37,7 @@ export class NoteReactionEntityService implements OnModuleInit { //private noteEntityService: NoteEntityService, //private reactionService: ReactionService, //private idService: IdService, + //private cacheService: CacheService, ) { } @@ -43,6 +46,7 @@ export class NoteReactionEntityService implements OnModuleInit { this.noteEntityService = this.moduleRef.get('NoteEntityService'); this.reactionService = this.moduleRef.get('ReactionService'); this.idService = this.moduleRef.get('IdService'); + this.cacheService = this.moduleRef.get('CacheService'); } @bindThis @@ -75,10 +79,30 @@ export class NoteReactionEntityService implements OnModuleInit { ): Promise[]> { const opts = Object.assign({ }, options); - const _users = reactions.map(({ user, userId }) => user ?? userId); + const meId = me ? me.id : null; + + // ログインユーザーがいる場合のみ、ブロック・ミュートリストを取得 + let muted: Set | null = null; + let blocked: Set | null = null; + let newReactions: MiNoteReaction[] = reactions; + + if (meId) { + [blocked, muted] = await Promise.all([ + this.cacheService.userBlockingCache.fetch(meId), // 自分がブロックしたユーザー + this.cacheService.userMutingsCache.fetch(meId), // 自分がミュートしたユーザー + ]); + + const filteredReactions = reactions.filter(reaction => { + const isBlockedOrMuted = blocked!.has(reaction.userId) || muted!.has(reaction.userId); + return !isBlockedOrMuted; + }); + + newReactions = filteredReactions; + } + const _users = newReactions.map(({ user, userId }) => user ?? userId); const _userMap = await this.userEntityService.packMany(_users, me) .then(users => new Map(users.map(u => [u.id, u]))); - return Promise.all(reactions.map(reaction => this.pack(reaction, me, opts, { packedUser: _userMap.get(reaction.userId) }))); + return Promise.all(newReactions.map(reaction => this.pack(reaction, me, opts, { packedUser: _userMap.get(reaction.userId) }))); } @bindThis @@ -94,6 +118,22 @@ export class NoteReactionEntityService implements OnModuleInit { }, options); const reaction = typeof src === 'object' ? src : await this.noteReactionsRepository.findOneByOrFail({ id: src }); + const meId = me ? me.id : null; + + // ログインユーザーがいる場合のみ、ブロック・ミュートリストを取得 + let muted: Set | null = null; + let blocked: Set | null = null; + + if (meId) { + [blocked, muted] = await Promise.all([ + this.cacheService.userBlockingCache.fetch(meId), // 自分がブロックしたユーザー + this.cacheService.userMutingsCache.fetch(meId), // 自分がミュートしたユーザー + ]); + + if (reaction.userId && (blocked?.has(reaction.userId) || muted?.has(reaction.userId))) { + return {} as any; // ミュート・ブロックされている場合は空オブジェクトを返す + } + } return { id: reaction.id, @@ -110,11 +150,24 @@ export class NoteReactionEntityService implements OnModuleInit { me?: { id: MiUser['id'] } | null | undefined, options?: object, ): Promise[]> { - const opts = Object.assign({ - }, options); - const _users = reactions.map(({ user, userId }) => user ?? userId); + const opts = Object.assign({}, options); + + // キャッシュからミュート・ブロック情報を取得 + const blocked = me ? await this.cacheService.userBlockedCache.fetch(me.id) : null; + const muted = me ? await this.cacheService.userMutingsCache.fetch(me.id) : null; + + // ミュート・ブロックされたユーザーのリアクションを除外 + const filteredReactions = reactions.filter(reaction => { + if (!me) return true; + return !(blocked?.has(reaction.userId) || muted?.has(reaction.userId)); + }); + + const _users = filteredReactions.map(({ user, userId }) => user ?? userId); const _userMap = await this.userEntityService.packMany(_users, me) .then(users => new Map(users.map(u => [u.id, u]))); - return Promise.all(reactions.map(reaction => this.packWithNote(reaction, me, opts, { packedUser: _userMap.get(reaction.userId) }))); + + return Promise.all(filteredReactions.map(reaction => + this.packWithNote(reaction, me, opts, { packedUser: _userMap.get(reaction.userId) }), + )); } } diff --git a/packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts b/packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts index e102bc1d4a..a03dd9fb70 100644 --- a/packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts +++ b/packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts @@ -60,7 +60,7 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, ) { super(meta, paramDef, async (ps, me) => { - return await this.noteEntityService.fetchDiffs(ps.noteIds); + return await this.noteEntityService.fetchDiffs(ps.noteIds, me?.id ?? null); }); } } diff --git a/packages/frontend/src/composables/use-note-capture.ts b/packages/frontend/src/composables/use-note-capture.ts index 25a9383cd5..253e0fe8e1 100644 --- a/packages/frontend/src/composables/use-note-capture.ts +++ b/packages/frontend/src/composables/use-note-capture.ts @@ -227,6 +227,16 @@ export function useNoteCapture(props: { function onReacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; } | null; }): void { let normalizedName = ctx.reaction.replace(/^:(\w+):$/, ':$1@.:'); normalizedName = normalizedName.match('\u200d') ? normalizedName : normalizedName.replace(/\ufe0f/g, ''); + const blockedIds: Set = ($i as any)?.blockedIds ?? new Set(); + const mutedIds: Set = ($i as any)?.mutedIds ?? new Set(); + const isBlocked = blockedIds.has(ctx.userId); + const isMuted = mutedIds.has(ctx.userId); + + if (isBlocked || isMuted) { + // ブロック/ミュートユーザーからのリアクションは集計に含めず、処理を終了 + return; + } + if (reactionUserMap.has(ctx.userId) && reactionUserMap.get(ctx.userId) === normalizedName) return; reactionUserMap.set(ctx.userId, normalizedName); @@ -247,6 +257,15 @@ export function useNoteCapture(props: { function onUnreacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; } | null; }): void { let normalizedName = ctx.reaction.replace(/^:(\w+):$/, ':$1@.:'); normalizedName = normalizedName.match('\u200d') ? normalizedName : normalizedName.replace(/\ufe0f/g, ''); + const blockedIds: Set = ($i as any)?.blockedIds ?? new Set(); + const mutedIds: Set = ($i as any)?.mutedIds ?? new Set(); + const isBlocked = blockedIds.has(ctx.userId); + const isMuted = mutedIds.has(ctx.userId); + + if (isBlocked || isMuted) { + // ブロック/ミュートユーザーによるリアクション削除は無視する + return; + } // 確実に一度リアクションされて取り消されている場合のみ処理をとめる(APIで初回読み込み→Streamでアップデート等の場合、reactionUserMapに情報がないため) if (reactionUserMap.has(ctx.userId) && reactionUserMap.get(ctx.userId) === noReaction) return;