From 685847e1b6df06c86f9aafbe3a7d812919c01e43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 7 Nov 2025 12:04:42 +0000 Subject: [PATCH] Add mutingType field to Muting model and update related code Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- .../migration/1762516776421-add-muting-type.js | 16 ++++++++++++++++ packages/backend/src/core/CacheService.ts | 12 ++++++------ .../src/core/FanoutTimelineEndpointService.ts | 4 +++- packages/backend/src/core/NotificationService.ts | 3 ++- packages/backend/src/core/SearchService.ts | 6 ++++-- packages/backend/src/core/UserMutingService.ts | 3 ++- .../src/core/entities/MutingEntityService.ts | 1 + .../core/entities/NotificationEntityService.ts | 9 +++++---- packages/backend/src/models/Muting.ts | 6 ++++++ .../backend/src/models/json-schema/muting.ts | 5 +++++ .../src/server/api/endpoints/mute/create.ts | 8 +++++++- .../src/server/api/endpoints/notes/featured.ts | 6 ++++-- .../server/api/endpoints/users/featured-notes.ts | 7 +++++-- .../src/server/api/endpoints/users/reactions.ts | 3 ++- .../backend/src/server/api/stream/Connection.ts | 6 ++++-- 15 files changed, 72 insertions(+), 23 deletions(-) create mode 100644 packages/backend/migration/1762516776421-add-muting-type.js diff --git a/packages/backend/migration/1762516776421-add-muting-type.js b/packages/backend/migration/1762516776421-add-muting-type.js new file mode 100644 index 0000000000..2b7bed1b68 --- /dev/null +++ b/packages/backend/migration/1762516776421-add-muting-type.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddMutingType1762516776421 { + name = 'AddMutingType1762516776421' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "muting" ADD "mutingType" varchar(128) NOT NULL DEFAULT 'all'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "muting" DROP COLUMN "mutingType"`); + } +} diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 6725ebe75b..b82dcccc3d 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; -import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js'; +import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing, MiMuting } from '@/models/_.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; @@ -21,7 +21,7 @@ export class CacheService implements OnApplicationShutdown { public localUserByIdCache: MemoryKVCache; public uriPersonCache: MemoryKVCache; public userProfileCache: RedisKVCache; - public userMutingsCache: RedisKVCache>; + public userMutingsCache: RedisKVCache>>; public userBlockingCache: RedisKVCache>; public userBlockedCache: RedisKVCache>; // NOTE: 「被」Blockキャッシュ public renoteMutingsCache: RedisKVCache>; @@ -69,12 +69,12 @@ export class CacheService implements OnApplicationShutdown { fromRedisConverter: (value) => JSON.parse(value), // TODO: date型の考慮 }); - this.userMutingsCache = new RedisKVCache>(this.redisClient, 'userMutings', { + this.userMutingsCache = new RedisKVCache>>(this.redisClient, 'userMutings', { lifetime: 1000 * 60 * 30, // 30m memoryCacheLifetime: 1000 * 60, // 1m - fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))), - toRedisConverter: (value) => JSON.stringify(Array.from(value)), - fromRedisConverter: (value) => new Set(JSON.parse(value)), + fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key }, select: ['muteeId', 'mutingType'] }).then(xs => new Map(xs.map(x => [x.muteeId, { mutingType: x.mutingType }]))), + toRedisConverter: (value) => JSON.stringify(Array.from(value.entries())), + fromRedisConverter: (value) => new Map(JSON.parse(value)), }); this.userBlockingCache = new RedisKVCache>(this.redisClient, 'userBlocking', { diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index e39d70d683..f06661dc27 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -111,7 +111,7 @@ export class FanoutTimelineEndpointService { if (ps.me) { const me = ps.me; const [ - userIdsWhoMeMuting, + userIdsWhoMeMutingMap, userIdsWhoMeMutingRenotes, userIdsWhoBlockingMe, userMutedInstances, @@ -124,6 +124,8 @@ export class FanoutTimelineEndpointService { this.channelMutingService.mutingChannelsCache.fetch(me.id), ]); + const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys()); + const parentFilter = filter; filter = (note) => { if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false; diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index eeade4569b..65d7beb442 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -108,7 +108,8 @@ export class NotificationService implements OnApplicationShutdown { } const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId); - if (mutings.has(notifierId)) { + const muting = mutings.get(notifierId); + if (muting && muting.mutingType === 'all') { return null; } diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index 71dc718916..7077e6f63a 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -286,14 +286,16 @@ export class SearchService { } const [ - userIdsWhoMeMuting, + userIdsWhoMeMutingMap, userIdsWhoBlockingMe, ] = me ? await Promise.all([ this.cacheService.userMutingsCache.fetch(me.id), this.cacheService.userBlockedCache.fetch(me.id), ]) - : [new Set(), new Set()]; + : [new Map(), new Set()]; + + const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys()); const query = this.notesRepository.createQueryBuilder('note') .innerJoinAndSelect('note.user', 'user') diff --git a/packages/backend/src/core/UserMutingService.ts b/packages/backend/src/core/UserMutingService.ts index 06643be5fb..f88c48b37a 100644 --- a/packages/backend/src/core/UserMutingService.ts +++ b/packages/backend/src/core/UserMutingService.ts @@ -24,12 +24,13 @@ export class UserMutingService { } @bindThis - public async mute(user: MiUser, target: MiUser, expiresAt: Date | null = null): Promise { + public async mute(user: MiUser, target: MiUser, expiresAt: Date | null = null, mutingType: 'all' | 'timelineOnly' = 'all'): Promise { await this.mutingsRepository.insert({ id: this.idService.gen(), expiresAt: expiresAt ?? null, muterId: user.id, muteeId: target.id, + mutingType: mutingType, }); this.cacheService.userMutingsCache.refresh(user.id); diff --git a/packages/backend/src/core/entities/MutingEntityService.ts b/packages/backend/src/core/entities/MutingEntityService.ts index d361a20271..60176ec3d5 100644 --- a/packages/backend/src/core/entities/MutingEntityService.ts +++ b/packages/backend/src/core/entities/MutingEntityService.ts @@ -40,6 +40,7 @@ export class MutingEntityService { id: muting.id, createdAt: this.idService.parse(muting.id).date.toISOString(), expiresAt: muting.expiresAt ? muting.expiresAt.toISOString() : null, + mutingType: muting.mutingType, muteeId: muting.muteeId, mutee: hints?.packedMutee ?? this.userEntityService.pack(muting.muteeId, me, { schema: 'UserDetailedNotMe', diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 0e96237d32..bada10c4f4 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -289,12 +289,13 @@ export class NotificationEntityService implements OnModuleInit { */ #validateNotifier ( notification: T, - userIdsWhoMeMuting: Set, + userIdsWhoMeMutingMap: Map, userMutedInstances: Set, notifiers: MiUser[], ): boolean { if (!('notifierId' in notification)) return true; - if (userIdsWhoMeMuting.has(notification.notifierId)) return false; + const muting = userIdsWhoMeMutingMap.get(notification.notifierId); + if (muting && muting.mutingType === 'all') return false; const notifier = notifiers.find(x => x.id === notification.notifierId) ?? null; @@ -324,7 +325,7 @@ export class NotificationEntityService implements OnModuleInit { meId: MiUser['id'], ): Promise { const [ - userIdsWhoMeMuting, + userIdsWhoMeMutingMap, userMutedInstances, ] = await Promise.all([ this.cacheService.userMutingsCache.fetch(meId), @@ -337,7 +338,7 @@ export class NotificationEntityService implements OnModuleInit { }) : []; const filteredNotifications = ((await Promise.all(notifications.map(async (notification) => { - const isValid = this.#validateNotifier(notification, userIdsWhoMeMuting, userMutedInstances, notifiers); + const isValid = this.#validateNotifier(notification, userIdsWhoMeMutingMap, userMutedInstances, notifiers); return isValid ? notification : null; }))) as [T | null] ).filter(x => x != null); diff --git a/packages/backend/src/models/Muting.ts b/packages/backend/src/models/Muting.ts index e1240b9c4e..07ab9bfcd1 100644 --- a/packages/backend/src/models/Muting.ts +++ b/packages/backend/src/models/Muting.ts @@ -19,6 +19,12 @@ export class MiMuting { }) public expiresAt: Date | null; + @Column('varchar', { + length: 128, + default: 'all', + }) + public mutingType: 'all' | 'timelineOnly'; + @Index() @Column({ ...id(), diff --git a/packages/backend/src/models/json-schema/muting.ts b/packages/backend/src/models/json-schema/muting.ts index b5fab013ef..828ccfa044 100644 --- a/packages/backend/src/models/json-schema/muting.ts +++ b/packages/backend/src/models/json-schema/muting.ts @@ -22,6 +22,11 @@ export const packedMutingSchema = { optional: false, nullable: true, format: 'date-time', }, + mutingType: { + type: 'string', + optional: false, nullable: false, + enum: ['all', 'timelineOnly'], + }, muteeId: { type: 'string', optional: false, nullable: false, diff --git a/packages/backend/src/server/api/endpoints/mute/create.ts b/packages/backend/src/server/api/endpoints/mute/create.ts index e39c133b43..09755f1986 100644 --- a/packages/backend/src/server/api/endpoints/mute/create.ts +++ b/packages/backend/src/server/api/endpoints/mute/create.ts @@ -55,6 +55,12 @@ export const paramDef = { nullable: true, description: 'A Unix Epoch timestamp that must lie in the future. `null` means an indefinite mute.', }, + mutingType: { + type: 'string', + enum: ['all', 'timelineOnly'], + default: 'all', + description: 'Type of muting. `all` mutes everything including notifications. `timelineOnly` mutes only timeline and search, but allows notifications.', + }, }, required: ['userId'], } as const; @@ -98,7 +104,7 @@ export default class extends Endpoint { // eslint- return; } - await this.userMutingService.mute(muter, mutee, ps.expiresAt ? new Date(ps.expiresAt) : null); + await this.userMutingService.mute(muter, mutee, ps.expiresAt ? new Date(ps.expiresAt) : null, ps.mutingType ?? 'all'); }); } } diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index a57c84d432..779ea92863 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -80,12 +80,14 @@ export default class extends Endpoint { // eslint- } const [ - userIdsWhoMeMuting, + userIdsWhoMeMutingMap, userIdsWhoBlockingMe, ] = me ? await Promise.all([ this.cacheService.userMutingsCache.fetch(me.id), this.cacheService.userBlockedCache.fetch(me.id), - ]) : [new Set(), new Set()]; + ]) : [new Map(), new Set()]; + + const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys()); const query = this.notesRepository.createQueryBuilder('note') .where('note.id IN (:...noteIds)', { noteIds: noteIds }) diff --git a/packages/backend/src/server/api/endpoints/users/featured-notes.ts b/packages/backend/src/server/api/endpoints/users/featured-notes.ts index 90bd11bc25..8a1ec19865 100644 --- a/packages/backend/src/server/api/endpoints/users/featured-notes.ts +++ b/packages/backend/src/server/api/endpoints/users/featured-notes.ts @@ -73,10 +73,13 @@ export default class extends Endpoint { // eslint- } const [ - userIdsWhoMeMuting, + userIdsWhoMeMutingMap, ] = me ? await Promise.all([ this.cacheService.userMutingsCache.fetch(me.id), - ]) : [new Set()]; + ]) : [new Map()]; + + // Convert to Set for backward compatibility with isUserRelated + const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys()); const query = this.notesRepository.createQueryBuilder('note') .where('note.id IN (:...noteIds)', { noteIds: noteIds }) diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts index d84a191f7a..bf7de97d78 100644 --- a/packages/backend/src/server/api/endpoints/users/reactions.ts +++ b/packages/backend/src/server/api/endpoints/users/reactions.ts @@ -94,7 +94,8 @@ export default class extends Endpoint { // eslint- } } - const userIdsWhoMeMuting = me ? await this.cacheService.userMutingsCache.fetch(me.id) : new Set(); + const userIdsWhoMeMutingMap = me ? await this.cacheService.userMutingsCache.fetch(me.id) : new Map(); + const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys()); const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index 222086c960..f5b096f7a4 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -38,6 +38,7 @@ export default class Connection { public followingChannels: Set = new Set(); public mutingChannels: Set = new Set(); public userIdsWhoMeMuting: Set = new Set(); + public userIdsWhoMeMutingMap: Map = new Map(); public userIdsWhoBlockingMe: Set = new Set(); public userIdsWhoMeMutingRenotes: Set = new Set(); public userMutedInstances: Set = new Set(); @@ -64,7 +65,7 @@ export default class Connection { following, followingChannels, mutingChannels, - userIdsWhoMeMuting, + userIdsWhoMeMutingMap, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes, ] = await Promise.all([ @@ -80,7 +81,8 @@ export default class Connection { this.following = following; this.followingChannels = followingChannels; this.mutingChannels = mutingChannels; - this.userIdsWhoMeMuting = userIdsWhoMeMuting; + this.userIdsWhoMeMutingMap = userIdsWhoMeMutingMap; + this.userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys()); this.userIdsWhoBlockingMe = userIdsWhoBlockingMe; this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes; this.userMutedInstances = new Set(userProfile.mutedInstances);