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 * 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<MiLocalUser>;
public uriPersonCache: MemoryKVCache<MiUser | null>;
public userProfileCache: RedisKVCache<MiUserProfile>;
public userMutingsCache: RedisKVCache<Set<string>>;
public userMutingsCache: RedisKVCache<Map<string, Pick<MiMuting, 'mutingType'>>>;
public userBlockingCache: RedisKVCache<Set<string>>;
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
public renoteMutingsCache: RedisKVCache<Set<string>>;
@ -69,12 +69,12 @@ export class CacheService implements OnApplicationShutdown {
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
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<Set<string>>(this.redisClient, 'userBlocking', {

View File

@ -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;

View File

@ -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;
}

View File

@ -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<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')
.innerJoinAndSelect('note.user', 'user')

View File

@ -24,12 +24,13 @@ export class UserMutingService {
}
@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({
id: this.idService.gen(),
expiresAt: expiresAt ?? null,
muterId: user.id,
muteeId: target.id,
mutingType: mutingType,
});
this.cacheService.userMutingsCache.refresh(user.id);

View File

@ -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',

View File

@ -289,12 +289,13 @@ export class NotificationEntityService implements OnModuleInit {
*/
#validateNotifier <T extends MiNotification | MiGroupedNotification> (
notification: T,
userIdsWhoMeMuting: Set<MiUser['id']>,
userIdsWhoMeMutingMap: Map<MiUser['id'], { mutingType: 'all' | 'timelineOnly' }>,
userMutedInstances: Set<string>,
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<T[]> {
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);

View File

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

View File

@ -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,

View File

@ -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<typeof meta, typeof paramDef> { // 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');
});
}
}

View File

@ -80,12 +80,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingMap,
userIdsWhoBlockingMe,
] = me ? await Promise.all([
this.cacheService.userMutingsCache.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')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })

View File

@ -73,10 +73,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingMap,
] = me ? await Promise.all([
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')
.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'),
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 mutingChannels: 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 userIdsWhoMeMutingRenotes: Set<string> = new Set();
public userMutedInstances: Set<string> = 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);