diff --git a/locales/index.d.ts b/locales/index.d.ts index da0f7a963f..5691130b55 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -11988,6 +11988,22 @@ export interface Locale extends ILocale { * Boosts muted */ "renoteMuted": string; + /** + * Mute note + */ + "muteNote": string; + /** + * Unmute note + */ + "unmuteNote": string; + /** + * {name} said something in a muted post + */ + "userSaysSomethingInMutedNote": ParameterizedString<"name">; + /** + * {name} said something in a muted thread + */ + "userSaysSomethingInMutedThread": ParameterizedString<"name">; /** * Mark all media from user as NSFW */ @@ -12265,6 +12281,10 @@ export interface Locale extends ILocale { * Collapse files */ "collapseFiles": string; + /** + * Clone + */ + "clone": string; /** * Uncollapse CWs on notes */ diff --git a/packages/backend/migration/1749523586531-add-note_thread_muting-isPostMute.js b/packages/backend/migration/1749523586531-add-note_thread_muting-isPostMute.js new file mode 100644 index 0000000000..741102cb91 --- /dev/null +++ b/packages/backend/migration/1749523586531-add-note_thread_muting-isPostMute.js @@ -0,0 +1,22 @@ +/** + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddNoteThreadMutingIsPostMute1749523586531 { + name = 'AddNoteThreadMutingIsPostMute1749523586531' + + async up(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_ae7aab18a2641d3e5f25e0c4ea"`); + await queryRunner.query(`ALTER TABLE "note_thread_muting" ADD "isPostMute" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "note_thread_muting"."isPostMute" IS 'If true, then this mute applies only to the referenced note. If false (default), then it applies to all replies as well.'`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_01f7ab05099400012e9a7fd42b" ON "note_thread_muting" ("userId", "threadId", "isPostMute") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_01f7ab05099400012e9a7fd42b"`); + await queryRunner.query(`COMMENT ON COLUMN "note_thread_muting"."isPostMute" IS 'If true, then this mute applies only to the referenced note. If false (default), then it applies to all replies as well.'`); + await queryRunner.query(`ALTER TABLE "note_thread_muting" DROP COLUMN "isPostMute"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_ae7aab18a2641d3e5f25e0c4ea" ON "note_thread_muting" ("userId", "threadId") `); + } +} diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 2d37cd6bab..7ba1aba1bc 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,8 @@ export class CacheService implements OnApplicationShutdown { public userBlockingCache: QuantumKVCache>; public userBlockedCache: QuantumKVCache>; // NOTE: 「被」Blockキャッシュ public renoteMutingsCache: QuantumKVCache>; + public threadMutingsCache: QuantumKVCache>; + public noteMutingsCache: QuantumKVCache>; public userFollowingsCache: QuantumKVCache>>; public userFollowersCache: QuantumKVCache>>; public hibernatedUserCache: QuantumKVCache; @@ -77,6 +79,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 +150,36 @@ 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, isPostMute: false }, 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') + .groupBy('"muting"."userId"') + .where({ userId: In(muterIds), isPostMute: false }) + .getRawMany<{ userId: string, threadIds: string[] }>() + .then(ms => ms.map(m => [m.userId, new Set(m.threadIds)])), + }); + + this.noteMutingsCache = new QuantumKVCache>(this.internalEventService, 'noteMutings', { + lifetime: 1000 * 60 * 30, // 30m + fetcher: muterId => this.noteThreadMutingsRepository + .find({ where: { userId: muterId, isPostMute: true }, 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') + .groupBy('"muting"."userId"') + .where({ userId: In(muterIds), isPostMute: true }) + .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]))), @@ -272,6 +307,8 @@ export class CacheService implements OnApplicationShutdown { this.userFollowingsCache.delete(body.id), this.userFollowersCache.delete(body.id), this.hibernatedUserCache.delete(body.id), + this.threadMutingsCache.delete(body.id), + this.noteMutingsCache.delete(body.id), ]); } } else { @@ -542,7 +579,11 @@ export class CacheService implements OnApplicationShutdown { this.userBlockingCache.dispose(); this.userBlockedCache.dispose(); this.renoteMutingsCache.dispose(); + this.threadMutingsCache.dispose(); + this.noteMutingsCache.dispose(); this.userFollowingsCache.dispose(); + this.userFollowersCache.dispose(); + this.hibernatedUserCache.dispose(); } @bindThis 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/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index 8dc42e45c0..c8111a62d1 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -394,6 +394,7 @@ export class WebhookTestService { private async toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Promise> { return { id: note.id, + threadId: note.threadId ?? note.id, createdAt: new Date().toISOString(), deletedAt: null, text: note.text, @@ -403,6 +404,10 @@ export class WebhookTestService { replyId: note.replyId, renoteId: note.renoteId, isHidden: false, + isMutingThread: false, + isMutingNote: false, + isFavorited: false, + isRenoted: false, visibility: note.visibility, mentions: note.mentions, visibleUserIds: note.visibleUserIds, diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 4248fde77f..029a0b6ebb 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -11,11 +11,12 @@ import type { Packed } from '@/misc/json-schema.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; -import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, MiPollVote, MiPoll, MiChannel, MiFollowing } from '@/models/_.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, MiPollVote, MiPoll, MiChannel, MiFollowing, NoteFavoritesRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { DebounceLoader } from '@/misc/loader.js'; import { IdService } from '@/core/IdService.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; +import { QueryService } from '@/core/QueryService.js'; import { isPackedPureRenote } from '@/misc/is-renote.js'; import type { Config } from '@/config.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -55,6 +56,7 @@ function getAppearNoteIds(notes: MiNote[]): Set { return appearNoteIds; } +// noinspection ES6MissingAwait @Injectable() export class NoteEntityService implements OnModuleInit { private userEntityService: UserEntityService; @@ -96,6 +98,10 @@ export class NoteEntityService implements OnModuleInit { @Inject(DI.config) private readonly config: Config, + @Inject(DI.noteFavoritesRepository) + private noteFavoritesRepository: NoteFavoritesRepository, + + private readonly queryService: QueryService, //private userEntityService: UserEntityService, //private driveFileEntityService: DriveFileEntityService, //private customEmojiService: CustomEmojiService, @@ -131,9 +137,21 @@ export class NoteEntityService implements OnModuleInit { return packedNote.visibility; } + @bindThis + public async hideNotes(notes: Packed<'Note'>[], meId: string | null): Promise { + const myFollowing = meId ? new Map(await this.cacheService.userFollowingsCache.fetch(meId)) : new Map>(); + const myBlockers = meId ? new Set(await this.cacheService.userBlockedCache.fetch(meId)) : new Set(); + + // This shouldn't actually await, but we have to wrap it anyway because hideNote() is async + await Promise.all(notes.map(note => this.hideNote(note, meId, { + myFollowing, + myBlockers, + }))); + } + @bindThis public async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null, hint?: { - myFollowing?: ReadonlyMap, + myFollowing?: ReadonlyMap> | ReadonlySet, myBlockers?: ReadonlySet, }): Promise { if (meId === packedNote.userId) return; @@ -275,6 +293,142 @@ export class NoteEntityService implements OnModuleInit { }; } + @bindThis + public async populateMyNoteMutings(notes: Packed<'Note'>[], meId: string): Promise> { + const mutedNotes = await this.cacheService.noteMutingsCache.fetch(meId); + + const mutedIds = notes + .filter(note => mutedNotes.has(note.id)) + .map(note => note.id); + return new Set(mutedIds); + } + + @bindThis + public async populateMyTheadMutings(notes: Packed<'Note'>[], meId: string): Promise> { + const mutedThreads = await this.cacheService.threadMutingsCache.fetch(meId); + + const mutedIds = notes + .filter(note => mutedThreads.has(note.threadId)) + .map(note => note.id); + return new Set(mutedIds); + } + + @bindThis + public async populateMyRenotes(notes: Packed<'Note'>[], meId: string, _hint_?: { + myRenotes: Set; + }): Promise> { + const fetchedRenotes = new Set(); + const toFetch = new Set(); + + if (_hint_) { + for (const note of notes) { + if (_hint_.myRenotes.has(note.id)) { + fetchedRenotes.add(note.id); + } else { + toFetch.add(note.id); + } + } + } + + if (toFetch.size > 0) { + const fetched = await this.queryService + .andIsRenote(this.notesRepository.createQueryBuilder('note'), 'note') + .andWhere({ + userId: meId, + renoteId: In(Array.from(toFetch)), + }) + .select('note.renoteId', 'renoteId') + .getRawMany<{ renoteId: string }>(); + + for (const { renoteId } of fetched) { + fetchedRenotes.add(renoteId); + } + } + + return fetchedRenotes; + } + + @bindThis + public async populateMyFavorites(notes: Packed<'Note'>[], meId: string, _hint_?: { + myFavorites: Set; + }): Promise> { + const fetchedFavorites = new Set(); + const toFetch = new Set(); + + if (_hint_) { + for (const note of notes) { + if (_hint_.myFavorites.has(note.id)) { + fetchedFavorites.add(note.id); + } else { + toFetch.add(note.id); + } + } + } + + if (toFetch.size > 0) { + const fetched = await this.noteFavoritesRepository.find({ + where: { + userId: meId, + noteId: In(Array.from(toFetch)), + }, + select: { + noteId: true, + }, + }) as { noteId: string }[]; + + for (const { noteId } of fetched) { + fetchedFavorites.add(noteId); + } + } + + return fetchedFavorites; + } + + @bindThis + public async populateMyReactions(notes: Packed<'Note'>[], meId: string, _hint_?: { + myReactions: Map; + }): Promise> { + const fetchedReactions = new Map(); + const toFetch = new Set(); + + if (_hint_) { + for (const note of notes) { + const fromHint = _hint_.myReactions.get(note.id); + + // null means we know there's no reaction, so just skip it. + if (fromHint === null) continue; + + if (fromHint) { + const converted = this.reactionService.convertLegacyReaction(fromHint); + fetchedReactions.set(note.id, converted); + } else if (Object.values(note.reactions).some(count => count > 0)) { + // Note has at least one reaction, so we need to fetch + toFetch.add(note.id); + } + } + } + + if (toFetch.size > 0) { + const fetched = await this.noteReactionsRepository.find({ + where: { + userId: meId, + noteId: In(Array.from(toFetch)), + }, + select: { + noteId: true, + reaction: true, + }, + }); + + for (const { noteId, reaction } of fetched) { + const converted = this.reactionService.convertLegacyReaction(reaction); + fetchedReactions.set(noteId, converted); + } + } + + return fetchedReactions; + } + @bindThis public async populateMyReaction(note: { id: MiNote['id']; reactions: MiNote['reactions']; reactionAndUserPairCache?: MiNote['reactionAndUserPairCache']; }, meId: MiUser['id'], _hint_?: { myReactions: Map; @@ -306,9 +460,14 @@ export class NoteEntityService implements OnModuleInit { return undefined; } - const reaction = await this.noteReactionsRepository.findOneBy({ - userId: meId, - noteId: note.id, + const reaction = await this.noteReactionsRepository.findOne({ + where: { + userId: meId, + noteId: note.id, + }, + select: { + reaction: true, + }, }); if (reaction) { @@ -422,6 +581,10 @@ export class NoteEntityService implements OnModuleInit { pollVotes: Map>; channels: Map; notes: Map; + mutedThreads: Set; + mutedNotes: Set; + favoriteNotes: Set; + renotedNotes: Set; }; }, ): Promise> { @@ -460,8 +623,28 @@ export class NoteEntityService implements OnModuleInit { const packedFiles = options?._hint_?.packedFiles; const packedUsers = options?._hint_?.packedUsers; + const threadId = note.threadId ?? note.id; + const [mutedThreads, mutedNotes, isFavorited, isRenoted] = await Promise.all([ + // mutedThreads + opts._hint_?.mutedThreads + ?? (meId ? this.cacheService.threadMutingsCache.fetch(meId) : new Set()), + // mutedNotes + opts._hint_?.mutedNotes + ?? (meId ? this.cacheService.noteMutingsCache.fetch(meId) : new Set), + // isFavorited + opts._hint_?.favoriteNotes.has(note.id) + ?? (meId ? this.noteFavoritesRepository.existsBy({ userId: meId, noteId: note.id }) : false), + // isRenoted + opts._hint_?.renotedNotes.has(note.id) + ?? (meId ? this.queryService + .andIsRenote(this.notesRepository.createQueryBuilder('note'), 'note') + .andWhere({ renoteId: note.id, userId: meId }) + .getExists() : false), + ]); + const packed: Packed<'Note'> = await awaitAll({ id: note.id, + threadId, createdAt: this.idService.parse(note.id).date.toISOString(), updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined, userId: note.userId, @@ -501,6 +684,10 @@ export class NoteEntityService implements OnModuleInit { poll: opts._hint_?.polls.get(note.id), myVotes: opts._hint_?.pollVotes.get(note.id)?.get(note.userId), }) : undefined, + isMutingThread: mutedThreads.has(threadId), + isMutingNote: mutedNotes.has(note.id), + isFavorited, + isRenoted, ...(meId && Object.keys(reactions).length > 0 ? { myReaction: this.populateMyReaction({ @@ -648,7 +835,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, mutedNotes, favoriteNotes, renotedNotes] = await Promise.all([ // bufferedReactions & myReactionsMap this.getReactions(targetNotes, me), // packedFiles @@ -659,6 +846,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)), @@ -683,6 +871,24 @@ export class NoteEntityService implements OnModuleInit { }, new Map>)), // channels this.getChannels(targetNotes), + // mutedThreads + me ? this.cacheService.threadMutingsCache.fetch(me.id) : new Set(), + // mutedNotes + me ? this.cacheService.noteMutingsCache.fetch(me.id) : new Set(), + // favoriteNotes + me ? this.noteFavoritesRepository + .createQueryBuilder('favorite') + .select('favorite.noteId', 'noteId') + .where({ userId: me.id, noteId: In(noteIds) }) + .getRawMany<{ noteId: string }>() + .then(fs => new Set(fs.map(f => f.noteId))) : new Set(), + // renotedNotes + me ? this.queryService + .andIsRenote(this.notesRepository.createQueryBuilder('note'), 'note') + .andWhere({ userId: me.id, renoteId: In(noteIds) }) + .select('note.renoteId', 'renoteId') + .getRawMany<{ renoteId: string }>() + .then(ns => new Set(ns.map(n => n.renoteId))) : new Set(), // (not returned) this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes)), ]); @@ -701,6 +907,10 @@ export class NoteEntityService implements OnModuleInit { pollVotes, channels, notes: new Map(targetNotes.map(n => [n.id, n])), + mutedThreads, + mutedNotes, + favoriteNotes, + renotedNotes, }, }))); } diff --git a/packages/backend/src/models/NoteThreadMuting.ts b/packages/backend/src/models/NoteThreadMuting.ts index e7bd39f348..2d71825ab4 100644 --- a/packages/backend/src/models/NoteThreadMuting.ts +++ b/packages/backend/src/models/NoteThreadMuting.ts @@ -8,7 +8,7 @@ import { id } from './util/id.js'; import { MiUser } from './User.js'; @Entity('note_thread_muting') -@Index(['userId', 'threadId'], { unique: true }) +@Index(['userId', 'threadId', 'isPostMute'], { unique: true }) export class MiNoteThreadMuting { @PrimaryColumn(id()) public id: string; @@ -30,4 +30,10 @@ export class MiNoteThreadMuting { length: 256, }) public threadId: string; + + @Column('boolean', { + comment: 'If true, then this mute applies only to the referenced note. If false (default), then it applies to all replies as well.', + default: false, + }) + public isPostMute: boolean; } diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index b19c8f7c06..b57458235f 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, @@ -167,6 +173,22 @@ export const packedNoteSchema = { }, }, }, + isMutingThread: { + type: 'boolean', + optional: false, nullable: false, + }, + isMutingNote: { + type: 'boolean', + optional: false, nullable: false, + }, + isFavorited: { + type: 'boolean', + optional: false, nullable: false, + }, + isRenoted: { + type: 'boolean', + optional: false, nullable: false, + }, emojis: { type: 'object', optional: true, nullable: false, diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index eaeaecb1c2..de24a32eed 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -8,9 +8,8 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import * as WebSocket from 'ws'; import proxyAddr from 'proxy-addr'; -import ms from 'ms'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, MiAccessToken, MiUser } from '@/models/_.js'; +import type { UsersRepository, MiAccessToken, MiUser, NoteReactionsRepository, NotesRepository, NoteFavoritesRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; import type { Keyed, RateLimit } from '@/misc/rate-limit-utils.js'; import { NotificationService } from '@/core/NotificationService.js'; @@ -22,6 +21,7 @@ import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; import { LoggerService } from '@/core/LoggerService.js'; import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; +import { QueryService } from '@/core/QueryService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; import MainStreamConnection from './stream/Connection.js'; import { ChannelsService } from './stream/ChannelsService.js'; @@ -45,6 +45,16 @@ export class StreamingApiServerService { @Inject(DI.usersRepository) private usersRepository: UsersRepository, + @Inject(DI.noteReactionsRepository) + private readonly noteReactionsRepository: NoteReactionsRepository, + + @Inject(DI.notesRepository) + private readonly notesRepository: NotesRepository, + + @Inject(DI.noteFavoritesRepository) + private readonly noteFavoritesRepository: NoteFavoritesRepository, + + private readonly queryService: QueryService, private cacheService: CacheService, private authenticateService: AuthenticateService, private channelsService: ChannelsService, @@ -168,6 +178,10 @@ export class StreamingApiServerService { }; const stream = new MainStreamConnection( + this.noteReactionsRepository, + this.notesRepository, + this.noteFavoritesRepository, + this.queryService, this.channelsService, this.notificationService, this.cacheService, 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..953f01184f 100644 --- a/packages/backend/src/server/api/endpoints/notes/state.ts +++ b/packages/backend/src/server/api/endpoints/notes/state.ts @@ -7,6 +7,8 @@ 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'; +import { QueryService } from '@/core/QueryService.js'; export const meta = { tags: ['notes'], @@ -26,6 +28,14 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isMutedNote: { + type: 'boolean', + optional: false, nullable: false, + }, + isRenoted: { + type: 'boolean', + optional: false, nullable: false, + }, }, }, @@ -55,30 +65,39 @@ export default class extends Endpoint { // eslint- @Inject(DI.noteFavoritesRepository) private noteFavoritesRepository: NoteFavoritesRepository, + + private readonly cacheService: CacheService, + private readonly queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { const note = await this.notesRepository.findOneByOrFail({ id: ps.noteId }); - const [favorite, threadMuting] = await Promise.all([ - this.noteFavoritesRepository.count({ + const [favorite, threadMuting, noteMuting, renoted] = await Promise.all([ + // favorite + 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, }), + // treadMuting + this.cacheService.threadMutingsCache.fetch(me.id).then(ms => ms.has(note.threadId ?? note.id)), + // noteMuting + this.cacheService.noteMutingsCache.fetch(me.id).then(ms => ms.has(note.id)), + // renoted + this.notesRepository + .createQueryBuilder('note') + .andWhere({ renoteId: note.id, userId: me.id }) + .andWhere(qb => this.queryService + .andIsRenote(qb, 'note')) + .getExists(), ]); return { - isFavorited: favorite !== 0, - isMutedThread: threadMuting !== 0, + isFavorited: favorite, + isMutedThread: threadMuting, + isMutedNote: noteMuting, + isRenoted: renoted, }; }); } 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..497281521c 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 = { @@ -19,9 +20,11 @@ export const meta = { kind: 'write:account', + // Up to 10 calls, then 1/second limit: { - duration: ms('1hour'), - max: 10, + type: 'bucket', + size: 10, + dripRate: 1000, }, errors: { @@ -37,6 +40,7 @@ export const paramDef = { type: 'object', properties: { noteId: { type: 'string', format: 'misskey:id' }, + noteOnly: { type: 'boolean', default: false }, }, required: ['noteId'], } as const; @@ -52,6 +56,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 +64,7 @@ export default class extends Endpoint { // eslint- throw err; }); + /* const mutedNotes = await this.notesRepository.find({ where: [{ id: note.threadId ?? note.id, @@ -66,12 +72,20 @@ export default class extends Endpoint { // eslint- threadId: note.threadId ?? note.id, }], }); + */ + const threadId = note.threadId ?? note.id; await this.noteThreadMutingsRepository.insert({ id: this.idService.gen(), - threadId: note.threadId ?? note.id, + threadId: ps.noteOnly ? note.id : threadId, userId: me.id, + isPostMute: ps.noteOnly, }); + + await Promise.all([ + this.cacheService.threadMutingsCache.refresh(me.id), + this.cacheService.noteMutingsCache.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..fb843850b7 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 = { @@ -25,10 +26,11 @@ export const meta = { }, }, - // 10 calls per hour (match create) + // Up to 20 calls, then 2/second limit: { - duration: 1000 * 60 * 60, - max: 10, + type: 'bucket', + size: 20, + dripRate: 2000, }, } as const; @@ -36,6 +38,7 @@ export const paramDef = { type: 'object', properties: { noteId: { type: 'string', format: 'misskey:id' }, + noteOnly: { type: 'boolean', default: false }, }, required: ['noteId'], } as const; @@ -47,6 +50,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 => { @@ -54,10 +58,17 @@ export default class extends Endpoint { // eslint- throw err; }); + const threadId = note.threadId ?? note.id; await this.noteThreadMutingsRepository.delete({ - threadId: note.threadId ?? note.id, + threadId: ps.noteOnly ? note.id : threadId, userId: me.id, + isPostMute: ps.noteOnly, }); + + await Promise.all([ + this.cacheService.threadMutingsCache.refresh(me.id), + this.cacheService.noteMutingsCache.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..8d8f211af3 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -10,13 +10,14 @@ import type { Packed } from '@/misc/json-schema.js'; import type { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; -import { MiFollowing, MiUserProfile } from '@/models/_.js'; +import type { MiFollowing, MiUserProfile, NoteFavoritesRepository, NoteReactionsRepository, NotesRepository } from '@/models/_.js'; import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js'; import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; +import { QueryService } from '@/core/QueryService.js'; import type { ChannelsService } from './ChannelsService.js'; import type { EventEmitter } from 'events'; import type Channel from './channel.js'; @@ -42,14 +43,23 @@ export default class Connection { public userIdsWhoBlockingMe: Set = new Set(); public userIdsWhoMeMutingRenotes: Set = new Set(); public userMutedInstances: Set = new Set(); + public userMutedThreads: Set = new Set(); + public userMutedNotes: Set = new Set(); + public myRecentReactions: Map = new Map(); + public myRecentRenotes: Set = new Set(); + public myRecentFavorites: Set = new Set(); private fetchIntervalId: NodeJS.Timeout | null = null; private closingConnection = false; private logger: Logger; constructor( + private readonly noteReactionsRepository: NoteReactionsRepository, + private readonly notesRepository: NotesRepository, + private readonly noteFavoritesRepository: NoteFavoritesRepository, + private readonly queryService: QueryService, private channelsService: ChannelsService, private notificationService: NotificationService, - private cacheService: CacheService, + public readonly cacheService: CacheService, private channelFollowingService: ChannelFollowingService, loggerService: LoggerService, @@ -67,13 +77,34 @@ 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, noteMutings, myRecentReactions, myRecentFavorites, myRecentRenotes] = 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.cacheService.noteMutingsCache.fetch(this.user.id), + this.noteReactionsRepository.find({ + where: { userId: this.user.id }, + select: { noteId: true, reaction: true }, + order: { id: 'desc' }, + take: 100, + }), + this.noteFavoritesRepository.find({ + where: { userId: this.user.id }, + select: { noteId: true }, + order: { id: 'desc' }, + take: 100, + }), + this.queryService + .andIsRenote(this.notesRepository.createQueryBuilder('note'), 'note') + .andWhere({ userId: this.user.id }) + .orderBy({ id: 'DESC' }) + .limit(100) + .select('note.renoteId', 'renoteId') + .getRawMany<{ renoteId: string }>(), ]); this.userProfile = userProfile; this.following = following; @@ -82,6 +113,11 @@ export default class Connection { this.userIdsWhoBlockingMe = userIdsWhoBlockingMe; this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes; this.userMutedInstances = new Set(userProfile.mutedInstances); + this.userMutedThreads = threadMutings; + this.userMutedNotes = noteMutings; + this.myRecentReactions = new Map(myRecentReactions.map(r => [r.noteId, r.reaction])); + this.myRecentFavorites = new Set(myRecentFavorites.map(f => f.noteId )); + this.myRecentRenotes = new Set(myRecentRenotes.map(r => r.renoteId )); } @bindThis diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 40ad454adb..7e0e3e0bc6 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -10,7 +10,8 @@ import { isRenotePacked, isQuotePacked, isPackedPureRenote } from '@/misc/is-ren import type { Packed } from '@/misc/json-schema.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import type Connection from './Connection.js'; +import { deepClone } from '@/misc/clone.js'; +import type Connection from '@/server/api/stream/Connection.js'; /** * Stream channel @@ -33,18 +34,35 @@ export default abstract class Channel { return this.connection.userProfile; } + protected get cacheService() { + return this.connection.cacheService; + } + + /** + * @deprecated use cacheService.userFollowingsCache to avoid stale data + */ protected get following() { return this.connection.following; } + /** + * TODO use onChange to keep these in sync? + * @deprecated use cacheService.userMutingsCache to avoid stale data + */ protected get userIdsWhoMeMuting() { return this.connection.userIdsWhoMeMuting; } + /** + * @deprecated use cacheService.renoteMutingsCache to avoid stale data + */ protected get userIdsWhoMeMutingRenotes() { return this.connection.userIdsWhoMeMutingRenotes; } + /** + * @deprecated use cacheService.userBlockedCache to avoid stale data + */ protected get userIdsWhoBlockingMe() { return this.connection.userIdsWhoBlockingMe; } @@ -53,6 +71,20 @@ export default abstract class Channel { return this.connection.userMutedInstances; } + /** + * @deprecated use cacheService.threadMutingsCache to avoid stale data + */ + protected get userMutedThreads() { + return this.connection.userMutedThreads; + } + + /** + * @deprecated use cacheService.noteMutingsCache to avoid stale data + */ + protected get userMutedNotes() { + return this.connection.userMutedNotes; + } + protected get followingChannels() { return this.connection.followingChannels; } @@ -61,6 +93,18 @@ export default abstract class Channel { return this.connection.subscriber; } + protected get myRecentReactions() { + return this.connection.myRecentReactions; + } + + protected get myRecentRenotes() { + return this.connection.myRecentRenotes; + } + + protected get myRecentFavorites() { + return this.connection.myRecentFavorites; + } + /** * Checks if a note is visible to the current user *excluding* blocks and mutes. */ @@ -94,6 +138,12 @@ 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; + + // Muted note + if (this.userMutedNotes.has(note.id)) 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; @@ -104,29 +154,9 @@ export default abstract class Channel { if (!this.following.has(note.userId)) return true; } - // TODO muted threads - return false; } - /** - * This function modifies {@link note}, please make sure it has been shallow cloned. - * See Dakkar's comment of {@link assignMyReaction} for more - * @param note The note to change - */ - protected async hideNote(note: Packed<'Note'>): Promise { - if (note.renote) { - await this.hideNote(note.renote); - } - - if (note.reply) { - await this.hideNote(note.reply); - } - - const meId = this.user?.id ?? null; - await this.noteEntityService.hideNote(note, meId); - } - constructor(id: string, connection: Connection, noteEntityService: NoteEntityService) { this.id = id; this.connection = connection; @@ -153,37 +183,44 @@ export default abstract class Channel { public onMessage?(type: string, body: JsonValue): void; - public async assignMyReaction(note: Packed<'Note'>): Promise> { + public async rePackNote(note: Packed<'Note'>): Promise> { + // If there's no user, then packing won't change anything. + // We can just re-use the original note. + if (!this.user) { + return note; + } + // StreamingApiServerService creates a single EventEmitter per server process, // so a new note arriving from redis gets de-serialised once per server process, // and then that single object is passed to all active channels on each connection. // If we didn't clone the notes here, different connections would asynchronously write // different values to the same object, resulting in a random value being sent to each frontend. -- Dakkar - const clonedNote = { ...note }; - if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { - if (note.renote && Object.keys(note.renote.reactions).length > 0) { - const myReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); - if (myReaction) { - clonedNote.renote = { ...note.renote }; - clonedNote.renote.myReaction = myReaction; - } - } - if (note.renote?.reply && Object.keys(note.renote.reply.reactions).length > 0) { - const myReaction = await this.noteEntityService.populateMyReaction(note.renote.reply, this.user.id); - if (myReaction) { - clonedNote.renote = { ...note.renote }; - clonedNote.renote.reply = { ...note.renote.reply }; - clonedNote.renote.reply.myReaction = myReaction; - } - } - } - if (this.user && note.reply && Object.keys(note.reply.reactions).length > 0) { - const myReaction = await this.noteEntityService.populateMyReaction(note.reply, this.user.id); - if (myReaction) { - clonedNote.reply = { ...note.reply }; - clonedNote.reply.myReaction = myReaction; - } - } + const clonedNote = deepClone(note); + const notes = crawl(clonedNote); + + // Hide notes before everything else, since this modifies fields that the other functions will check. + await this.noteEntityService.hideNotes(notes, this.user.id); + + const [myReactions, myRenotes, myFavorites, myThreadMutings, myNoteMutings] = await Promise.all([ + this.noteEntityService.populateMyReactions(notes, this.user.id, { + myReactions: this.myRecentReactions, + }), + this.noteEntityService.populateMyRenotes(notes, this.user.id, { + myRenotes: this.myRecentRenotes, + }), + this.noteEntityService.populateMyFavorites(notes, this.user.id, { + myFavorites: this.myRecentFavorites, + }), + this.noteEntityService.populateMyTheadMutings(notes, this.user.id), + this.noteEntityService.populateMyNoteMutings(notes, this.user.id), + ]); + + note.myReaction = myReactions.get(note.id) ?? null; + note.isRenoted = myRenotes.has(note.id); + note.isFavorited = myFavorites.has(note.id); + note.isMutingThread = myThreadMutings.has(note.id); + note.isMutingNote = myNoteMutings.has(note.id); + return clonedNote; } } @@ -194,3 +231,21 @@ export type MiChannelService = { kind: T extends true ? string : string | null | undefined; create: (id: string, connection: Connection) => Channel; }; + +function crawl(note: Packed<'Note'>, into?: Packed<'Note'>[]): Packed<'Note'>[] { + into ??= []; + + if (!into.includes(note)) { + into.push(note); + } + + if (note.reply) { + crawl(note.reply, into); + } + + if (note.renote) { + crawl(note.renote, into); + } + + return into; +} diff --git a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts index 72f719b411..a7f538eeda 100644 --- a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts @@ -78,9 +78,7 @@ class BubbleTimelineChannel extends Channel { } } - const clonedNote = await this.assignMyReaction(note); - await this.hideNote(clonedNote); - + const clonedNote = await this.rePackNote(note); this.send('note', clonedNote); } diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 65fb8d67cb..9eea423088 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -49,9 +49,7 @@ class ChannelChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - const clonedNote = await this.assignMyReaction(note); - await this.hideNote(clonedNote); - + const clonedNote = await this.rePackNote(note); this.send('note', clonedNote); } diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index 5c73f637c7..9bdd299ddb 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -79,9 +79,7 @@ class GlobalTimelineChannel extends Channel { } } - const clonedNote = await this.assignMyReaction(note); - await this.hideNote(clonedNote); - + const clonedNote = await this.rePackNote(note); this.send('note', clonedNote); } diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index f47a10f293..e0331828d2 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -45,9 +45,7 @@ class HashtagChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - const clonedNote = await this.assignMyReaction(note); - await this.hideNote(clonedNote); - + const clonedNote = await this.rePackNote(note); this.send('note', clonedNote); } diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index c7062c0394..bb28cbf81e 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -73,9 +73,7 @@ class HomeTimelineChannel extends Channel { } } - const clonedNote = await this.assignMyReaction(note); - await this.hideNote(clonedNote); - + const clonedNote = await this.rePackNote(note); this.send('note', clonedNote); } diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 7cb64c9f89..82d96c6e7e 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -90,9 +90,7 @@ class HybridTimelineChannel extends Channel { } } - const clonedNote = await this.assignMyReaction(note); - await this.hideNote(clonedNote); - + const clonedNote = await this.rePackNote(note); this.send('note', clonedNote); } diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 4869d871d6..c6c04d356f 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -82,9 +82,7 @@ class LocalTimelineChannel extends Channel { } } - const clonedNote = await this.assignMyReaction(note); - await this.hideNote(clonedNote); - + const clonedNote = await this.rePackNote(note); this.send('note', clonedNote); } diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts index a3886618f1..2a8d4f7ffd 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -70,9 +70,7 @@ class RoleTimelineChannel extends Channel { } } - const clonedNote = await this.assignMyReaction(note); - await this.hideNote(clonedNote); - + const clonedNote = await this.rePackNote(note); this.send('note', clonedNote); } else { this.send(data.type, data.body); diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 4dae24a696..98bcbd11ba 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -114,9 +114,7 @@ class UserListChannel extends Channel { } } - const clonedNote = await this.assignMyReaction(note); - await this.hideNote(clonedNote); - + const clonedNote = await this.rePackNote(note); this.send('note', clonedNote); } diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index c40d042fa4..654d477628 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -601,6 +601,7 @@ export class ClientServerService { relations: ['user'], }); + // TODO pack with current user, or the frontend can get bad data if (note && !note.user!.requireSigninToViewContents) { const _note = await this.noteEntityService.pack(note); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId }); diff --git a/packages/backend/test/misc/noOpCaches.ts b/packages/backend/test/misc/noOpCaches.ts index f3cc1e2ba2..208d51f23a 100644 --- a/packages/backend/test/misc/noOpCaches.ts +++ b/packages/backend/test/misc/noOpCaches.ts @@ -6,7 +6,7 @@ import * as Redis from 'ioredis'; import { Inject } from '@nestjs/common'; import { FakeInternalEventService } from './FakeInternalEventService.js'; -import type { BlockingsRepository, FollowingsRepository, MiUser, MutingsRepository, RenoteMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { BlockingsRepository, FollowingsRepository, MiUser, MutingsRepository, NoteThreadMutingsRepository, RenoteMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { MiLocalUser } from '@/models/User.js'; import { MemoryKVCache, MemorySingleCache, RedisKVCache, RedisSingleCache } from '@/misc/cache.js'; import { QuantumKVCache, QuantumKVOpts } from '@/misc/QuantumKVCache.js'; @@ -50,6 +50,9 @@ export class NoOpCacheService extends CacheService { @Inject(DI.followingsRepository) followingsRepository: FollowingsRepository, + @Inject(DI.noteThreadMutingsRepository) + noteThreadMutingsRepository: NoteThreadMutingsRepository, + @Inject(UserEntityService) userEntityService: UserEntityService, ) { @@ -65,6 +68,7 @@ export class NoOpCacheService extends CacheService { blockingsRepository, renoteMutingsRepository, followingsRepository, + noteThreadMutingsRepository, userEntityService, fakeInternalEventService, ); @@ -82,6 +86,8 @@ export class NoOpCacheService extends CacheService { this.userBlockingCache = NoOpQuantumKVCache.copy(this.userBlockingCache, fakeInternalEventService); this.userBlockedCache = NoOpQuantumKVCache.copy(this.userBlockedCache, fakeInternalEventService); this.renoteMutingsCache = NoOpQuantumKVCache.copy(this.renoteMutingsCache, fakeInternalEventService); + this.threadMutingsCache = NoOpQuantumKVCache.copy(this.threadMutingsCache, fakeInternalEventService); + this.noteMutingsCache = NoOpQuantumKVCache.copy(this.noteMutingsCache, fakeInternalEventService); this.userFollowingsCache = NoOpQuantumKVCache.copy(this.userFollowingsCache, fakeInternalEventService); this.userFollowersCache = NoOpQuantumKVCache.copy(this.userFollowersCache, fakeInternalEventService); this.hibernatedUserCache = NoOpQuantumKVCache.copy(this.hibernatedUserCache, fakeInternalEventService); 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..a7880550d5 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