Add mutingType field to Muting model and update related code

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-07 12:04:42 +00:00
parent 1810d6e837
commit 685847e1b6
15 changed files with 72 additions and 23 deletions

View File

@ -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"`);
}
}

View File

@ -5,7 +5,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; 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 { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
import type { MiLocalUser, MiUser } from '@/models/User.js'; import type { MiLocalUser, MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@ -21,7 +21,7 @@ export class CacheService implements OnApplicationShutdown {
public localUserByIdCache: MemoryKVCache<MiLocalUser>; public localUserByIdCache: MemoryKVCache<MiLocalUser>;
public uriPersonCache: MemoryKVCache<MiUser | null>; public uriPersonCache: MemoryKVCache<MiUser | null>;
public userProfileCache: RedisKVCache<MiUserProfile>; public userProfileCache: RedisKVCache<MiUserProfile>;
public userMutingsCache: RedisKVCache<Set<string>>; public userMutingsCache: RedisKVCache<Map<string, Pick<MiMuting, 'mutingType'>>>;
public userBlockingCache: RedisKVCache<Set<string>>; public userBlockingCache: RedisKVCache<Set<string>>;
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
public renoteMutingsCache: RedisKVCache<Set<string>>; public renoteMutingsCache: RedisKVCache<Set<string>>;
@ -69,12 +69,12 @@ export class CacheService implements OnApplicationShutdown {
fromRedisConverter: (value) => JSON.parse(value), // TODO: date型の考慮 fromRedisConverter: (value) => JSON.parse(value), // TODO: date型の考慮
}); });
this.userMutingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userMutings', { this.userMutingsCache = new RedisKVCache<Map<string, Pick<MiMuting, 'mutingType'>>>(this.redisClient, 'userMutings', {
lifetime: 1000 * 60 * 30, // 30m lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60, // 1m memoryCacheLifetime: 1000 * 60, // 1m
fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))), 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)), toRedisConverter: (value) => JSON.stringify(Array.from(value.entries())),
fromRedisConverter: (value) => new Set(JSON.parse(value)), fromRedisConverter: (value) => new Map(JSON.parse(value)),
}); });
this.userBlockingCache = new RedisKVCache<Set<string>>(this.redisClient, 'userBlocking', { this.userBlockingCache = new RedisKVCache<Set<string>>(this.redisClient, 'userBlocking', {

View File

@ -111,7 +111,7 @@ export class FanoutTimelineEndpointService {
if (ps.me) { if (ps.me) {
const me = ps.me; const me = ps.me;
const [ const [
userIdsWhoMeMuting, userIdsWhoMeMutingMap,
userIdsWhoMeMutingRenotes, userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe, userIdsWhoBlockingMe,
userMutedInstances, userMutedInstances,
@ -124,6 +124,8 @@ export class FanoutTimelineEndpointService {
this.channelMutingService.mutingChannelsCache.fetch(me.id), this.channelMutingService.mutingChannelsCache.fetch(me.id),
]); ]);
const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys());
const parentFilter = filter; const parentFilter = filter;
filter = (note) => { filter = (note) => {
if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false; if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false;

View File

@ -108,7 +108,8 @@ export class NotificationService implements OnApplicationShutdown {
} }
const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId); const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId);
if (mutings.has(notifierId)) { const muting = mutings.get(notifierId);
if (muting && muting.mutingType === 'all') {
return null; return null;
} }

View File

@ -286,14 +286,16 @@ export class SearchService {
} }
const [ const [
userIdsWhoMeMuting, userIdsWhoMeMutingMap,
userIdsWhoBlockingMe, userIdsWhoBlockingMe,
] = me ] = me
? await Promise.all([ ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id), this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id), this.cacheService.userBlockedCache.fetch(me.id),
]) ])
: [new Set<string>(), new Set<string>()]; : [new Map<string, { mutingType: 'all' | 'timelineOnly' }>(), new Set<string>()];
const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys());
const query = this.notesRepository.createQueryBuilder('note') const query = this.notesRepository.createQueryBuilder('note')
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')

