From 7d0f995c9b176ae325a9ff9a4b239d93d63fc1a7 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 9 Jun 2025 20:44:29 -0400 Subject: [PATCH 01/14] hide muted threads from timelines --- packages/backend/src/core/CacheService.ts | 20 +++++++- .../backend/src/core/NoteCreateService.ts | 51 ++++++++++--------- packages/backend/src/core/NoteEditService.ts | 13 ++--- packages/backend/src/core/ReactionService.ts | 8 +-- .../src/core/entities/NoteEntityService.ts | 1 + .../backend/src/models/json-schema/note.ts | 6 +++ .../server/api/endpoints/channels/timeline.ts | 13 ++++- .../api/endpoints/notes/bubble-timeline.ts | 1 + .../api/endpoints/notes/global-timeline.ts | 3 ++ .../api/endpoints/notes/hybrid-timeline.ts | 7 +++ .../api/endpoints/notes/local-timeline.ts | 12 +++++ .../src/server/api/endpoints/notes/state.ts | 18 +++---- .../endpoints/notes/thread-muting/create.ts | 6 +++ .../endpoints/notes/thread-muting/delete.ts | 4 ++ .../server/api/endpoints/notes/timeline.ts | 5 ++ .../src/server/api/stream/Connection.ts | 5 +- .../backend/src/server/api/stream/channel.ts | 7 +++ packages/misskey-js/src/autogen/types.ts | 5 ++ 18 files changed, 134 insertions(+), 51 deletions(-) diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 2d37cd6bab..397d52d10e 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -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>; public userBlockedCache: QuantumKVCache>; // NOTE: 「被」Blockキャッシュ public renoteMutingsCache: QuantumKVCache>; + public threadMutingsCache: QuantumKVCache>; public userFollowingsCache: QuantumKVCache>>; public userFollowersCache: QuantumKVCache>>; public hibernatedUserCache: QuantumKVCache; @@ -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>(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>>(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]))), diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index f4159facc3..bf7d209fef 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -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()]; + ]); 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; } diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index 4be097465d..5b0d1980c3 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -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()]; + ]); const muted = isUserRelated(note, userIdsWhoMeMuting); diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 8d2dc7d4e8..bb56c6c745 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -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', { diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 4248fde77f..1875f8310b 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -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, diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index b19c8f7c06..036bf1fc06 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -12,6 +12,12 @@ export const packedNoteSchema = { format: 'id', example: 'xxxxxxxxxx', }, + threadId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, createdAt: { type: 'string', optional: false, nullable: false, diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index fa5b948eca..2c8338c115 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -13,8 +13,8 @@ import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { MiLocalUser } from '@/models/User.js'; +import { CacheService } from '@/core/CacheService.js'; import { ApiError } from '../../error.js'; -import { Brackets } from 'typeorm'; export const meta = { tags: ['notes', 'channels'], @@ -83,6 +83,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private activeUsersChart: ActiveUsersChart, + private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -106,6 +107,8 @@ export default class extends Endpoint { // eslint- return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me), me); } + const threadMutings = me ? await this.cacheService.threadMutingsCache.fetch(me.id) : null; + return await this.fanoutTimelineEndpointService.timeline({ untilId, sinceId, @@ -119,6 +122,13 @@ export default class extends Endpoint { // eslint- dbFallback: async (untilId, sinceId, limit) => { return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me); }, + noteFilter: note => { + if (threadMutings?.has(note.threadId ?? note.id)) { + return false; + } + + return true; + }, }); }); } @@ -148,6 +158,7 @@ export default class extends Endpoint { // eslint- if (me) { this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedNoteThreadQuery(query, me); } if (ps.withFiles) { diff --git a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts index 84d6aa0dc7..6bb5856f96 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -100,6 +100,7 @@ export default class extends Endpoint { // eslint- if (me) { this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedNoteThreadQuery(query, me); } if (ps.withFiles) { diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 506ea6fcda..ce70c5ae5e 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -12,6 +12,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; +import { CacheService } from '@/core/CacheService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -67,6 +68,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, + private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const policies = await this.roleService.getUserPolicies(me ? me.id : null); @@ -90,6 +92,7 @@ export default class extends Endpoint { // eslint- if (me) { this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedNoteThreadQuery(query, me); } if (ps.withFiles) { diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index a5623d1f03..78faa5bed9 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -147,8 +147,10 @@ export default class extends Endpoint { // eslint- const [ followings, + mutedThreads, ] = await Promise.all([ this.cacheService.userFollowingsCache.fetch(me.id), + this.cacheService.threadMutingsCache.fetch(me.id), ]); const redisTimeline = await this.fanoutTimelineEndpointService.timeline({ @@ -167,6 +169,10 @@ export default class extends Endpoint { // eslint- if (!followings.has(note.reply.userId) && note.reply.userId !== me.id) return false; } + if (mutedThreads.has(note.threadId ?? note.id)) { + return false; + } + return true; }, dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ @@ -230,6 +236,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateSilencedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedNoteThreadQuery(query, me); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 41b1ee1086..a754e9720a 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -15,6 +15,7 @@ import { IdService } from '@/core/IdService.js'; import { QueryService } from '@/core/QueryService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { CacheService } from '@/core/CacheService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -83,6 +84,7 @@ export default class extends Endpoint { // eslint- private idService: IdService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private queryService: QueryService, + private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -115,6 +117,8 @@ export default class extends Endpoint { // eslint- return await this.noteEntityService.packMany(timeline, me); } + const mutedThreads = me ? await this.cacheService.threadMutingsCache.fetch(me.id) : null; + const timeline = await this.fanoutTimelineEndpointService.timeline({ untilId, sinceId, @@ -139,6 +143,13 @@ export default class extends Endpoint { // eslint- withBots: ps.withBots, withRenotes: ps.withRenotes, }, me), + noteFilter: note => { + if (mutedThreads?.has(note.threadId ?? note.id)) { + return false; + } + + return true; + }, }); if (me) { @@ -185,6 +196,7 @@ export default class extends Endpoint { // eslint- if (me) { this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedNoteThreadQuery(query, me); } if (ps.withFiles) { diff --git a/packages/backend/src/server/api/endpoints/notes/state.ts b/packages/backend/src/server/api/endpoints/notes/state.ts index 448e704528..985b7e004e 100644 --- a/packages/backend/src/server/api/endpoints/notes/state.ts +++ b/packages/backend/src/server/api/endpoints/notes/state.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository, NoteThreadMutingsRepository, NoteFavoritesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; export const meta = { tags: ['notes'], @@ -55,30 +56,25 @@ export default class extends Endpoint { // eslint- @Inject(DI.noteFavoritesRepository) private noteFavoritesRepository: NoteFavoritesRepository, + + private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const note = await this.notesRepository.findOneByOrFail({ id: ps.noteId }); const [favorite, threadMuting] = await Promise.all([ - this.noteFavoritesRepository.count({ + this.noteFavoritesRepository.exists({ where: { userId: me.id, noteId: note.id, }, - take: 1, - }), - this.noteThreadMutingsRepository.count({ - where: { - userId: me.id, - threadId: note.threadId ?? note.id, - }, - take: 1, }), + this.cacheService.threadMutingsCache.fetch(me.id).then(ms => ms.has(note.threadId ?? note.id)), ]); return { - isFavorited: favorite !== 0, - isMutedThread: threadMuting !== 0, + isFavorited: favorite, + isMutedThread: threadMuting, }; }); } diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts index 29c6aa7434..94d48c3f72 100644 --- a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts @@ -10,6 +10,7 @@ import { IdService } from '@/core/IdService.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { GetterService } from '@/server/api/GetterService.js'; import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -52,6 +53,7 @@ export default class extends Endpoint { // eslint- private getterService: GetterService, private idService: IdService, + private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const note = await this.getterService.getNote(ps.noteId).catch(err => { @@ -59,6 +61,7 @@ export default class extends Endpoint { // eslint- throw err; }); + /* const mutedNotes = await this.notesRepository.find({ where: [{ id: note.threadId ?? note.id, @@ -66,12 +69,15 @@ export default class extends Endpoint { // eslint- threadId: note.threadId ?? note.id, }], }); + */ await this.noteThreadMutingsRepository.insert({ id: this.idService.gen(), threadId: note.threadId ?? note.id, userId: me.id, }); + + await this.cacheService.threadMutingsCache.refresh(me.id); }); } } diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts index 50ce4fb89a..2e031c579a 100644 --- a/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts @@ -8,6 +8,7 @@ import type { NoteThreadMutingsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { GetterService } from '@/server/api/GetterService.js'; import { DI } from '@/di-symbols.js'; +import { CacheService } from '@/core/CacheService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -47,6 +48,7 @@ export default class extends Endpoint { // eslint- private noteThreadMutingsRepository: NoteThreadMutingsRepository, private getterService: GetterService, + private readonly cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const note = await this.getterService.getNote(ps.noteId).catch(err => { @@ -58,6 +60,8 @@ export default class extends Endpoint { // eslint- threadId: note.threadId ?? note.id, userId: me.id, }); + + await this.cacheService.threadMutingsCache.refresh(me.id); }); } } diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 44c539eaad..10f22155e0 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -99,8 +99,10 @@ export default class extends Endpoint { // eslint- const [ followings, + threadMutings, ] = await Promise.all([ this.cacheService.userFollowingsCache.fetch(me.id), + this.cacheService.threadMutingsCache.fetch(me.id), ]); const timeline = this.fanoutTimelineEndpointService.timeline({ @@ -119,6 +121,8 @@ export default class extends Endpoint { // eslint- } if (!ps.withBots && note.user?.isBot) return false; + if (threadMutings.has(note.threadId ?? note.id)) return false; + return true; }, dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ @@ -167,6 +171,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateSilencedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedNoteThreadQuery(query, me); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index 0ee7078eb2..8ca473ff56 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -42,6 +42,7 @@ export default class Connection { public userIdsWhoBlockingMe: Set = new Set(); public userIdsWhoMeMutingRenotes: Set = new Set(); public userMutedInstances: Set = new Set(); + public userMutedThreads: Set = new Set(); private fetchIntervalId: NodeJS.Timeout | null = null; private closingConnection = false; private logger: Logger; @@ -67,13 +68,14 @@ export default class Connection { @bindThis public async fetch() { if (this.user == null) return; - const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes] = await Promise.all([ + const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes, threadMutings] = await Promise.all([ this.cacheService.userProfileCache.fetch(this.user.id), this.cacheService.userFollowingsCache.fetch(this.user.id), this.channelFollowingService.userFollowingChannelsCache.fetch(this.user.id), this.cacheService.userMutingsCache.fetch(this.user.id), this.cacheService.userBlockedCache.fetch(this.user.id), this.cacheService.renoteMutingsCache.fetch(this.user.id), + this.cacheService.threadMutingsCache.fetch(this.user.id), ]); this.userProfile = userProfile; this.following = following; @@ -82,6 +84,7 @@ export default class Connection { this.userIdsWhoBlockingMe = userIdsWhoBlockingMe; this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes; this.userMutedInstances = new Set(userProfile.mutedInstances); + this.userMutedThreads = threadMutings; } @bindThis diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 40ad454adb..397f9e9367 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -53,6 +53,10 @@ export default abstract class Channel { return this.connection.userMutedInstances; } + protected get userMutedThreads() { + return this.connection.userMutedThreads; + } + protected get followingChannels() { return this.connection.followingChannels; } @@ -94,6 +98,9 @@ export default abstract class Channel { // 流れてきたNoteがリノートをミュートしてるユーザが行ったもの if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true; + // Muted thread + if (this.userMutedThreads.has(note.threadId)) return true; + // If it's a boost (pure renote) then we need to check the target as well if (isPackedPureRenote(note) && note.renote && this.isNoteMutedOrBlocked(note.renote)) return true; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 97356e0c6e..e019df95ac 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4652,6 +4652,11 @@ export type components = { * @example xxxxxxxxxx */ id: string; + /** + * Format: id + * @example xxxxxxxxxx + */ + threadId: string; /** Format: date-time */ createdAt: string; /** Format: date-time */ From c9389b013a01dff9546c332c23a51f5c44555349 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 9 Jun 2025 21:35:31 -0400 Subject: [PATCH 02/14] fix Vue error in SkNoteDetailed and MkNoteDetailed --- packages/frontend/src/components/MkNoteDetailed.vue | 2 +- packages/frontend/src/components/SkNoteDetailed.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 7f38b9ec02..b029382034 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -112,7 +112,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue index f761029cfb..7bdc17dc92 100644 --- a/packages/frontend/src/components/SkNoteDetailed.vue +++ b/packages/frontend/src/components/SkNoteDetailed.vue @@ -116,7 +116,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
From 23cfb5647c1a2265d5d7047311646d82c87e5f28 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 9 Jun 2025 22:09:29 -0400 Subject: [PATCH 03/14] fix spacing between SkUrlPreviewGroup --- .../frontend/src/components/MkAbuseReport.vue | 4 +++- packages/frontend/src/components/MkNote.vue | 4 ++-- .../src/components/MkNoteDetailed.vue | 4 ++-- packages/frontend/src/components/SkNote.vue | 4 ++-- .../src/components/SkNoteDetailed.vue | 4 ++-- .../src/components/SkOldNoteWindow.vue | 4 +++- .../src/components/SkUrlPreviewGroup.vue | 21 +++++++++---------- .../src/components/page/page.text.vue | 4 ++-- packages/frontend/src/pages/chat/XMessage.vue | 4 +++- 9 files changed, 29 insertions(+), 24 deletions(-) diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index 6025bc44f0..c34c4d3409 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -63,7 +63,9 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
+ +
diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 56bfa5de94..20071ce00d 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -95,8 +95,8 @@ SPDX-License-Identifier: AGPL-3.0-only -
- +
+
-
- +
+
diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue index 4d6d080ddf..2c8a553052 100644 --- a/packages/frontend/src/components/SkNote.vue +++ b/packages/frontend/src/components/SkNote.vue @@ -96,8 +96,8 @@ SPDX-License-Identifier: AGPL-3.0-only -
- +
+
-
- +
+
diff --git a/packages/frontend/src/components/SkOldNoteWindow.vue b/packages/frontend/src/components/SkOldNoteWindow.vue index aa1da2d6e3..18ba9cd5b5 100644 --- a/packages/frontend/src/components/SkOldNoteWindow.vue +++ b/packages/frontend/src/components/SkOldNoteWindow.vue @@ -47,7 +47,9 @@ SPDX-License-Identifier: AGPL-3.0-only - +
+ +
{{ appearNote.channel.name }} diff --git a/packages/frontend/src/components/SkUrlPreviewGroup.vue b/packages/frontend/src/components/SkUrlPreviewGroup.vue index dbd930248a..844dc2cc45 100644 --- a/packages/frontend/src/components/SkUrlPreviewGroup.vue +++ b/packages/frontend/src/components/SkUrlPreviewGroup.vue @@ -93,10 +93,9 @@ const urls = computed(() => { return []; }); -// todo un-ref these const isRefreshing = ref | false>(false); -const cachedNotes = ref(new Map()); -const cachedPreviews = ref(new Map()); +const cachedNotes = new Map(); +const cachedPreviews = new Map(); const cachedUsers = new Map(); /** @@ -151,7 +150,7 @@ async function fetchPreviews(): Promise { } async function fetchPreview(url: string): Promise { - const cached = cachedPreviews.value.get(url); + const cached = cachedPreviews.get(url); if (cached) { return cached; } @@ -163,15 +162,15 @@ async function fetchPreview(url: string): Promise { if (res?.ok) { // Success - got the summary const summary: Summary = await res.json(); - cachedPreviews.value.set(url, summary); + cachedPreviews.set(url, summary); if (summary.url !== url) { - cachedPreviews.value.set(summary.url, summary); + cachedPreviews.set(summary.url, summary); } return summary; } // Failed, blocked, or not found - cachedPreviews.value.set(url, null); + cachedPreviews.set(url, null); return null; } @@ -187,7 +186,7 @@ async function attachNote(summary: Summary, noteLimiter: Limiter { - const cached = cachedNotes.value.get(noteUri); + const cached = cachedNotes.get(noteUri); if (cached) { return cached; } @@ -197,15 +196,15 @@ async function fetchNote(noteUri: string): Promise const note = response['object']; // Success - got the note - cachedNotes.value.set(noteUri, note); + cachedNotes.set(noteUri, note); if (note.uri && note.uri !== noteUri) { - cachedNotes.value.set(note.uri, note); + cachedNotes.set(note.uri, note); } return note; } // Failed, blocked, or not found - cachedNotes.value.set(noteUri, null); + cachedNotes.set(noteUri, null); return null; } diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue index 3891380dd0..74967d7f26 100644 --- a/packages/frontend/src/components/page/page.text.vue +++ b/packages/frontend/src/components/page/page.text.vue @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/chat/XMessage.vue b/packages/frontend/src/pages/chat/XMessage.vue index b72583214b..ae1c0c5a11 100644 --- a/packages/frontend/src/pages/chat/XMessage.vue +++ b/packages/frontend/src/pages/chat/XMessage.vue @@ -22,7 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only /> - +
+ +
From 87582034b52b28d382b853fd517fad7cafd6d502 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 9 Jun 2025 22:10:30 -0400 Subject: [PATCH 04/14] expose thread mute status as Note.isMuting property --- .../backend/src/core/entities/NoteEntityService.ts | 13 +++++++++++-- packages/backend/src/models/json-schema/note.ts | 4 ++++ packages/misskey-js/src/autogen/types.ts | 1 + 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 1875f8310b..9fbe7235fa 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -422,6 +422,7 @@ export class NoteEntityService implements OnModuleInit { pollVotes: Map>; channels: Map; notes: Map; + mutedThreads: Set; }; }, ): Promise> { @@ -460,9 +461,11 @@ export class NoteEntityService implements OnModuleInit { const packedFiles = options?._hint_?.packedFiles; const packedUsers = options?._hint_?.packedUsers; + const threadId = note.threadId ?? note.id; + const packed: Packed<'Note'> = await awaitAll({ id: note.id, - threadId: note.threadId ?? note.id, + threadId, createdAt: this.idService.parse(note.id).date.toISOString(), updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined, userId: note.userId, @@ -502,6 +505,8 @@ export class NoteEntityService implements OnModuleInit { poll: opts._hint_?.polls.get(note.id), myVotes: opts._hint_?.pollVotes.get(note.id)?.get(note.userId), }) : undefined, + isMuting: opts._hint_?.mutedThreads.has(threadId) + ?? (meId != null && this.cacheService.threadMutingsCache.fetch(meId).then(ms => ms.has(threadId))), ...(meId && Object.keys(reactions).length > 0 ? { myReaction: this.populateMyReaction({ @@ -649,7 +654,7 @@ export class NoteEntityService implements OnModuleInit { const fileIds = new Set(targetNotes.flatMap(n => n.fileIds)); const mentionedUsers = new Set(targetNotes.flatMap(note => note.mentions)); - const [{ bufferedReactions, myReactionsMap }, packedFiles, packedUsers, mentionHandles, userFollowings, userBlockers, polls, pollVotes, channels] = await Promise.all([ + const [{ bufferedReactions, myReactionsMap }, packedFiles, packedUsers, mentionHandles, userFollowings, userBlockers, polls, pollVotes, channels, mutedThreads] = await Promise.all([ // bufferedReactions & myReactionsMap this.getReactions(targetNotes, me), // packedFiles @@ -660,6 +665,7 @@ export class NoteEntityService implements OnModuleInit { // mentionHandles this.getUserHandles(Array.from(mentionedUsers)), // userFollowings + // TODO this might be wrong this.cacheService.userFollowingsCache.fetchMany(userIds).then(fs => new Map(fs)), // userBlockers this.cacheService.userBlockedCache.fetchMany(userIds).then(bs => new Map(bs)), @@ -684,6 +690,8 @@ export class NoteEntityService implements OnModuleInit { }, new Map>)), // channels this.getChannels(targetNotes), + // mutedThreads + me ? this.cacheService.threadMutingsCache.fetch(me.id) : new Set(), // (not returned) this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes)), ]); @@ -702,6 +710,7 @@ export class NoteEntityService implements OnModuleInit { pollVotes, channels, notes: new Map(targetNotes.map(n => [n.id, n])), + mutedThreads, }, }))); } diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index 036bf1fc06..cfe6c425bd 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -173,6 +173,10 @@ export const packedNoteSchema = { }, }, }, + isMuting: { + type: 'boolean', + optional: false, nullable: false, + }, emojis: { type: 'object', optional: true, nullable: false, diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index e019df95ac..c6045c9c43 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4701,6 +4701,7 @@ export type components = { votes: number; }[]; }) | null; + isMuting: boolean; emojis?: { [key: string]: string; }; From 9bebf7718f476dbc698ef78d8ecc6b63a058eaa3 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 9 Jun 2025 22:11:34 -0400 Subject: [PATCH 05/14] hide muted threads behind a CW --- locales/index.d.ts | 4 ++ packages/frontend/src/components/MkNote.vue | 8 ++-- .../src/components/MkNoteDetailed.vue | 8 ++-- .../frontend/src/components/MkNoteSub.vue | 8 ++-- .../src/components/SkFollowingFeedEntry.vue | 8 ++-- .../frontend/src/components/SkMutedNote.vue | 39 ++++++++++++------- packages/frontend/src/components/SkNote.vue | 8 ++-- .../src/components/SkNoteDetailed.vue | 8 ++-- .../frontend/src/components/SkNoteSub.vue | 6 +-- .../frontend/src/utility/check-word-mute.ts | 32 ++++++++++++--- sharkey-locales/en-US.yml | 1 + 11 files changed, 83 insertions(+), 47 deletions(-) diff --git a/locales/index.d.ts b/locales/index.d.ts index da0f7a963f..b6a80f004a 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -11988,6 +11988,10 @@ export interface Locale extends ILocale { * Boosts muted */ "renoteMuted": string; + /** + * {name} said something in a muted thread + */ + "userSaysSomethingInMutedThread": ParameterizedString<"name">; /** * Mark all media from user as NSFW */ diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 20071ce00d..c8bb441359 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only