hide muted threads from timelines

This commit is contained in:
Hazelnoot 2025-06-09 20:44:29 -04:00
parent a4c0ef824c
commit 7d0f995c9b
18 changed files with 134 additions and 51 deletions

View file

@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { In, IsNull } from 'typeorm';
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiNote, MiFollowing } from '@/models/_.js';
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiNote, MiFollowing, NoteThreadMutingsRepository } from '@/models/_.js';
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
import { QuantumKVCache } from '@/misc/QuantumKVCache.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
@ -46,6 +46,7 @@ export class CacheService implements OnApplicationShutdown {
public userBlockingCache: QuantumKVCache<Set<string>>;
public userBlockedCache: QuantumKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
public renoteMutingsCache: QuantumKVCache<Set<string>>;
public threadMutingsCache: QuantumKVCache<Set<string>>;
public userFollowingsCache: QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>;
public userFollowersCache: QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>;
public hibernatedUserCache: QuantumKVCache<boolean>;
@ -77,6 +78,9 @@ export class CacheService implements OnApplicationShutdown {
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
@Inject(DI.noteThreadMutingsRepository)
private readonly noteThreadMutingsRepository: NoteThreadMutingsRepository,
private userEntityService: UserEntityService,
private readonly internalEventService: InternalEventService,
) {
@ -145,6 +149,20 @@ export class CacheService implements OnApplicationShutdown {
.then(ms => ms.map(m => [m.muterId, new Set(m.muteeIds)])),
});
this.threadMutingsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'threadMutings', {
lifetime: 1000 * 60 * 30, // 30m
fetcher: muterId => this.noteThreadMutingsRepository
.find({ where: { userId: muterId }, select: { threadId: true } })
.then(ms => new Set(ms.map(m => m.threadId))),
bulkFetcher: muterIds => this.noteThreadMutingsRepository
.createQueryBuilder('muting')
.select('"muting"."userId"', 'userId')
.addSelect('array_agg("muting"."threadId")', 'threadIds')
.where({ userId: In(muterIds) })
.getRawMany<{ userId: string, threadIds: string[] }>()
.then(ms => ms.map(m => [m.userId, new Set(m.threadIds)])),
});
this.userFollowingsCache = new QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>(this.internalEventService, 'userFollowings', {
lifetime: 1000 * 60 * 30, // 30m
fetcher: (key) => this.followingsRepository.findBy({ followerId: key }).then(xs => new Map(xs.map(f => [f.followeeId, f]))),

View file

@ -676,18 +676,15 @@ export class NoteCreateService implements OnApplicationShutdown {
});
// 通知
if (data.reply.userHost === null) {
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
where: {
userId: data.reply.userId,
threadId: data.reply.threadId ?? data.reply.id,
},
});
const threadId = data.reply.threadId ?? data.reply.id;
const [
isThreadMuted,
userIdsWhoMeMuting,
] = data.reply.userId ? await Promise.all([
] = await Promise.all([
this.cacheService.threadMutingsCache.fetch(data.reply.userId).then(ms => ms.has(threadId)),
this.cacheService.userMutingsCache.fetch(data.reply.userId),
]) : [new Set<string>()];
]);
const muted = isUserRelated(note, userIdsWhoMeMuting);
@ -705,14 +702,17 @@ export class NoteCreateService implements OnApplicationShutdown {
// Notify
if (data.renote.userHost === null) {
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
where: {
userId: data.renote.userId,
threadId: data.renote.threadId ?? data.renote.id,
},
});
const threadId = data.renote.threadId ?? data.renote.id;
const muted = data.renote.userId && isUserRelated(note, await this.cacheService.userMutingsCache.fetch(data.renote.userId));
const [
isThreadMuted,
userIdsWhoMeMuting,
] = await Promise.all([
this.cacheService.threadMutingsCache.fetch(data.renote.userId).then(ms => ms.has(threadId)),
this.cacheService.userMutingsCache.fetch(data.renote.userId),
]);
const muted = data.renote.userId && isUserRelated(note, userIdsWhoMeMuting);
if (!isThreadMuted && !muted) {
nm.push(data.renote.userId, type);
@ -842,18 +842,23 @@ export class NoteCreateService implements OnApplicationShutdown {
@bindThis
private async createMentionedEvents(mentionedUsers: MinimumUser[], note: MiNote, nm: NotificationManager) {
const [
threadMutings,
userMutings,
] = await Promise.all([
this.cacheService.threadMutingsCache.fetchMany(mentionedUsers.map(u => u.id)).then(ms => new Map(ms)),
this.cacheService.userMutingsCache.fetchMany(mentionedUsers.map(u => u.id)).then(ms => new Map(ms)),
]);
// Only create mention events for local users, and users for whom the note is visible
for (const u of mentionedUsers.filter(u => (note.visibility !== 'specified' || note.visibleUserIds.some(x => x === u.id)) && this.userEntityService.isLocalUser(u))) {
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
where: {
userId: u.id,
threadId: note.threadId ?? note.id,
},
});
const threadId = note.threadId ?? note.id;
const isThreadMuted = threadMutings.get(u.id)?.has(threadId);
const muted = u.id && isUserRelated(note, await this.cacheService.userMutingsCache.fetch(u.id));
const mutings = userMutings.get(u.id);
const isUserMuted = mutings != null && isUserRelated(note, mutings);
if (isThreadMuted || muted) {
if (isThreadMuted || isUserMuted) {
continue;
}

View file

@ -647,18 +647,15 @@ export class NoteEditService implements OnApplicationShutdown {
if (data.reply) {
// 通知
if (data.reply.userHost === null) {
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
where: {
userId: data.reply.userId,
threadId: data.reply.threadId ?? data.reply.id,
},
});
const threadId = data.reply.threadId ?? data.reply.id;
const [
isThreadMuted,
userIdsWhoMeMuting,
] = data.reply.userId ? await Promise.all([
] = await Promise.all([
this.cacheService.threadMutingsCache.fetch(data.reply.userId).then(ms => ms.has(threadId)),
this.cacheService.userMutingsCache.fetch(data.reply.userId),
]) : [new Set<string>()];
]);
const muted = isUserRelated(note, userIdsWhoMeMuting);

View file

@ -275,12 +275,8 @@ export class ReactionService {
// リアクションされたユーザーがローカルユーザーなら通知を作成
if (note.userHost === null) {
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
where: {
userId: note.userId,
threadId: note.threadId ?? note.id,
},
});
const threadId = note.threadId ?? note.id;
const isThreadMuted = await this.cacheService.threadMutingsCache.fetch(note.userId).then(ms => ms.has(threadId));
if (!isThreadMuted) {
this.notificationService.createNotification(note.userId, 'reaction', {

View file

@ -462,6 +462,7 @@ export class NoteEntityService implements OnModuleInit {
const packed: Packed<'Note'> = await awaitAll({
id: note.id,
threadId: note.threadId ?? note.id,
createdAt: this.idService.parse(note.id).date.toISOString(),
updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined,
userId: note.userId,