View File

@ -24,12 +24,13 @@ export class UserMutingService {
} }
@bindThis @bindThis
public async mute(user: MiUser, target: MiUser, expiresAt: Date | null = null): Promise<void> { public async mute(user: MiUser, target: MiUser, expiresAt: Date | null = null, mutingType: 'all' | 'timelineOnly' = 'all'): Promise<void> {
await this.mutingsRepository.insert({ await this.mutingsRepository.insert({
id: this.idService.gen(), id: this.idService.gen(),
expiresAt: expiresAt ?? null, expiresAt: expiresAt ?? null,
muterId: user.id, muterId: user.id,
muteeId: target.id, muteeId: target.id,
mutingType: mutingType,
}); });
this.cacheService.userMutingsCache.refresh(user.id); this.cacheService.userMutingsCache.refresh(user.id);

View File

@ -40,6 +40,7 @@ export class MutingEntityService {
id: muting.id, id: muting.id,
createdAt: this.idService.parse(muting.id).date.toISOString(), createdAt: this.idService.parse(muting.id).date.toISOString(),
expiresAt: muting.expiresAt ? muting.expiresAt.toISOString() : null, expiresAt: muting.expiresAt ? muting.expiresAt.toISOString() : null,
mutingType: muting.mutingType,
muteeId: muting.muteeId, muteeId: muting.muteeId,
mutee: hints?.packedMutee ?? this.userEntityService.pack(muting.muteeId, me, { mutee: hints?.packedMutee ?? this.userEntityService.pack(muting.muteeId, me, {
schema: 'UserDetailedNotMe', schema: 'UserDetailedNotMe',

View File

@ -289,12 +289,13 @@ export class NotificationEntityService implements OnModuleInit {
*/ */
#validateNotifier <T extends MiNotification | MiGroupedNotification> ( #validateNotifier <T extends MiNotification | MiGroupedNotification> (
notification: T, notification: T,
userIdsWhoMeMuting: Set<MiUser['id']>, userIdsWhoMeMutingMap: Map<MiUser['id'], { mutingType: 'all' | 'timelineOnly' }>,
userMutedInstances: Set<string>, userMutedInstances: Set<string>,
notifiers: MiUser[], notifiers: MiUser[],
): boolean { ): boolean {
if (!('notifierId' in notification)) return true; 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; const notifier = notifiers.find(x => x.id === notification.notifierId) ?? null;
@ -324,7 +325,7 @@ export class NotificationEntityService implements OnModuleInit {
meId: MiUser['id'], meId: MiUser['id'],
): Promise<T[]> { ): Promise<T[]> {
const [ const [
userIdsWhoMeMuting, userIdsWhoMeMutingMap,
userMutedInstances, userMutedInstances,
] = await Promise.all([ ] = await Promise.all([
this.cacheService.userMutingsCache.fetch(meId), this.cacheService.userMutingsCache.fetch(meId),
@ -337,7 +338,7 @@ export class NotificationEntityService implements OnModuleInit {
}) : []; }) : [];
const filteredNotifications = ((await Promise.all(notifications.map(async (notification) => { 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; return isValid ? notification : null;
}))) as [T | null] ).filter(x => x != null); }))) as [T | null] ).filter(x => x != null);

View File

@ -19,6 +19,12 @@ export class MiMuting {
}) })
public expiresAt: Date | null; public expiresAt: Date | null;
@Column('varchar', {
length: 128,
default: 'all',
})
public mutingType: 'all' | 'timelineOnly';
@Index() @Index()
@Column({ @Column({
...id(), ...id(),

View File

@ -22,6 +22,11 @@ export const packedMutingSchema = {
optional: false, nullable: true, optional: false, nullable: true,
format: 'date-time', format: 'date-time',
}, },
mutingType: {
type: 'string',
optional: false, nullable: false,
enum: ['all', 'timelineOnly'],
},
muteeId: { muteeId: {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,

View File

@ -55,6 +55,12 @@ export const paramDef = {
nullable: true, nullable: true,
description: 'A Unix Epoch timestamp that must lie in the future. `null` means an indefinite mute.', 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'], required: ['userId'],
} as const; } as const;
@ -98,7 +104,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return; 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');
}); });
} }
} }

View File

@ -80,12 +80,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
const [ const [
userIdsWhoMeMuting, userIdsWhoMeMutingMap,
userIdsWhoBlockingMe, userIdsWhoBlockingMe,
] = me ? await Promise.all([ ] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id), this.cacheService.userMutingsCache.fetch(me.id),
this.cacheService.userBlockedCache.fetch(me.id), this.cacheService.userBlockedCache.fetch(me.id),
]) : [new Set<string>(), new Set<string>()]; ]) : [new Map<string, { mutingType: 'all' | 'timelineOnly' }>(), new Set<string>()];
const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys());
const query = this.notesRepository.createQueryBuilder('note') const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds }) .where('note.id IN (:...noteIds)', { noteIds: noteIds })

View File

@ -73,10 +73,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
const [ const [
userIdsWhoMeMuting, userIdsWhoMeMutingMap,
] = me ? await Promise.all([ ] = me ? await Promise.all([
this.cacheService.userMutingsCache.fetch(me.id), this.cacheService.userMutingsCache.fetch(me.id),
]) : [new Set<string>()]; ]) : [new Map<string, { mutingType: 'all' | 'timelineOnly' }>()];
// Convert to Set for backward compatibility with isUserRelated
const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys());
const query = this.notesRepository.createQueryBuilder('note') const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds }) .where('note.id IN (:...noteIds)', { noteIds: noteIds })

View File

@ -94,7 +94,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
} }
const userIdsWhoMeMuting = me ? await this.cacheService.userMutingsCache.fetch(me.id) : new Set<string>(); const userIdsWhoMeMutingMap = me ? await this.cacheService.userMutingsCache.fetch(me.id) : new Map<string, { mutingType: 'all' | 'timelineOnly' }>();
const userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys());
const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)

View File

@ -38,6 +38,7 @@ export default class Connection {
public followingChannels: Set<string> = new Set(); public followingChannels: Set<string> = new Set();
public mutingChannels: Set<string> = new Set(); public mutingChannels: Set<string> = new Set();
public userIdsWhoMeMuting: Set<string> = new Set(); public userIdsWhoMeMuting: Set<string> = new Set();
public userIdsWhoMeMutingMap: Map<string, { mutingType: 'all' | 'timelineOnly' }> = new Map();
public userIdsWhoBlockingMe: Set<string> = new Set(); public userIdsWhoBlockingMe: Set<string> = new Set();
public userIdsWhoMeMutingRenotes: Set<string> = new Set(); public userIdsWhoMeMutingRenotes: Set<string> = new Set();
public userMutedInstances: Set<string> = new Set(); public userMutedInstances: Set<string> = new Set();
@ -64,7 +65,7 @@ export default class Connection {
following, following,
followingChannels, followingChannels,
mutingChannels, mutingChannels,
userIdsWhoMeMuting, userIdsWhoMeMutingMap,
userIdsWhoBlockingMe, userIdsWhoBlockingMe,
userIdsWhoMeMutingRenotes, userIdsWhoMeMutingRenotes,
] = await Promise.all([ ] = await Promise.all([
@ -80,7 +81,8 @@ export default class Connection {
this.following = following; this.following = following;
this.followingChannels = followingChannels; this.followingChannels = followingChannels;
this.mutingChannels = mutingChannels; this.mutingChannels = mutingChannels;
this.userIdsWhoMeMuting = userIdsWhoMeMuting; this.userIdsWhoMeMutingMap = userIdsWhoMeMutingMap;
this.userIdsWhoMeMuting = new Set(userIdsWhoMeMutingMap.keys());
this.userIdsWhoBlockingMe = userIdsWhoBlockingMe; this.userIdsWhoBlockingMe = userIdsWhoBlockingMe;
this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes; this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes;
this.userMutedInstances = new Set(userProfile.mutedInstances); this.userMutedInstances = new Set(userProfile.mutedInstances);