From 825f219368f2e8c0587630f3ac0cb0e07d4aa7a0 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 2 Jun 2025 15:25:53 -0400 Subject: [PATCH 01/53] improve query/parameter logging --- packages/backend/src/postgres.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index b4bd934972..45caec54ce 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -145,7 +145,10 @@ class MyCustomLogger implements Logger { @bindThis private transformParameters(parameters?: any[]) { if (this.props.enableQueryParamLogging && parameters && parameters.length > 0) { - return parameters.map(stringifyParameter); + return parameters.reduce((params, p, i) => { + params[`$${i + 1}`] = stringifyParameter(p); + return params; + }, {} as Record); } return undefined; @@ -158,7 +161,8 @@ class MyCustomLogger implements Logger { const prefix = (this.props.printReplicationMode && queryRunner) ? `[${queryRunner.getReplicationMode()}] ` : undefined; - sqlLogger.info(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters)); + const transformed = this.transformQueryLog(query, { prefix }); + sqlLogger.debug(`Query run: ${transformed}`, this.transformParameters(parameters)); } @bindThis From cbefbd2a33eefcada4be2554d3f2560ad094b5f5 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 2 Jun 2025 16:58:54 -0400 Subject: [PATCH 02/53] refactor QueryService to use EXISTS instead of IN for most queries --- packages/backend/src/core/QueryService.ts | 376 +++++++++++++--------- 1 file changed, 221 insertions(+), 155 deletions(-) diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index cf2419a9eb..548887959d 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -4,13 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { Brackets, ObjectLiteral } from 'typeorm'; +import { Brackets, Not, WhereExpressionBuilder } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { MiUser } from '@/models/User.js'; -import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta } from '@/models/_.js'; +import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta, InstancesRepository, MiInstance } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; -import type { SelectQueryBuilder } from 'typeorm'; +import type { SelectQueryBuilder, FindOptionsWhere, ObjectLiteral } from 'typeorm'; @Injectable() export class QueryService { @@ -36,6 +36,9 @@ export class QueryService { @Inject(DI.renoteMutingsRepository) private renoteMutingsRepository: RenoteMutingsRepository, + @Inject(DI.instancesRepository) + private readonly instancesRepository: InstancesRepository, + @Inject(DI.meta) private meta: MiMeta, @@ -72,215 +75,278 @@ export class QueryService { // ここでいうBlockedは被Blockedの意 @bindThis - public generateBlockedUserQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { - const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') - .select('blocking.blockerId') - .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); - + public generateBlockedUserQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { // 投稿の作者にブロックされていない かつ // 投稿の返信先の作者にブロックされていない かつ // 投稿の引用元の作者にブロックされていない - q - .andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`) + return this.excludeBlockingUser(q, 'note.userId', ':meId') .andWhere(new Brackets(qb => { - qb - .where('note.replyUserId IS NULL') - .orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`); + this.excludeBlockingUser(qb, 'note.replyUserId', ':meId') + .orWhere('note.replyUserId IS NULL'); })) .andWhere(new Brackets(qb => { - qb - .where('note.renoteUserId IS NULL') - .orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`); - })); - - q.setParameters(blockingQuery.getParameters()); + this.excludeBlockingUser(qb, 'note.renoteUserId', ':meId') + .orWhere('note.renoteUserId IS NULL'); + })) + .setParameters({ meId: me.id }); } @bindThis - public generateBlockQueryForUsers(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { - const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') - .select('blocking.blockeeId') - .where('blocking.blockerId = :blockerId', { blockerId: me.id }); - - const blockedQuery = this.blockingsRepository.createQueryBuilder('blocking') - .select('blocking.blockerId') - .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); - - q.andWhere(`user.id NOT IN (${ blockingQuery.getQuery() })`); - q.setParameters(blockingQuery.getParameters()); - - q.andWhere(`user.id NOT IN (${ blockedQuery.getQuery() })`); - q.setParameters(blockedQuery.getParameters()); + public generateBlockQueryForUsers(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { + this.excludeBlockingUser(q, ':meId', 'user.id'); + this.excludeBlockingUser(q, 'user.id', ':me.id'); + return q.setParameters({ meId: me.id }); } @bindThis - public generateMutedNoteThreadQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { - const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') - .select('threadMuted.threadId') - .where('threadMuted.userId = :userId', { userId: me.id }); - - q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); - q.andWhere(new Brackets(qb => { - qb - .where('note.threadId IS NULL') - .orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`); - })); - - q.setParameters(mutedQuery.getParameters()); + public generateMutedNoteThreadQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { + return this.excludeMutingThread(q, ':meId', 'note.id') + .andWhere(new Brackets(qb => { + this.excludeMutingThread(qb, ':meId', 'note.threadId') + .orWhere('note.threadId IS NULL'); + })) + .setParameters({ meId: me.id }); } @bindThis - public generateMutedUserQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void { - const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') - .select('muting.muteeId') - .where('muting.muterId = :muterId', { muterId: me.id }); - - if (exclude) { - mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id }); - } - - const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile') - .select('user_profile.mutedInstances') - .where('user_profile.userId = :muterId', { muterId: me.id }); - + public generateMutedUserQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): SelectQueryBuilder { // 投稿の作者をミュートしていない かつ // 投稿の返信先の作者をミュートしていない かつ // 投稿の引用元の作者をミュートしていない - q - .andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`) + this.excludeMutingUser(q, ':meId', 'note.userId', exclude) .andWhere(new Brackets(qb => { - qb - .where('note.replyUserId IS NULL') - .orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`); + this.excludeMutingUser(qb, ':meId', 'note.replyUserId', exclude) + .orWhere('note.replyUserId IS NULL'); })) .andWhere(new Brackets(qb => { - qb - .where('note.renoteUserId IS NULL') - .orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`); - })) - // mute instances - .andWhere(new Brackets(qb => { - qb - .andWhere('note.userHost IS NULL') - .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`); - })) - .andWhere(new Brackets(qb => { - qb - .where('note.replyUserHost IS NULL') - .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`); - })) - .andWhere(new Brackets(qb => { - qb - .where('note.renoteUserHost IS NULL') - .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`); + this.excludeMutingUser(qb, ':meId', 'note.renoteUserId', exclude) + .orWhere('note.renoteUserId IS NULL'); })); - q.setParameters(mutingQuery.getParameters()); - q.setParameters(mutingInstanceQuery.getParameters()); + // mute instances + this.excludeMutingInstance(q, ':meId', 'note.userHost') + .andWhere(new Brackets(qb => { + this.excludeMutingInstance(qb, ':meId', 'note.replyUserHost') + .orWhere('note.replyUserHost IS NULL'); + })) + .andWhere(new Brackets(qb => { + this.excludeMutingInstance(qb, ':meId', 'note.renoteUserHost') + .orWhere('note.renoteUserHost IS NULL'); + })); + + return q.setParameters({ meId: me.id }); } @bindThis - public generateMutedUserQueryForUsers(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { - const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') - .select('muting.muteeId') - .where('muting.muterId = :muterId', { muterId: me.id }); - - q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`); - - q.setParameters(mutingQuery.getParameters()); + public generateMutedUserQueryForUsers(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { + return this.excludeMutingUser(q, ':meId', 'user.id') + .setParameters({ meId: me.id }); } + // This intentionally skips isSuspended, isDeleted, makeNotesFollowersOnlyBefore, makeNotesHiddenBefore, and requireSigninToViewContents. + // NoteEntityService checks these automatically and calls hideNote() to hide them without breaking threads. + // For moderation purposes, you can set isSilenced to forcibly hide existing posts by a user. @bindThis - public generateVisibilityQuery(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null): void { + public generateVisibilityQuery(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null): SelectQueryBuilder { // This code must always be synchronized with the checks in Notes.isVisibleForMe. - if (me == null) { - q.andWhere(new Brackets(qb => { - qb - .where('note.visibility = \'public\'') - .orWhere('note.visibility = \'home\''); - })); - } else { - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :meId'); + return q.andWhere(new Brackets(qb => { + // Public post + qb.orWhere('note.visibility = \'public\'') + .orWhere('note.visibility = \'home\''); - q.andWhere(new Brackets(qb => { + if (me != null) { qb - // 公開投稿である - .where(new Brackets(qb => { - qb - .where('note.visibility = \'public\'') - .orWhere('note.visibility = \'home\''); - })) - // または 自分自身 - .orWhere('note.userId = :meId') - // または 自分宛て - .orWhere(':meIdAsList <@ note.visibleUserIds') + // My post + .orWhere(':meId = note.userId') + // Reply to me + .orWhere(':meId = note.replyUserId') + // DM to me + .orWhere(':meId = ANY (note.visibleUserIds)') + // Mentions me + .orWhere(':meId = ANY (note.mentions)') + // Followers-only post .orWhere(new Brackets(qb => { - qb // または フォロワー宛ての投稿であり、 - .where('note.visibility = \'followers\'') - .andWhere(new Brackets(qb => { - qb - // 自分がフォロワーである - .where(`note.userId IN (${ followingQuery.getQuery() })`) - // または 自分の投稿へのリプライ - .orWhere('note.replyUserId = :meId') - .orWhere(':meIdAsList <@ note.mentions'); - })); + this.addFollowingUser(qb, ':meId', 'note.userId') + .andWhere('note.visibility = \'followers\''); })); - })); - q.setParameters({ meId: me.id, meIdAsList: [me.id] }); - } + q.setParameters({ meId: me.id }); + } + })); } @bindThis - public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { - const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting') - .select('renote_muting.muteeId') - .where('renote_muting.muterId = :muterId', { muterId: me.id }); - - q.andWhere(new Brackets(qb => { - qb + public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { + return q.andWhere(new Brackets(qb => { + this.excludeMutingRenote(qb, ':meId', 'note.userId') .orWhere('note.renoteId IS NULL') .orWhere('note.text IS NOT NULL') .orWhere('note.cw IS NOT NULL') .orWhere('note.replyId IS NOT NULL') - .orWhere('note.hasPoll = false') - .orWhere('note.fileIds != \'{}\'') - .orWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`); - })); - - q.setParameters(mutingQuery.getParameters()); + .orWhere('note.hasPoll = true') + .orWhere('note.fileIds != \'{}\''); + })) + .setParameters({ meId: me.id }); } + // TODO replace allowSilenced with matchingHostQuery @bindThis - public generateBlockedHostQueryForNote(q: SelectQueryBuilder, excludeAuthor?: boolean, allowSilenced = true): void { - function checkFor(key: 'user' | 'replyUser' | 'renoteUser') { - q.leftJoin(`note.${key}Instance`, `${key}Instance`); + public generateBlockedHostQueryForNote(q: SelectQueryBuilder, excludeAuthor?: boolean, allowSilenced = true): SelectQueryBuilder { + const checkFor = (key: 'user' | 'replyUser' | 'renoteUser') => { q.andWhere(new Brackets(qb => { - qb.orWhere(`note.${key}Id IS NULL`) // no corresponding user - .orWhere(`note.${key}Host IS NULL`); // local + qb.orWhere(`note.${key}Host IS NULL`); // local + + if (key !== 'user') { + // note.userId always exists and is non-null + qb.orWhere(`note.${key}Id IS NULL`); // no corresponding user + + // note.userId always equals note.userId + if (excludeAuthor) { + qb.orWhere(`note.userId = note.${key}Id`); // author + } + } if (allowSilenced) { - qb.orWhere(`${key}Instance.isBlocked = false`); // not blocked + // not blocked + this.excludeInstanceWhere(qb, `note.${key}Host`, { + isBlocked: false, + }, 'orWhere'); } else { - qb.orWhere(new Brackets(qbb => qbb - .andWhere(`${key}Instance.isBlocked = false`) // not blocked - .andWhere(`${key}Instance.isSilenced = false`))); // not silenced - } - - if (excludeAuthor) { - qb.orWhere(`note.userId = note.${key}Id`); // author + // not blocked or silenced + this.excludeInstanceWhere(qb, `note.${key}Host`, { + isBlocked: false, + isSilenced: false, + }, 'orWhere'); } })); - } + }; if (!excludeAuthor) { checkFor('user'); } checkFor('replyUser'); checkFor('renoteUser'); + + return q; + } + + @bindThis + public generateMatchingHostQueryForNote(q: SelectQueryBuilder, filters: FindOptionsWhere | FindOptionsWhere[], hostProp = 'note.userHost'): SelectQueryBuilder { + return this.includeInstanceWhere(q, hostProp, filters); + } + + /** + * Adds condition that hostProp (instance host) matches the given filters. + * The prop should be an expression, not raw values. + */ + @bindThis + public includeInstanceWhere(q: Q, hostProp: string, filters: FindOptionsWhere | FindOptionsWhere[], join: 'andWhere' | 'orWhere' = 'andWhere'): Q { + const instancesQuery = this.instancesRepository.createQueryBuilder('instance') + .select('1') + .andWhere(`instance.host = ${hostProp}`) + .andWhere(filters); + + return q[join](`EXISTS (${instancesQuery.getQuery()})`, instancesQuery.getParameters()); + } + + /** + * Adds condition that hostProp (instance host) matches the given filters. + * The prop should be an expression, not raw values. + */ + @bindThis + public excludeInstanceWhere(q: Q, hostProp: string, filters: FindOptionsWhere | FindOptionsWhere[], join: 'andWhere' | 'orWhere' = 'andWhere'): Q { + const instancesQuery = this.instancesRepository.createQueryBuilder('instance') + .select('1') + .andWhere(`instance.host = ${hostProp}`) + .andWhere(filters); + + return q[join](`NOT EXISTS (${instancesQuery.getQuery()})`, instancesQuery.getParameters()); + } + + /** + * Adds condition that followerProp (user ID) is following followeeProp (user ID). + * Both props should be expressions, not raw values. + */ + public addFollowingUser(q: Q, followerProp: string, followeeProp: string): Q { + const followingQuery = this.followingsRepository.createQueryBuilder('following') + .select('1') + .andWhere(`following.followerId = ${followerProp}`) + .andWhere(`following.followeeId = ${followeeProp}`); + + return q.andWhere(`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters()); + }; + + /** + * Adds condition that blockerProp (user ID) is not blocking blockeeProp (user ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public excludeBlockingUser(q: Q, blockerProp: string, blockeeProp: string): Q { + const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') + .select('1') + .andWhere(`blocking.blockerId = ${blockerProp}`) + .andWhere(`blocking.blockeeId = ${blockeeProp}`); + + return q.andWhere(`NOT EXISTS (${blockingQuery.getQuery()})`, blockingQuery.getParameters()); + }; + + /** + * Adds condition that muterProp (user ID) is not muting muteeProp (user ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public excludeMutingUser(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q { + const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') + .select('1') + .andWhere(`muting.muterId = ${muterProp}`) + .andWhere(`muting.muteeId = ${muteeProp}`); + + if (exclude) { + mutingQuery.andWhere({ muteeId: Not(exclude.id) }); + } + + return q.andWhere(`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters()); + } + + /** + * Adds condition that muterProp (user ID) is not muting renotes by muteeProp (user ID). + * Both props should be expressions, not raw values. + */ + public excludeMutingRenote(q: Q, muterProp: string, muteeProp: string): Q { + const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting') + .select('1') + .andWhere(`renote_muting.muterId = ${muterProp}`) + .andWhere(`renote_muting.muteeId = ${muteeProp}`); + + return q.andWhere(`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters()); + }; + + /** + * Adds condition that muterProp (user ID) is not muting muteeProp (instance host). + * Both props should be expressions, not raw values. + */ + @bindThis + public excludeMutingInstance(q: Q, muterProp: string, muteeProp: string): Q { + const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile') + .select('1') + .andWhere(`user_profile.userId = ${muterProp}`) + .andWhere(`"user_profile"."mutedInstances"::jsonb ? ${muteeProp}`); + + return q.andWhere(`NOT EXISTS (${mutingInstanceQuery.getQuery()})`, mutingInstanceQuery.getParameters()); + } + + /** + * Adds condition that muterProp (user ID) is not muting muteeProp (note ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public excludeMutingThread(q: Q, muterProp: string, muteeProp: string): Q { + const threadMutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') + .select('1') + .andWhere(`threadMuted.userId = ${muterProp}`) + .andWhere(`threadMuted.threadId = ${muteeProp}`); + + return q.andWhere(`NOT EXISTS (${threadMutedQuery.getQuery()})`, threadMutedQuery.getParameters()); } } From 078399997446b2435335ca0b93730557798942bd Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 2 Jun 2025 17:22:41 -0400 Subject: [PATCH 03/53] add copyright header to bubble-timeline.ts --- .../src/server/api/endpoints/notes/bubble-timeline.ts | 5 +++++ 1 file changed, 5 insertions(+) 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 17c9b31c90..882e344883 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -1,3 +1,8 @@ +/* + * SPDX-FileCopyrightText: Marie and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + import { Inject, Injectable } from '@nestjs/common'; import { Brackets } from 'typeorm'; import type { NotesRepository, MiMeta } from '@/models/_.js'; From 0e16a56086a45263f812dc5c1ad39449f209ccd4 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 2 Jun 2025 17:26:58 -0400 Subject: [PATCH 04/53] remove unused DI from bubble-timeline.ts --- .../src/server/api/endpoints/notes/bubble-timeline.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 882e344883..25f3bd6c9e 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets } from 'typeorm'; -import type { NotesRepository, MiMeta } from '@/models/_.js'; +import type { NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -61,9 +61,6 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.meta) - private serverSettings: MiMeta, - @Inject(DI.notesRepository) private notesRepository: NotesRepository, From 9ebb254ce54d909a3bdd7ed4b17e792e01fa3e7f Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 2 Jun 2025 17:27:33 -0400 Subject: [PATCH 05/53] avoid unnecessary nextTick callbacks in bubble-timeline.ts --- .../src/server/api/endpoints/notes/bubble-timeline.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 25f3bd6c9e..281a781922 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -128,11 +128,11 @@ export default class extends Endpoint { // eslint- return true; }); - process.nextTick(() => { - if (me) { + if (me) { + process.nextTick(() => { this.activeUsersChart.read(me); - } - }); + }); + } return await this.noteEntityService.packMany(timeline, me); }); From 0a59276457c54573a65398ffe84a3710013423f4 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 2 Jun 2025 17:28:10 -0400 Subject: [PATCH 06/53] remove unnecessary call to generateVisibilityQuery in bubble-timeline.ts --- .../backend/src/server/api/endpoints/notes/bubble-timeline.ts | 1 - 1 file changed, 1 deletion(-) 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 281a781922..1ef57a14a2 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -91,7 +91,6 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); - this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); From 022c49f45f1e00a2b7b04040be28420247ebb6ea Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 2 Jun 2025 17:28:48 -0400 Subject: [PATCH 07/53] fix query slowdown caused by unnecessary userHost IS NOT NULL condition in bubble-timeline.ts --- .../backend/src/server/api/endpoints/notes/bubble-timeline.ts | 1 - 1 file changed, 1 deletion(-) 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 1ef57a14a2..ef3502de77 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -83,7 +83,6 @@ export default class extends Endpoint { // eslint- ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('note.visibility = \'public\'') .andWhere('note.channelId IS NULL') - .andWhere('note.userHost IS NOT NULL') .andWhere('userInstance.isBubbled = true') // This comes from generateBlockedHostQueryForNote below .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') From d4ea917869e809e4d9f1c8e41dc8ad5b155b5f27 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 2 Jun 2025 17:30:34 -0400 Subject: [PATCH 08/53] remove incorrect requireSigninToViewContents check in bubble-timeline.ts --- .../backend/src/server/api/endpoints/notes/bubble-timeline.ts | 2 -- 1 file changed, 2 deletions(-) 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 ef3502de77..1a73f3c7f7 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -83,7 +83,6 @@ export default class extends Endpoint { // eslint- ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('note.visibility = \'public\'') .andWhere('note.channelId IS NULL') - .andWhere('userInstance.isBubbled = true') // This comes from generateBlockedHostQueryForNote below .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') @@ -94,7 +93,6 @@ export default class extends Endpoint { // eslint- if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - if (!me) query.andWhere('user.requireSigninToViewContents = false'); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); From 0ccbb8f3cf1e219818037a0c58dab79e430ece1a Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 2 Jun 2025 17:31:09 -0400 Subject: [PATCH 09/53] fix hasPoll check in bubble-timeline.ts --- .../backend/src/server/api/endpoints/notes/bubble-timeline.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1a73f3c7f7..ec19fda6a9 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -106,7 +106,7 @@ export default class extends Endpoint { // eslint- .orWhere('note.text IS NOT NULL') .orWhere('note.cw IS NOT NULL') .orWhere('note.replyId IS NOT NULL') - .orWhere('note.hasPoll = false') + .orWhere('note.hasPoll = true') .orWhere('note.fileIds != \'{}\''))); } //#endregion From 005b23947e8e9f792b0cd894882bcea1f460765a Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 2 Jun 2025 17:32:06 -0400 Subject: [PATCH 10/53] reduce calls to generateMutedUserRenotesQueryForNotes in bubble-timeline.ts --- .../backend/src/server/api/endpoints/notes/bubble-timeline.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 ec19fda6a9..716330bae9 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -92,7 +92,6 @@ export default class extends Endpoint { // eslint- this.queryService.generateBlockedHostQueryForNote(query); if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); - if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); @@ -108,6 +107,9 @@ export default class extends Endpoint { // eslint- .orWhere('note.replyId IS NOT NULL') .orWhere('note.hasPoll = true') .orWhere('note.fileIds != \'{}\''))); + } else if (me) { + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } } //#endregion From c49341e7be8ed575625d335e7794e60c4cf9004a Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 2 Jun 2025 17:32:26 -0400 Subject: [PATCH 11/53] match if(me) formatting of other endpoints in bubble-timeline.ts --- .../src/server/api/endpoints/notes/bubble-timeline.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 716330bae9..8acf1711eb 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -90,8 +90,10 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateBlockedHostQueryForNote(query); - if (me) this.queryService.generateMutedUserQueryForNotes(query, me); - if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); + if (me) { + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + } if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); From 7f0a70561217d30d567a158713a3249ee4f8a981 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 2 Jun 2025 17:34:07 -0400 Subject: [PATCH 12/53] use generateMatchingHostQuery in bubble-timeline.ts --- .../src/server/api/endpoints/notes/bubble-timeline.ts | 5 +++++ 1 file changed, 5 insertions(+) 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 8acf1711eb..afc571d1f4 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -112,6 +112,11 @@ export default class extends Endpoint { // eslint- } else if (me) { this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } + + if (me) { + this.queryService.generateMatchingHostQueryForNote(query, { isBubbled: true }); + } else { + this.queryService.generateMatchingHostQueryForNote(query, { isBubbled: true, isSilenced: false }); } //#endregion From 51d9b1c8c5a6a3f5378203384f1972417b1a34bf Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 2 Jun 2025 17:34:35 -0400 Subject: [PATCH 13/53] fetch followings asynchronously in bubble-timeline.ts --- .../api/endpoints/notes/bubble-timeline.ts | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) 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 afc571d1f4..bbd41ca99f 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -76,7 +76,9 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.btlDisabled); } - const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : undefined; + // Run this asynchronously - we will await it after the query completes. + // Catch-suppression is needed to avoid "unhandled rejection" if the query throws. + const followingsPromise = me ? this.cacheService.userFollowingsCache.fetch(me.id).catch(() => null) : null; //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), @@ -122,16 +124,17 @@ export default class extends Endpoint { // eslint- let timeline = await query.limit(ps.limit).getMany(); - timeline = timeline.filter(note => { - if (note.user?.isSilenced) { - if (!me) return false; - if (!followings) return false; - if (note.userId !== me.id) { - return followings[note.userId]; - } - } - return true; - }); + const followings = await followingsPromise; + if (me && followings) { + timeline = timeline.filter(note => { + // Allow my own notes + if (note.userId === me.id) return true; + // Allow if not silenced + if (!note.user?.isSilenced) return true; + // Allow if following + return followings[note.userId]; + }); + } if (me) { process.nextTick(() => { From 0b9c0a6bc7f8bcd4ef1d3025884487467df81598 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 2 Jun 2025 22:51:10 -0400 Subject: [PATCH 14/53] fix andWhere/orWhere in QueryService.ts --- packages/backend/src/core/QueryService.ts | 61 +++++++++++++++-------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index 548887959d..0f74e7cab1 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -81,11 +81,11 @@ export class QueryService { // 投稿の引用元の作者にブロックされていない return this.excludeBlockingUser(q, 'note.userId', ':meId') .andWhere(new Brackets(qb => { - this.excludeBlockingUser(qb, 'note.replyUserId', ':meId') + this.excludeBlockingUser(qb, 'note.replyUserId', ':meId', 'orWhere') .orWhere('note.replyUserId IS NULL'); })) .andWhere(new Brackets(qb => { - this.excludeBlockingUser(qb, 'note.renoteUserId', ':meId') + this.excludeBlockingUser(qb, 'note.renoteUserId', ':meId', 'orWhere') .orWhere('note.renoteUserId IS NULL'); })) .setParameters({ meId: me.id }); @@ -102,7 +102,7 @@ export class QueryService { public generateMutedNoteThreadQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { return this.excludeMutingThread(q, ':meId', 'note.id') .andWhere(new Brackets(qb => { - this.excludeMutingThread(qb, ':meId', 'note.threadId') + this.excludeMutingThread(qb, ':meId', 'note.threadId', 'orWhere') .orWhere('note.threadId IS NULL'); })) .setParameters({ meId: me.id }); @@ -113,24 +113,24 @@ export class QueryService { // 投稿の作者をミュートしていない かつ // 投稿の返信先の作者をミュートしていない かつ // 投稿の引用元の作者をミュートしていない - this.excludeMutingUser(q, ':meId', 'note.userId', exclude) + this.excludeMutingUser(q, ':meId', 'note.userId', 'andWhere', exclude) .andWhere(new Brackets(qb => { - this.excludeMutingUser(qb, ':meId', 'note.replyUserId', exclude) + this.excludeMutingUser(qb, ':meId', 'note.replyUserId', 'orWhere', exclude) .orWhere('note.replyUserId IS NULL'); })) .andWhere(new Brackets(qb => { - this.excludeMutingUser(qb, ':meId', 'note.renoteUserId', exclude) + this.excludeMutingUser(qb, ':meId', 'note.renoteUserId', 'orWhere', exclude) .orWhere('note.renoteUserId IS NULL'); })); // mute instances - this.excludeMutingInstance(q, ':meId', 'note.userHost') + this.excludeMutingInstance(q, ':meId', 'note.userHost', 'andWhere') .andWhere(new Brackets(qb => { - this.excludeMutingInstance(qb, ':meId', 'note.replyUserHost') + this.excludeMutingInstance(qb, ':meId', 'note.replyUserHost', 'orWhere') .orWhere('note.replyUserHost IS NULL'); })) .andWhere(new Brackets(qb => { - this.excludeMutingInstance(qb, ':meId', 'note.renoteUserHost') + this.excludeMutingInstance(qb, ':meId', 'note.renoteUserHost', 'orWhere') .orWhere('note.renoteUserHost IS NULL'); })); @@ -231,6 +231,25 @@ export class QueryService { return q; } + @bindThis + public generateSilencedUserQueryForNotes(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null, userProp = 'user'): SelectQueryBuilder { + if (!me) { + return q.andWhere(`${userProp}.isSilenced = false`); + } + + return q + .andWhere(new Brackets(qb => { + // case 1: we are following the user + this.addFollowingUser(qb, ':meId', `${userProp}.id`, 'orWhere'); + // case 2: user not silenced AND instance not silenced + qb.orWhere(new Brackets(qbb => { + this.includeInstanceWhere(qbb, `${userProp}.host`, { isSilenced: false }); + qbb.andWhere(`${userProp}.isSilenced = false`); + })); + })) + .setParameters({ meId: me.id }); + } + @bindThis public generateMatchingHostQueryForNote(q: SelectQueryBuilder, filters: FindOptionsWhere | FindOptionsWhere[], hostProp = 'note.userHost'): SelectQueryBuilder { return this.includeInstanceWhere(q, hostProp, filters); @@ -268,13 +287,13 @@ export class QueryService { * Adds condition that followerProp (user ID) is following followeeProp (user ID). * Both props should be expressions, not raw values. */ - public addFollowingUser(q: Q, followerProp: string, followeeProp: string): Q { + public addFollowingUser(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere' = 'andWhere'): Q { const followingQuery = this.followingsRepository.createQueryBuilder('following') .select('1') .andWhere(`following.followerId = ${followerProp}`) .andWhere(`following.followeeId = ${followeeProp}`); - return q.andWhere(`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters()); + return q[join](`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters()); }; /** @@ -282,13 +301,13 @@ export class QueryService { * Both props should be expressions, not raw values. */ @bindThis - public excludeBlockingUser(q: Q, blockerProp: string, blockeeProp: string): Q { + public excludeBlockingUser(q: Q, blockerProp: string, blockeeProp: string, join: 'andWhere' | 'orWhere' = 'andWhere'): Q { const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') .select('1') .andWhere(`blocking.blockerId = ${blockerProp}`) .andWhere(`blocking.blockeeId = ${blockeeProp}`); - return q.andWhere(`NOT EXISTS (${blockingQuery.getQuery()})`, blockingQuery.getParameters()); + return q[join](`NOT EXISTS (${blockingQuery.getQuery()})`, blockingQuery.getParameters()); }; /** @@ -296,7 +315,7 @@ export class QueryService { * Both props should be expressions, not raw values. */ @bindThis - public excludeMutingUser(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q { + public excludeMutingUser(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere' = 'andWhere', exclude?: { id: MiUser['id'] }): Q { const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') .select('1') .andWhere(`muting.muterId = ${muterProp}`) @@ -306,20 +325,20 @@ export class QueryService { mutingQuery.andWhere({ muteeId: Not(exclude.id) }); } - return q.andWhere(`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters()); + return q[join](`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters()); } /** * Adds condition that muterProp (user ID) is not muting renotes by muteeProp (user ID). * Both props should be expressions, not raw values. */ - public excludeMutingRenote(q: Q, muterProp: string, muteeProp: string): Q { + public excludeMutingRenote(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere' = 'andWhere'): Q { const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting') .select('1') .andWhere(`renote_muting.muterId = ${muterProp}`) .andWhere(`renote_muting.muteeId = ${muteeProp}`); - return q.andWhere(`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters()); + return q[join](`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters()); }; /** @@ -327,13 +346,13 @@ export class QueryService { * Both props should be expressions, not raw values. */ @bindThis - public excludeMutingInstance(q: Q, muterProp: string, muteeProp: string): Q { + public excludeMutingInstance(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere' = 'andWhere'): Q { const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile') .select('1') .andWhere(`user_profile.userId = ${muterProp}`) .andWhere(`"user_profile"."mutedInstances"::jsonb ? ${muteeProp}`); - return q.andWhere(`NOT EXISTS (${mutingInstanceQuery.getQuery()})`, mutingInstanceQuery.getParameters()); + return q[join](`NOT EXISTS (${mutingInstanceQuery.getQuery()})`, mutingInstanceQuery.getParameters()); } /** @@ -341,12 +360,12 @@ export class QueryService { * Both props should be expressions, not raw values. */ @bindThis - public excludeMutingThread(q: Q, muterProp: string, muteeProp: string): Q { + public excludeMutingThread(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere' = 'andWhere'): Q { const threadMutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') .select('1') .andWhere(`threadMuted.userId = ${muterProp}`) .andWhere(`threadMuted.threadId = ${muteeProp}`); - return q.andWhere(`NOT EXISTS (${threadMutedQuery.getQuery()})`, threadMutedQuery.getParameters()); + return q[join](`NOT EXISTS (${threadMutedQuery.getQuery()})`, threadMutedQuery.getParameters()); } } From 7ab5ce1537feb47480a47e4f3faab16d089820f1 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Mon, 2 Jun 2025 22:56:35 -0400 Subject: [PATCH 15/53] replace generateBlockedHostQueryForNote allowSilenced parameter with generateSilencedUserQueryForNotes --- packages/backend/src/core/QueryService.ts | 17 +++-------------- .../server/api/endpoints/notes/search-by-tag.ts | 3 ++- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index 0f74e7cab1..2e0a368bd7 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -190,9 +190,8 @@ export class QueryService { .setParameters({ meId: me.id }); } - // TODO replace allowSilenced with matchingHostQuery @bindThis - public generateBlockedHostQueryForNote(q: SelectQueryBuilder, excludeAuthor?: boolean, allowSilenced = true): SelectQueryBuilder { + public generateBlockedHostQueryForNote(q: SelectQueryBuilder, excludeAuthor?: boolean): SelectQueryBuilder { const checkFor = (key: 'user' | 'replyUser' | 'renoteUser') => { q.andWhere(new Brackets(qb => { qb.orWhere(`note.${key}Host IS NULL`); // local @@ -207,18 +206,8 @@ export class QueryService { } } - if (allowSilenced) { - // not blocked - this.excludeInstanceWhere(qb, `note.${key}Host`, { - isBlocked: false, - }, 'orWhere'); - } else { - // not blocked or silenced - this.excludeInstanceWhere(qb, `note.${key}Host`, { - isBlocked: false, - isSilenced: false, - }, 'orWhere'); - } + // not blocked + this.excludeInstanceWhere(qb, `note.${key}Host`, { isBlocked: false }, 'orWhere'); })); }; diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index 5c1ab0fb78..01bedd9b1d 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -96,7 +96,8 @@ export default class extends Endpoint { // eslint- if (!this.serverSettings.enableBotTrending) query.andWhere('user.isBot = FALSE'); - this.queryService.generateBlockedHostQueryForNote(query, undefined, false); + this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSilencedUserQueryForNotes(query, me); if (me) this.queryService.generateMutedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); From 15ebb0ef85f2db2dad0fad4bf0c9b06f0daf1339 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 15:12:59 -0400 Subject: [PATCH 16/53] more QueryService fixes --- packages/backend/src/core/QueryService.ts | 292 +++++++++++++--------- 1 file changed, 178 insertions(+), 114 deletions(-) diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index 2e0a368bd7..bf5b0b359f 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -7,10 +7,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets, Not, WhereExpressionBuilder } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { MiUser } from '@/models/User.js'; -import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta, InstancesRepository, MiInstance } from '@/models/_.js'; +import { MiInstance } from '@/models/Instance.js'; +import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta, InstancesRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; -import type { SelectQueryBuilder, FindOptionsWhere, ObjectLiteral } from 'typeorm'; +import type { SelectQueryBuilder, ObjectLiteral } from 'typeorm'; @Injectable() export class QueryService { @@ -79,32 +80,31 @@ export class QueryService { // 投稿の作者にブロックされていない かつ // 投稿の返信先の作者にブロックされていない かつ // 投稿の引用元の作者にブロックされていない - return this.excludeBlockingUser(q, 'note.userId', ':meId') - .andWhere(new Brackets(qb => { - this.excludeBlockingUser(qb, 'note.replyUserId', ':meId', 'orWhere') - .orWhere('note.replyUserId IS NULL'); - })) - .andWhere(new Brackets(qb => { - this.excludeBlockingUser(qb, 'note.renoteUserId', ':meId', 'orWhere') - .orWhere('note.renoteUserId IS NULL'); - })) + return this + .andNotBlockingUser(q, 'note.userId', ':meId') + .andWhere(new Brackets(qb => this + .orNotBlockingUser(qb, 'note.replyUserId', ':meId') + .orWhere('note.replyUserId IS NULL'))) + .andWhere(new Brackets(qb => this + .orNotBlockingUser(qb, 'note.renoteUserId', ':meId') + .orWhere('note.renoteUserId IS NULL'))) .setParameters({ meId: me.id }); } @bindThis public generateBlockQueryForUsers(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { - this.excludeBlockingUser(q, ':meId', 'user.id'); - this.excludeBlockingUser(q, 'user.id', ':me.id'); + this.andNotBlockingUser(q, ':meId', 'user.id'); + this.andNotBlockingUser(q, 'user.id', ':me.id'); return q.setParameters({ meId: me.id }); } @bindThis public generateMutedNoteThreadQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { - return this.excludeMutingThread(q, ':meId', 'note.id') - .andWhere(new Brackets(qb => { - this.excludeMutingThread(qb, ':meId', 'note.threadId', 'orWhere') - .orWhere('note.threadId IS NULL'); - })) + return this + .andNotMutingThread(q, ':meId', 'note.id') + .andWhere(new Brackets(qb => this + .orNotMutingThread(qb, ':meId', 'note.threadId') + .orWhere('note.threadId IS NULL'))) .setParameters({ meId: me.id }); } @@ -113,33 +113,32 @@ export class QueryService { // 投稿の作者をミュートしていない かつ // 投稿の返信先の作者をミュートしていない かつ // 投稿の引用元の作者をミュートしていない - this.excludeMutingUser(q, ':meId', 'note.userId', 'andWhere', exclude) - .andWhere(new Brackets(qb => { - this.excludeMutingUser(qb, ':meId', 'note.replyUserId', 'orWhere', exclude) - .orWhere('note.replyUserId IS NULL'); - })) - .andWhere(new Brackets(qb => { - this.excludeMutingUser(qb, ':meId', 'note.renoteUserId', 'orWhere', exclude) - .orWhere('note.renoteUserId IS NULL'); - })); - - // mute instances - this.excludeMutingInstance(q, ':meId', 'note.userHost', 'andWhere') - .andWhere(new Brackets(qb => { - this.excludeMutingInstance(qb, ':meId', 'note.replyUserHost', 'orWhere') - .orWhere('note.replyUserHost IS NULL'); - })) - .andWhere(new Brackets(qb => { - this.excludeMutingInstance(qb, ':meId', 'note.renoteUserHost', 'orWhere') - .orWhere('note.renoteUserHost IS NULL'); - })); - - return q.setParameters({ meId: me.id }); + return this + .andNotMutingUser(q, ':meId', 'note.userId', exclude) + .andWhere(new Brackets(qb => this + .orNotMutingUser(qb, ':meId', 'note.replyUserId', exclude) + .orWhere('note.replyUserId IS NULL'))) + .andWhere(new Brackets(qb => this + .orNotMutingUser(qb, ':meId', 'note.renoteUserId', exclude) + .orWhere('note.renoteUserId IS NULL'))) + // TODO exclude should also pass a host to skip these instances + // mute instances + .andWhere(new Brackets(qb => this + .andNotMutingInstance(qb, ':meId', 'note.userHost') + .orWhere('note.userHost IS NULL'))) + .andWhere(new Brackets(qb => this + .orNotMutingInstance(qb, ':meId', 'note.replyUserHost') + .orWhere('note.replyUserHost IS NULL'))) + .andWhere(new Brackets(qb => this + .orNotMutingInstance(qb, ':meId', 'note.renoteUserHost') + .orWhere('note.renoteUserHost IS NULL'))) + .setParameters({ meId: me.id }); } @bindThis public generateMutedUserQueryForUsers(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { - return this.excludeMutingUser(q, ':meId', 'user.id') + return this + .andNotMutingUser(q, ':meId', 'user.id') .setParameters({ meId: me.id }); } @@ -165,11 +164,9 @@ export class QueryService { // Mentions me .orWhere(':meId = ANY (note.mentions)') // Followers-only post - .orWhere(new Brackets(qb => { - // または フォロワー宛ての投稿であり、 - this.addFollowingUser(qb, ':meId', 'note.userId') - .andWhere('note.visibility = \'followers\''); - })); + .orWhere(new Brackets(qb => this + .andFollowingUser(qb, ':meId', 'note.userId') + .andWhere('note.visibility = \'followers\''))); q.setParameters({ meId: me.id }); } @@ -178,38 +175,43 @@ export class QueryService { @bindThis public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }): SelectQueryBuilder { - return q.andWhere(new Brackets(qb => { - this.excludeMutingRenote(qb, ':meId', 'note.userId') + return q + .andWhere(new Brackets(qb => this + .orNotMutingRenote(qb, ':meId', 'note.userId') .orWhere('note.renoteId IS NULL') .orWhere('note.text IS NOT NULL') .orWhere('note.cw IS NOT NULL') .orWhere('note.replyId IS NOT NULL') .orWhere('note.hasPoll = true') - .orWhere('note.fileIds != \'{}\''); - })) + .orWhere('note.fileIds != \'{}\''))) .setParameters({ meId: me.id }); } + @bindThis + public generateExcludedRenotesQueryForNotes(q: SelectQueryBuilder): SelectQueryBuilder { + return q + .andWhere(new Brackets(qb => qb + .orWhere('note.renoteId IS NULL') + .orWhere('note.text IS NOT NULL') + .orWhere('note.cw IS NOT NULL') + .orWhere('note.replyId IS NOT NULL') + .orWhere('note.hasPoll = true') + .orWhere('note.fileIds != \'{}\''))); + } + @bindThis public generateBlockedHostQueryForNote(q: SelectQueryBuilder, excludeAuthor?: boolean): SelectQueryBuilder { - const checkFor = (key: 'user' | 'replyUser' | 'renoteUser') => { - q.andWhere(new Brackets(qb => { - qb.orWhere(`note.${key}Host IS NULL`); // local + const checkFor = (key: 'user' | 'replyUser' | 'renoteUser') => this + .leftJoinInstance(q, `note.${key}Instance`, `${key}Instance`) + .andWhere(new Brackets(qb => { + qb + .orWhere(`"${key}Instance" IS NULL`) // local + .orWhere(`"${key}Instance"."isBlocked" = false`); // not blocked - if (key !== 'user') { - // note.userId always exists and is non-null - qb.orWhere(`note.${key}Id IS NULL`); // no corresponding user - - // note.userId always equals note.userId - if (excludeAuthor) { - qb.orWhere(`note.userId = note.${key}Id`); // author - } + if (excludeAuthor) { + qb.orWhere(`note.userId = note.${key}Id`); // author } - - // not blocked - this.excludeInstanceWhere(qb, `note.${key}Host`, { isBlocked: false }, 'orWhere'); })); - }; if (!excludeAuthor) { checkFor('user'); @@ -221,62 +223,58 @@ export class QueryService { } @bindThis - public generateSilencedUserQueryForNotes(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null, userProp = 'user'): SelectQueryBuilder { + public generateSilencedUserQueryForNotes(q: SelectQueryBuilder, me?: { id: MiUser['id'] } | null): SelectQueryBuilder { if (!me) { - return q.andWhere(`${userProp}.isSilenced = false`); + return q.andWhere('user.isSilenced = false'); } - return q - .andWhere(new Brackets(qb => { + return this + .leftJoinInstance(q, 'note.userInstance', 'userInstance') + .andWhere(new Brackets(qb => this // case 1: we are following the user - this.addFollowingUser(qb, ':meId', `${userProp}.id`, 'orWhere'); + .orFollowingUser(qb, ':meId', 'note.userId') // case 2: user not silenced AND instance not silenced - qb.orWhere(new Brackets(qbb => { - this.includeInstanceWhere(qbb, `${userProp}.host`, { isSilenced: false }); - qbb.andWhere(`${userProp}.isSilenced = false`); - })); - })) + .orWhere(new Brackets(qbb => qbb + .andWhere(new Brackets(qbbb => qbbb + .orWhere('"userInstance"."isSilenced" = false') + .orWhere('"userInstance" IS NULL'))) + .andWhere('user.isSilenced = false'))))) .setParameters({ meId: me.id }); } - @bindThis - public generateMatchingHostQueryForNote(q: SelectQueryBuilder, filters: FindOptionsWhere | FindOptionsWhere[], hostProp = 'note.userHost'): SelectQueryBuilder { - return this.includeInstanceWhere(q, hostProp, filters); - } - /** - * Adds condition that hostProp (instance host) matches the given filters. - * The prop should be an expression, not raw values. + * Left-joins an instance in to the query with a given alias and optional condition. + * These calls are de-duplicated - multiple uses of the same alias are skipped. */ @bindThis - public includeInstanceWhere(q: Q, hostProp: string, filters: FindOptionsWhere | FindOptionsWhere[], join: 'andWhere' | 'orWhere' = 'andWhere'): Q { - const instancesQuery = this.instancesRepository.createQueryBuilder('instance') - .select('1') - .andWhere(`instance.host = ${hostProp}`) - .andWhere(filters); + public leftJoinInstance(q: SelectQueryBuilder, relation: string | typeof MiInstance, alias: string, condition?: string): SelectQueryBuilder { + // Skip if it's already joined, otherwise we'll get an error + if (!q.expressionMap.joinAttributes.some(j => j.alias.name === alias)) { + q.leftJoin(relation, alias, condition); + } - return q[join](`EXISTS (${instancesQuery.getQuery()})`, instancesQuery.getParameters()); + return q; } /** - * Adds condition that hostProp (instance host) matches the given filters. - * The prop should be an expression, not raw values. - */ - @bindThis - public excludeInstanceWhere(q: Q, hostProp: string, filters: FindOptionsWhere | FindOptionsWhere[], join: 'andWhere' | 'orWhere' = 'andWhere'): Q { - const instancesQuery = this.instancesRepository.createQueryBuilder('instance') - .select('1') - .andWhere(`instance.host = ${hostProp}`) - .andWhere(filters); - - return q[join](`NOT EXISTS (${instancesQuery.getQuery()})`, instancesQuery.getParameters()); - } - - /** - * Adds condition that followerProp (user ID) is following followeeProp (user ID). + * Adds OR condition that followerProp (user ID) is following followeeProp (user ID). * Both props should be expressions, not raw values. */ - public addFollowingUser(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere' = 'andWhere'): Q { + @bindThis + public orFollowingUser(q: Q, followerProp: string, followeeProp: string): Q { + return this.addFollowingUser(q, followerProp, followeeProp, 'orWhere'); + } + + /** + * Adds AND condition that followerProp (user ID) is following followeeProp (user ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public andFollowingUser(q: Q, followerProp: string, followeeProp: string): Q { + return this.addFollowingUser(q, followerProp, followeeProp, 'andWhere'); + } + + private addFollowingUser(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere'): Q { const followingQuery = this.followingsRepository.createQueryBuilder('following') .select('1') .andWhere(`following.followerId = ${followerProp}`) @@ -286,11 +284,24 @@ export class QueryService { }; /** - * Adds condition that blockerProp (user ID) is not blocking blockeeProp (user ID). + * Adds OR condition that blockerProp (user ID) is not blocking blockeeProp (user ID). * Both props should be expressions, not raw values. */ @bindThis - public excludeBlockingUser(q: Q, blockerProp: string, blockeeProp: string, join: 'andWhere' | 'orWhere' = 'andWhere'): Q { + public orNotBlockingUser(q: Q, blockerProp: string, blockeeProp: string): Q { + return this.excludeBlockingUser(q, blockerProp, blockeeProp, 'orWhere'); + } + + /** + * Adds AND condition that blockerProp (user ID) is not blocking blockeeProp (user ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public andNotBlockingUser(q: Q, blockerProp: string, blockeeProp: string): Q { + return this.excludeBlockingUser(q, blockerProp, blockeeProp, 'andWhere'); + } + + private excludeBlockingUser(q: Q, blockerProp: string, blockeeProp: string, join: 'andWhere' | 'orWhere'): Q { const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') .select('1') .andWhere(`blocking.blockerId = ${blockerProp}`) @@ -300,11 +311,24 @@ export class QueryService { }; /** - * Adds condition that muterProp (user ID) is not muting muteeProp (user ID). + * Adds OR condition that muterProp (user ID) is not muting muteeProp (user ID). * Both props should be expressions, not raw values. */ @bindThis - public excludeMutingUser(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere' = 'andWhere', exclude?: { id: MiUser['id'] }): Q { + public orNotMutingUser(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q { + return this.excludeMutingUser(q, muterProp, muteeProp, 'orWhere', exclude); + } + + /** + * Adds AND condition that muterProp (user ID) is not muting muteeProp (user ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public andNotMutingUser(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q { + return this.excludeMutingUser(q, muterProp, muteeProp, 'andWhere', exclude); + } + + private excludeMutingUser(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere', exclude?: { id: MiUser['id'] }): Q { const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') .select('1') .andWhere(`muting.muterId = ${muterProp}`) @@ -318,10 +342,24 @@ export class QueryService { } /** - * Adds condition that muterProp (user ID) is not muting renotes by muteeProp (user ID). + * Adds OR condition that muterProp (user ID) is not muting renotes by muteeProp (user ID). * Both props should be expressions, not raw values. */ - public excludeMutingRenote(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere' = 'andWhere'): Q { + @bindThis + public orNotMutingRenote(q: Q, muterProp: string, muteeProp: string): Q { + return this.excludeMutingRenote(q, muterProp, muteeProp, 'orWhere'); + } + + /** + * Adds AND condition that muterProp (user ID) is not muting renotes by muteeProp (user ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public andNotMutingRenote(q: Q, muterProp: string, muteeProp: string): Q { + return this.excludeMutingRenote(q, muterProp, muteeProp, 'andWhere'); + } + + private excludeMutingRenote(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q { const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting') .select('1') .andWhere(`renote_muting.muterId = ${muterProp}`) @@ -331,11 +369,24 @@ export class QueryService { }; /** - * Adds condition that muterProp (user ID) is not muting muteeProp (instance host). + * Adds OR condition that muterProp (user ID) is not muting muteeProp (instance host). * Both props should be expressions, not raw values. */ @bindThis - public excludeMutingInstance(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere' = 'andWhere'): Q { + public orNotMutingInstance(q: Q, muterProp: string, muteeProp: string): Q { + return this.excludeMutingInstance(q, muterProp, muteeProp, 'orWhere'); + } + + /** + * Adds AND condition that muterProp (user ID) is not muting muteeProp (instance host). + * Both props should be expressions, not raw values. + */ + @bindThis + public andNotMutingInstance(q: Q, muterProp: string, muteeProp: string): Q { + return this.excludeMutingInstance(q, muterProp, muteeProp, 'andWhere'); + } + + private excludeMutingInstance(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q { const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile') .select('1') .andWhere(`user_profile.userId = ${muterProp}`) @@ -345,11 +396,24 @@ export class QueryService { } /** - * Adds condition that muterProp (user ID) is not muting muteeProp (note ID). + * Adds OR condition that muterProp (user ID) is not muting muteeProp (note ID). * Both props should be expressions, not raw values. */ @bindThis - public excludeMutingThread(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere' = 'andWhere'): Q { + public orNotMutingThread(q: Q, muterProp: string, muteeProp: string): Q { + return this.excludeMutingThread(q, muterProp, muteeProp, 'orWhere'); + } + + /** + * Adds AND condition that muterProp (user ID) is not muting muteeProp (note ID). + * Both props should be expressions, not raw values. + */ + @bindThis + public andNotMutingThread(q: Q, muterProp: string, muteeProp: string): Q { + return this.excludeMutingThread(q, muterProp, muteeProp, 'andWhere'); + } + + private excludeMutingThread(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q { const threadMutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') .select('1') .andWhere(`threadMuted.userId = ${muterProp}`) From 2258e439af06dca8093e89c5667c28bf5bc6be74 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 15:14:22 -0400 Subject: [PATCH 17/53] replace silenced users check with generateSilencedUserQueryForNotes in bubble-timeline.ts --- .../api/endpoints/notes/bubble-timeline.ts | 20 ++----------------- 1 file changed, 2 insertions(+), 18 deletions(-) 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 bbd41ca99f..214beface5 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -68,7 +68,6 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, - private cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const policies = await this.roleService.getUserPolicies(me ? me.id : null); @@ -76,10 +75,6 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.btlDisabled); } - // Run this asynchronously - we will await it after the query completes. - // Catch-suppression is needed to avoid "unhandled rejection" if the query throws. - const followingsPromise = me ? this.cacheService.userFollowingsCache.fetch(me.id).catch(() => null) : null; - //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) @@ -92,6 +87,7 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSilencedUserQueryForNotes(query, me); if (me) { this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); @@ -122,19 +118,7 @@ export default class extends Endpoint { // eslint- } //#endregion - let timeline = await query.limit(ps.limit).getMany(); - - const followings = await followingsPromise; - if (me && followings) { - timeline = timeline.filter(note => { - // Allow my own notes - if (note.userId === me.id) return true; - // Allow if not silenced - if (!note.user?.isSilenced) return true; - // Allow if following - return followings[note.userId]; - }); - } + const timeline = await query.limit(ps.limit).getMany(); if (me) { process.nextTick(() => { From a392865203cf09ca44053426d3cad2d0f3b48632 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 15:14:37 -0400 Subject: [PATCH 18/53] replace renotes check with generateExcludedRenotesQueryForNotes in bubble-timeline.ts --- .../src/server/api/endpoints/notes/bubble-timeline.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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 214beface5..dec5308bc8 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -100,13 +100,7 @@ export default class extends Endpoint { // eslint- if (!ps.withBots) query.andWhere('user.isBot = FALSE'); if (!ps.withRenotes) { - query.andWhere(new Brackets(qb => qb - .orWhere('note.renoteId IS NULL') - .orWhere('note.text IS NOT NULL') - .orWhere('note.cw IS NOT NULL') - .orWhere('note.replyId IS NOT NULL') - .orWhere('note.hasPoll = true') - .orWhere('note.fileIds != \'{}\''))); + this.queryService.generateExcludedRenotesQueryForNotes(query); } else if (me) { this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } From 38d4ac91ef6c0c3d76ee53d14d34e1f315cbb65f Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 15:15:04 -0400 Subject: [PATCH 19/53] fix bubble timeline query performance --- .../api/endpoints/notes/bubble-timeline.ts | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) 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 dec5308bc8..5f16351b20 100644 --- a/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/bubble-timeline.ts @@ -4,7 +4,6 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { Brackets } from 'typeorm'; import type { NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; @@ -12,7 +11,6 @@ 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 = { @@ -80,11 +78,21 @@ export default class extends Endpoint { // eslint- ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('note.visibility = \'public\'') .andWhere('note.channelId IS NULL') + .andWhere('note.userHost IS NOT NULL') .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') + .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId'); + + // This subquery mess teaches postgres how to use the right indexes. + // Using WHERE or ON conditions causes a fallback to full sequence scan, which times out. + // Important: don't use a query builder here or TypeORM will get confused and stop quoting column names! (known, unfixed bug apparently) + query + .leftJoin('(select "host" from "instance" where "isBubbled" = true)', 'bubbleInstance', '"bubbleInstance"."host" = "note"."userHost"') + .andWhere('"bubbleInstance" IS NOT NULL'); + this.queryService + .leftJoinInstance(query, 'note.userInstance', 'userInstance', '"userInstance"."host" = "bubbleInstance"."host"'); this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateSilencedUserQueryForNotes(query, me); @@ -104,12 +112,6 @@ export default class extends Endpoint { // eslint- } else if (me) { this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } - - if (me) { - this.queryService.generateMatchingHostQueryForNote(query, { isBubbled: true }); - } else { - this.queryService.generateMatchingHostQueryForNote(query, { isBubbled: true, isSilenced: false }); - } //#endregion const timeline = await query.limit(ps.limit).getMany(); From d2c2a82b76f04425456b35a5acf350258bc8ef4a Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 15:15:28 -0400 Subject: [PATCH 20/53] avoid unnecessary set construction in channel.ts --- packages/backend/src/server/api/stream/channel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 204ea9f705..640ef68746 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -69,7 +69,7 @@ export default abstract class Channel { if (note.user.requireSigninToViewContents && !this.user) return true; // 流れてきたNoteがインスタンスミュートしたインスタンスが関わる - if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? [])) && !this.following[note.userId]) return true; + if (isInstanceMuted(note, this.userMutedInstances) && !this.following[note.userId]) return true; // 流れてきたNoteがミュートしているユーザーが関わる if (isUserRelated(note, this.userIdsWhoMeMuting)) return true; From a7069c51b2ddd7bf3db6ef2ac68fc50e56e16f03 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 15:16:25 -0400 Subject: [PATCH 21/53] sync up bubble timeline endpoint and stream --- .../backend/src/server/api/stream/channels/bubble-timeline.ts | 2 -- 1 file changed, 2 deletions(-) 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 88cb9937b3..ddd11f19c9 100644 --- a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts @@ -57,9 +57,7 @@ class BubbleTimelineChannel extends Channel { if (note.visibility !== 'public') return; if (note.channelId != null) return; - if (note.user.host == null) return; if (!this.utilityService.isBubbledHost(note.user.host)) return; - if (note.user.requireSigninToViewContents && this.user == null) return; if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; From 4b58752a16e01f13950e13adfcc5a5069a05f716 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 15:17:55 -0400 Subject: [PATCH 22/53] copy changes to global-timeline.ts --- .../api/endpoints/notes/global-timeline.ts | 38 ++++++------------- 1 file changed, 12 insertions(+), 26 deletions(-) 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 e82d9ca7af..6ebb3c1676 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -12,7 +12,6 @@ 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 = { @@ -68,7 +67,6 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, - private cacheService: CacheService, ) { super(meta, paramDef, async (ps, me) => { const policies = await this.roleService.getUserPolicies(me ? me.id : null); @@ -76,8 +74,6 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.gtlDisabled); } - const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : {}; - //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) @@ -86,15 +82,14 @@ export default class extends Endpoint { // eslint- .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') + .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId'); this.queryService.generateBlockedHostQueryForNote(query); - + this.queryService.generateSilencedUserQueryForNotes(query, me); if (me) { this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } if (ps.withFiles) { @@ -103,29 +98,20 @@ export default class extends Endpoint { // eslint- if (!ps.withBots) query.andWhere('user.isBot = FALSE'); - if (ps.withRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.where('note.renoteId IS NULL'); - qb.orWhere(new Brackets(qb => { - qb.where('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - })); - })); + if (!ps.withRenotes) { + this.queryService.generateExcludedRenotesQueryForNotes(query); + } else if (me) { + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } //#endregion - let timeline = await query.limit(ps.limit).getMany(); + const timeline = await query.limit(ps.limit).getMany(); - timeline = timeline.filter(note => { - if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false; - return true; - }); - - process.nextTick(() => { - if (me) { + if (me) { + process.nextTick(() => { this.activeUsersChart.read(me); - } - }); + }); + } return await this.noteEntityService.packMany(timeline, me); }); From c3ba1396559147d149b99970d9a26af670d49165 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 15:21:42 -0400 Subject: [PATCH 23/53] copy changes to local-timeline.ts --- .../api/endpoints/notes/local-timeline.ts | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) 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 f55853f3f3..360528eaed 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -103,13 +103,14 @@ export default class extends Endpoint { // eslint- withFiles: ps.withFiles, withReplies: ps.withReplies, withBots: ps.withBots, + withRenotes: ps.withRenotes, }, me); - process.nextTick(() => { - if (me) { + if (me) { + process.nextTick(() => { this.activeUsersChart.read(me); - } - }); + }); + } return await this.noteEntityService.packMany(timeline, me); } @@ -136,14 +137,15 @@ export default class extends Endpoint { // eslint- withFiles: ps.withFiles, withReplies: ps.withReplies, withBots: ps.withBots, + withRenotes: ps.withRenotes, }, me), }); - process.nextTick(() => { - if (me) { + if (me) { + process.nextTick(() => { this.activeUsersChart.read(me); - } - }); + }); + } return timeline; }); @@ -156,26 +158,38 @@ export default class extends Endpoint { // eslint- withFiles: boolean, withReplies: boolean, withBots: boolean, + withRenotes: boolean, }, me: MiLocalUser | null) { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL) AND (note.channelId IS NULL)') + .andWhere('note.visibility = \'public\'') + .andWhere('note.channelId IS NULL') + .andWhere('note.userHost IS NULL') .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') + .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId'); - this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); - if (me) this.queryService.generateMutedUserQueryForNotes(query, me); - if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); - if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + this.queryService.generateSilencedUserQueryForNotes(query, me); + if (me) { + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + } if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); } + if (!ps.withBots) query.andWhere('user.isBot = FALSE'); + + if (!ps.withRenotes) { + this.queryService.generateExcludedRenotesQueryForNotes(query); + } else if (me) { + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } + if (!ps.withReplies) { query.andWhere(new Brackets(qb => { qb @@ -188,8 +202,6 @@ export default class extends Endpoint { // eslint- })); } - if (!ps.withBots) query.andWhere('user.isBot = FALSE'); - return await query.limit(ps.limit).getMany(); } } From ef3b5541f8ca090141bed371fc39480af8ea874b Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 15:26:46 -0400 Subject: [PATCH 24/53] copy changes to hybrid-timeline.ts --- .../api/endpoints/notes/hybrid-timeline.ts | 57 ++++--------------- packages/misskey-js/src/autogen/types.ts | 6 -- 2 files changed, 12 insertions(+), 51 deletions(-) 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 6461a2e33f..083da9090f 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -66,9 +66,6 @@ export const paramDef = { sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default - includeMyRenotes: { type: 'boolean', default: true }, - includeRenotedMyNotes: { type: 'boolean', default: true }, - includeLocalRenotes: { type: 'boolean', default: true }, withFiles: { type: 'boolean', default: false }, withRenotes: { type: 'boolean', default: true }, withReplies: { type: 'boolean', default: false }, @@ -114,12 +111,10 @@ export default class extends Endpoint { // eslint- untilId, sinceId, limit: ps.limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, withFiles: ps.withFiles, withReplies: ps.withReplies, withBots: ps.withBots, + withRenotes: ps.withRenotes, }, me); process.nextTick(() => { @@ -178,12 +173,10 @@ export default class extends Endpoint { // eslint- untilId, sinceId, limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, withFiles: ps.withFiles, withReplies: ps.withReplies, withBots: ps.withBots, + withRenotes: ps.withRenotes, }, me), }); @@ -199,12 +192,10 @@ export default class extends Endpoint { // eslint- untilId: string | null, sinceId: string | null, limit: number, - includeMyRenotes: boolean, - includeRenotedMyNotes: boolean, - includeLocalRenotes: boolean, withFiles: boolean, withReplies: boolean, withBots: boolean, + withRenotes: boolean, }, me: MiLocalUser) { const followees = await this.userFollowingService.getFollowees(me.id); const followingChannels = await this.channelFollowingsRepository.find({ @@ -227,8 +218,8 @@ export default class extends Endpoint { // eslint- .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') + .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId'); if (followingChannels.length > 0) { const followingChannelIds = followingChannels.map(x => x.followeeId); @@ -255,45 +246,21 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); + this.queryService.generateSilencedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeRenotedMyNotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeLocalRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserHost IS NOT NULL'); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); } if (!ps.withBots) query.andWhere('user.isBot = FALSE'); + + if (!ps.withRenotes) { + this.queryService.generateExcludedRenotesQueryForNotes(query); + } else { + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } //#endregion return await query.limit(ps.limit).getMany(); diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index c0c0fd7895..68e150c9b7 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -27230,12 +27230,6 @@ export type operations = { untilDate?: number; /** @default false */ allowPartial?: boolean; - /** @default true */ - includeMyRenotes?: boolean; - /** @default true */ - includeRenotedMyNotes?: boolean; - /** @default true */ - includeLocalRenotes?: boolean; /** @default false */ withFiles?: boolean; /** @default true */ From 66572d16a881018575d2bd6740235854eb6cfbdd Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 15:31:15 -0400 Subject: [PATCH 25/53] copy changes to timeline.ts --- .../server/api/endpoints/notes/timeline.ts | 56 +++---------------- packages/misskey-js/src/autogen/types.ts | 6 -- 2 files changed, 9 insertions(+), 53 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index a2dfa7fdac..a23a61373e 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -49,9 +49,6 @@ export const paramDef = { sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default - includeMyRenotes: { type: 'boolean', default: true }, - includeRenotedMyNotes: { type: 'boolean', default: true }, - includeLocalRenotes: { type: 'boolean', default: true }, withFiles: { type: 'boolean', default: false }, withRenotes: { type: 'boolean', default: true }, withBots: { type: 'boolean', default: true }, @@ -88,9 +85,6 @@ export default class extends Endpoint { // eslint- untilId, sinceId, limit: ps.limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, withFiles: ps.withFiles, withRenotes: ps.withRenotes, withBots: ps.withBots, @@ -131,9 +125,6 @@ export default class extends Endpoint { // eslint- untilId, sinceId, limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, withFiles: ps.withFiles, withRenotes: ps.withRenotes, withBots: ps.withBots, @@ -148,7 +139,7 @@ export default class extends Endpoint { // eslint- }); } - private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; withBots: boolean; }, me: MiLocalUser) { + private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; withFiles: boolean; withRenotes: boolean; withBots: boolean; }, me: MiLocalUser) { const followees = await this.userFollowingService.getFollowees(me.id); const followingChannels = await this.channelFollowingsRepository.find({ where: { @@ -161,8 +152,8 @@ export default class extends Endpoint { // eslint- .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') + .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId'); if (followees.length > 0 && followingChannels.length > 0) { // ユーザー・チャンネルともにフォローあり @@ -212,47 +203,18 @@ export default class extends Endpoint { // eslint- this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeRenotedMyNotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeLocalRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserHost IS NOT NULL'); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); } - if (ps.withRenotes === false) { - query.andWhere('note.renoteId IS NULL'); - } - if (!ps.withBots) query.andWhere('user.isBot = FALSE'); + + if (!ps.withRenotes) { + this.queryService.generateExcludedRenotesQueryForNotes(query); + } else if (me) { + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } //#endregion return await query.limit(ps.limit).getMany(); diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 68e150c9b7..b85f1601de 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -28645,12 +28645,6 @@ export type operations = { untilDate?: number; /** @default false */ allowPartial?: boolean; - /** @default true */ - includeMyRenotes?: boolean; - /** @default true */ - includeRenotedMyNotes?: boolean; - /** @default true */ - includeLocalRenotes?: boolean; /** @default false */ withFiles?: boolean; /** @default true */ From 023924fe9afa91518fbec970bdeeede2f6ef4424 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 15:36:56 -0400 Subject: [PATCH 26/53] copy changes to antennas/notes.ts --- .../src/server/api/endpoints/antennas/notes.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index 7e79f0dccc..1abeee53d2 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -14,6 +14,7 @@ import { IdService } from '@/core/IdService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { trackPromise } from '@/misc/promise-tracker.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -75,6 +76,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, private fanoutTimelineService: FanoutTimelineService, private globalEventService: GlobalEventService, + private readonly activeUsersChart: ActiveUsersChart, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -106,13 +108,14 @@ export default class extends Endpoint { // eslint- return []; } - const query = this.notesRepository.createQueryBuilder('note') + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), + ps.sinceId, ps.untilId) .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') + .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId'); // NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。 // https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255 @@ -124,11 +127,10 @@ export default class extends Endpoint { // eslint- this.queryService.generateMutedUserRenotesQueryForNotes(query, me); const notes = await query.getMany(); - if (sinceId != null && untilId == null) { - notes.sort((a, b) => a.id < b.id ? -1 : 1); - } else { - notes.sort((a, b) => a.id > b.id ? -1 : 1); - } + + process.nextTick(() => { + this.activeUsersChart.read(me); + }); return await this.noteEntityService.packMany(notes, me); }); From 6896bcea9dde208f8a7939d0f7949b82cf62eeb4 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 15:44:50 -0400 Subject: [PATCH 27/53] copy changes to mentions.ts --- .../server/api/endpoints/notes/mentions.ts | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts index 269b57366c..7c54bc69ab 100644 --- a/packages/backend/src/server/api/endpoints/notes/mentions.ts +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -10,6 +10,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; export const meta = { tags: ['notes'], @@ -57,43 +58,44 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private queryService: QueryService, + private readonly activeUsersChart: ActiveUsersChart, ) { super(meta, paramDef, async (ps, me) => { - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); - - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) - .andWhere(new Brackets(qb => { - qb // このmeIdAsListパラメータはqueryServiceのgenerateVisibilityQueryでセットされる - .where(':meIdAsList <@ note.mentions') - .orWhere(':meIdAsList <@ note.visibleUserIds'); - })) + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), + ps.sinceId, ps.untilId) + .andWhere(new Brackets(qb => qb + .orWhere(':meId = ANY (note.mentions)') + .orWhere(':meId = ANY (note.visibleUserIds)'))) + .setParameters({ meId: me.id }) // Avoid scanning primary key index .orderBy('CONCAT(note.id)', 'DESC') .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') + .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId'); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedNoteThreadQuery(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.visibility) { query.andWhere('note.visibility = :visibility', { visibility: ps.visibility }); } if (ps.following) { - query.andWhere(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id }); - query.setParameters(followingQuery.getParameters()); + this.queryService.andFollowingUser(query, ':meId', 'note.userId'); } const mentions = await query.limit(ps.limit).getMany(); + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + return await this.noteEntityService.packMany(mentions, me); }); } From 285a4748c3ba7c65c798d3c44041047191862620 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 15:47:55 -0400 Subject: [PATCH 28/53] copy changes to user-list-timeline.ts --- .../api/endpoints/notes/user-list-timeline.ts | 72 ++++--------------- packages/misskey-js/src/autogen/types.ts | 6 -- 2 files changed, 15 insertions(+), 63 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index 60f18a09b0..8872672b67 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -57,9 +57,6 @@ export const paramDef = { sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default - includeMyRenotes: { type: 'boolean', default: true }, - includeRenotedMyNotes: { type: 'boolean', default: true }, - includeLocalRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true }, withFiles: { type: 'boolean', @@ -109,14 +106,13 @@ export default class extends Endpoint { // eslint- untilId, sinceId, limit: ps.limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, withFiles: ps.withFiles, withRenotes: ps.withRenotes, }, me); - this.activeUsersChart.read(me); + process.nextTick(() => { + this.activeUsersChart.read(me); + }); return await this.noteEntityService.packMany(timeline, me); } @@ -135,15 +131,14 @@ export default class extends Endpoint { // eslint- untilId, sinceId, limit, - includeMyRenotes: ps.includeMyRenotes, - includeRenotedMyNotes: ps.includeRenotedMyNotes, - includeLocalRenotes: ps.includeLocalRenotes, withFiles: ps.withFiles, withRenotes: ps.withRenotes, }, me), }); - this.activeUsersChart.read(me); + process.nextTick(() => { + this.activeUsersChart.read(me); + }); return timeline; }); @@ -153,9 +148,6 @@ export default class extends Endpoint { // eslint- untilId: string | null, sinceId: string | null, limit: number, - includeMyRenotes: boolean, - includeRenotedMyNotes: boolean, - includeLocalRenotes: boolean, withFiles: boolean, withRenotes: boolean, }, me: MiLocalUser) { @@ -165,8 +157,8 @@ export default class extends Endpoint { // eslint- .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') + .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId') .andWhere('userListMemberships.userListId = :userListId', { userListId: list.id }) .andWhere('note.channelId IS NULL') // チャンネルノートではない .andWhere(new Brackets(qb => { @@ -193,51 +185,17 @@ export default class extends Endpoint { // eslint- this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - - if (ps.includeMyRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.userId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeRenotedMyNotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserId != :meId', { meId: me.id }); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.includeLocalRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteUserHost IS NOT NULL'); - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); - })); - } - - if (ps.withRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere(new Brackets(qb => { - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - })); - })); - } if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); } + + if (!ps.withRenotes) { + this.queryService.generateExcludedRenotesQueryForNotes(query); + } else if (me) { + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } + //#endregion return await query.limit(ps.limit).getMany(); diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index b85f1601de..d5712cdbed 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -28846,12 +28846,6 @@ export type operations = { /** @default false */ allowPartial?: boolean; /** @default true */ - includeMyRenotes?: boolean; - /** @default true */ - includeRenotedMyNotes?: boolean; - /** @default true */ - includeLocalRenotes?: boolean; - /** @default true */ withRenotes?: boolean; /** * @description Only show notes that have attached files. From 6b8766616c0c28277c38ee550511087d5ef938a4 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 15:50:19 -0400 Subject: [PATCH 29/53] copy changes to channels/timeline.ts --- .../server/api/endpoints/channels/timeline.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 99ae1c2211..19f1921e0a 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -96,7 +96,11 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchChannel); } - if (me) this.activeUsersChart.read(me); + if (me) { + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + } if (!this.serverSettings.enableFanoutTimeline) { return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me), me); @@ -134,30 +138,25 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('note.channel', 'channel'); + .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') + .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId'); this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateVisibilityQuery(query, me); if (me) { this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - } - - if (ps.withRenotes === false) { - query.andWhere(new Brackets(qb => { - qb.orWhere('note.renoteId IS NULL'); - qb.orWhere(new Brackets(qb => { - qb.orWhere('note.text IS NOT NULL'); - qb.orWhere('note.fileIds != \'{}\''); - })); - })); } if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); } + + if (!ps.withRenotes) { + this.queryService.generateExcludedRenotesQueryForNotes(query); + } else if (me) { + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + } //#endregion return await query.limit(ps.limit).getMany(); From b6b44758d1b8ceb61714dd181083250d79ebe5dc Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 15:52:31 -0400 Subject: [PATCH 30/53] copy changes to roles/notes.ts --- .../backend/src/server/api/endpoints/roles/notes.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index 536384a381..4752561ad5 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -12,6 +12,7 @@ import { DI } from '@/di-symbols.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { IdService } from '@/core/IdService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -74,6 +75,7 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private queryService: QueryService, private fanoutTimelineService: FanoutTimelineService, + private readonly activeUsersChart: ActiveUsersChart, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -101,11 +103,12 @@ export default class extends Endpoint { // eslint- const query = this.notesRepository.createQueryBuilder('note') .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .andWhere('(note.visibility = \'public\')') + .orderBy('note.id', 'DESC') .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); + .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') + .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId'); this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateMutedUserQueryForNotes(query, me); @@ -113,7 +116,10 @@ export default class extends Endpoint { // eslint- this.queryService.generateMutedUserRenotesQueryForNotes(query, me); const notes = await query.getMany(); - notes.sort((a, b) => a.id > b.id ? -1 : 1); + + process.nextTick(() => { + this.activeUsersChart.read(me); + }); return await this.noteEntityService.packMany(notes, me); }); From 8f466e8ac5728fe11da4d5cd0290db6be3d008c7 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 15:56:20 -0400 Subject: [PATCH 31/53] copy changes to notes/following.ts --- .../src/server/api/endpoints/notes/following.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/notes/following.ts b/packages/backend/src/server/api/endpoints/notes/following.ts index 088b172ba4..ac26dbbbc8 100644 --- a/packages/backend/src/server/api/endpoints/notes/following.ts +++ b/packages/backend/src/server/api/endpoints/notes/following.ts @@ -12,6 +12,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { QueryService } from '@/core/QueryService.js'; import { ApiError } from '@/server/api/error.js'; +import ActiveUsersChart from '@/core/chart/charts/active-users.js'; export const meta = { tags: ['notes'], @@ -76,8 +77,9 @@ export default class extends Endpoint { // eslint- @Inject(DI.notesRepository) private notesRepository: NotesRepository, - private noteEntityService: NoteEntityService, - private queryService: QueryService, + private readonly noteEntityService: NoteEntityService, + private readonly queryService: QueryService, + private readonly activeUsersChart: ActiveUsersChart, ) { super(meta, paramDef, async (ps, me) => { if (ps.includeReplies && ps.filesOnly) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); @@ -128,8 +130,8 @@ export default class extends Endpoint { // eslint- .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') + .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId') // Exclude channel notes .andWhere({ channelId: IsNull() }) @@ -157,11 +159,15 @@ export default class extends Endpoint { // eslint- // Support pagination this.queryService .makePaginationQuery(query, ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .orderBy('note.id', 'DESC') .take(ps.limit); // Query and return the next page const notes = await query.getMany(); + + process.nextTick(() => { + this.activeUsersChart.read(me); + }); + return await this.noteEntityService.packMany(notes, me, { skipHide: true }); }); } From eebdc2957da39a13d61c73a7771e213757514a1f Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 17:05:28 -0400 Subject: [PATCH 32/53] copy changes to antenna channel --- .../backend/src/server/api/stream/channels/antenna.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index 0974dbdb25..feb590adac 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -43,7 +43,15 @@ class AntennaChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - this.send('note', note); + if (note.user.isSilenced) { + if (!this.user) return; + if (note.userId !== this.user.id && !this.following[note.userId]) return; + } + + const clonedNote = await this.assignMyReaction(note); + await this.hideNote(clonedNote); + + this.send('note', clonedNote); } else { this.send(data.type, data.body); } From 96d186589afe99508acd472c77d05799918996a9 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 17:11:28 -0400 Subject: [PATCH 33/53] add Channel.isNoteVisibleToMe utility function --- packages/backend/src/server/api/stream/channel.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 640ef68746..d32ccc6450 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -61,6 +61,21 @@ export default abstract class Channel { return this.connection.subscriber; } + /** + * Checks if a note is visible to the current user *excluding* blocks and mutes. + */ + protected isNoteVisibleToMe(note: Packed<'Note'>): boolean { + if (note.visibility === 'public') return true; + if (note.visibility === 'home') return true; + if (!this.user) return false; + if (this.user.id === note.userId) return true; + if (note.visibility === 'followers') { + return this.following[note.userId] != null; + } + if (!note.visibleUserIds) return false; + return note.visibleUserIds.includes(this.user.id); + } + /* * ミュートとブロックされてるを処理する */ From 472c7b7f347b73ced3fda490f733f49bfb1b8e86 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 17:11:51 -0400 Subject: [PATCH 34/53] add instance.isSilenced to PackedUserLite --- packages/backend/src/core/entities/UserEntityService.ts | 1 + packages/backend/src/models/json-schema/user.ts | 4 ++++ packages/misskey-js/src/autogen/types.ts | 1 + 3 files changed, 6 insertions(+) diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index feddb8fa94..c030f9bfa4 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -616,6 +616,7 @@ export class UserEntityService implements OnModuleInit { iconUrl: instance.iconUrl, faviconUrl: instance.faviconUrl, themeColor: instance.themeColor, + isSilenced: instance.isSilenced, } : undefined) : undefined, followersCount: followersCount ?? 0, followingCount: followingCount ?? 0, diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 964a179244..58a6565b9f 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -200,6 +200,10 @@ export const packedUserLiteSchema = { type: 'string', nullable: true, optional: false, }, + isSilenced: { + type: 'boolean', + nullable: false, optional: false, + }, }, }, emojis: { diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index d5712cdbed..b10e0224da 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4281,6 +4281,7 @@ export type components = { iconUrl: string | null; faviconUrl: string | null; themeColor: string | null; + isSilenced: boolean; }; emojis: { [key: string]: string; From 6c58171c419d001f0a2f5b9a8544fa2bf11ff973 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 17:12:16 -0400 Subject: [PATCH 35/53] copy changes to home channel --- .../server/api/stream/channels/home-timeline.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) 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 dfdb491113..eb8a3f1ab5 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -50,24 +50,19 @@ class HomeTimelineChannel extends Channel { if (!isMe && !Object.hasOwn(this.following, note.userId)) return; } - if (note.visibility === 'followers') { - if (!isMe && !Object.hasOwn(this.following, note.userId)) return; - } else if (note.visibility === 'specified') { - if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return; - } + if (!this.isNoteVisibleToMe(note)) return; if (note.reply) { const reply = note.reply; - if (this.following[note.userId]?.withReplies) { - // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; - } else { + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following[note.userId]?.withReplies) { // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; } } - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; + if ((note.user.isSilenced || note.user.instance?.isSilenced) && !this.following[note.userId] && note.userId !== this.user!.id) return; // 純粋なリノート(引用リノートでないリノート)の場合 if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { @@ -75,7 +70,7 @@ class HomeTimelineChannel extends Channel { if (note.renote.reply) { const reply = note.renote.reply; // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; + if (!this.isNoteVisibleToMe(reply)) return; } } From 333b117a2b0a3ee7a9011ba246f2f8838088b650 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 17:21:11 -0400 Subject: [PATCH 36/53] fix antenna channel --- packages/backend/src/server/api/stream/channels/antenna.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index feb590adac..af1fed9cee 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -43,15 +43,12 @@ class AntennaChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - if (note.user.isSilenced) { + if (note.user.isSilenced || note.user.instance?.isSilenced) { if (!this.user) return; if (note.userId !== this.user.id && !this.following[note.userId]) return; } - const clonedNote = await this.assignMyReaction(note); - await this.hideNote(clonedNote); - - this.send('note', clonedNote); + this.send('note', note); } else { this.send(data.type, data.body); } From 324217d102323847f7c31b0c6733bf47e8182d39 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 17:32:42 -0400 Subject: [PATCH 37/53] handle silenced notes in Channel.isNoteMutedOrBlocked --- packages/backend/src/server/api/stream/channel.ts | 9 +++++++++ .../backend/src/server/api/stream/channels/antenna.ts | 5 ----- .../src/server/api/stream/channels/bubble-timeline.ts | 6 ------ 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index d32ccc6450..3a82865577 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -97,6 +97,15 @@ export default abstract class Channel { // 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; + // Hide silenced notes + if (note.user.isSilenced || note.user.instance?.isSilenced) { + if (this.user == null) return true; + if (this.user.id === note.userId) return false; + if (this.following[note.userId] == null) return true; + } + + // TODO muted threads + return false; } diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index af1fed9cee..0974dbdb25 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -43,11 +43,6 @@ class AntennaChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - if (note.user.isSilenced || note.user.instance?.isSilenced) { - if (!this.user) return; - if (note.userId !== this.user.id && !this.following[note.userId]) return; - } - this.send('note', note); } else { this.send(data.type, data.body); 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 ddd11f19c9..6f41fbd437 100644 --- a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts @@ -60,12 +60,6 @@ class BubbleTimelineChannel extends Channel { if (!this.utilityService.isBubbledHost(note.user.host)) return; if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; - - if (note.user.isSilenced) { - if (!this.user) return; - if (note.userId !== this.user.id && !this.following[note.userId]) return; - } - if (this.isNoteMutedOrBlocked(note)) return; const clonedNote = await this.assignMyReaction(note); From ebee6634104291147b19ad129e18427951042200 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 17:33:12 -0400 Subject: [PATCH 38/53] copy changes to main channel --- packages/backend/src/server/api/stream/channels/main.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/server/api/stream/channels/main.ts b/packages/backend/src/server/api/stream/channels/main.ts index 6194bb78dd..193907504a 100644 --- a/packages/backend/src/server/api/stream/channels/main.ts +++ b/packages/backend/src/server/api/stream/channels/main.ts @@ -32,10 +32,12 @@ class MainChannel extends Channel { switch (data.type) { case 'notification': { // Ignore notifications from instances the user has muted - if (isUserFromMutedInstance(data.body, new Set(this.userProfile?.mutedInstances ?? []))) return; + if (isUserFromMutedInstance(data.body, this.userMutedInstances)) return; if (data.body.userId && this.userIdsWhoMeMuting.has(data.body.userId)) return; if (data.body.note && data.body.note.isHidden) { + if (this.isNoteMutedOrBlocked(data.body.note)) return; + if (!this.isNoteVisibleToMe(data.body.id)) return; const note = await this.noteEntityService.pack(data.body.note.id, this.user, { detail: true, }); @@ -44,9 +46,7 @@ class MainChannel extends Channel { break; } case 'mention': { - if (isInstanceMuted(data.body, new Set(this.userProfile?.mutedInstances ?? []))) return; - - if (this.userIdsWhoMeMuting.has(data.body.userId)) return; + if (this.isNoteMutedOrBlocked(data.body)) return; if (data.body.isHidden) { const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true, From 0751600c438eb5aa50ad2c53d5fa3cb5c3a44330 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 17:40:14 -0400 Subject: [PATCH 39/53] remove duplicate checks from home channel --- .../backend/src/server/api/stream/channels/home-timeline.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 eb8a3f1ab5..d1dcbd07e5 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -50,6 +50,7 @@ class HomeTimelineChannel extends Channel { if (!isMe && !Object.hasOwn(this.following, note.userId)) return; } + if (this.isNoteMutedOrBlocked(note)) return; if (!this.isNoteVisibleToMe(note)) return; if (note.reply) { @@ -62,8 +63,6 @@ class HomeTimelineChannel extends Channel { } } - if ((note.user.isSilenced || note.user.instance?.isSilenced) && !this.following[note.userId] && note.userId !== this.user!.id) return; - // 純粋なリノート(引用リノートでないリノート)の場合 if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { if (!this.withRenotes) return; @@ -74,8 +73,6 @@ class HomeTimelineChannel extends Channel { } } - if (this.isNoteMutedOrBlocked(note)) return; - const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); From a9d4112bef994e9262bab50b5492623ea0f8a574 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 17:40:21 -0400 Subject: [PATCH 40/53] copy changes to local channel --- .../api/stream/channels/local-timeline.ts | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) 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 82b128eae0..70b02d9580 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -50,28 +50,37 @@ class LocalTimelineChannel extends Channel { @bindThis private async onNote(note: Packed<'Note'>) { + const isMe = this.user!.id === note.userId; + if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; if (!this.withBots && note.user.isBot) return; if (note.user.host !== null) return; if (note.visibility !== 'public') return; if (note.channelId != null) return; - if (note.user.requireSigninToViewContents && this.user == null) return; - if (note.renote && note.renote.user.requireSigninToViewContents && this.user == null) return; - if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return; - - // 関係ない返信は除外 - if (note.reply && this.user && !this.following[note.userId]?.withReplies && !this.withReplies) { - const reply = note.reply; - // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; - } - - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - - if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; if (this.isNoteMutedOrBlocked(note)) return; + if (!this.isNoteVisibleToMe(note)) return; + + // 関係ない返信は除外 + if (note.reply) { + const reply = note.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following[note.userId]?.withReplies) { + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; + } + } + + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { + if (!this.withRenotes) return; + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (!this.isNoteVisibleToMe(reply)) return; + } + } const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); From 44843966600d3ac6ef6a5b87118e649ca52de6cd Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 17:43:18 -0400 Subject: [PATCH 41/53] copy changes to hybrid channel --- .../api/stream/channels/hybrid-timeline.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) 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 6cb425ff81..d923167e04 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -67,34 +67,26 @@ class HybridTimelineChannel extends Channel { (note.channelId != null && this.followingChannels.has(note.channelId)) )) return; - if (note.visibility === 'followers') { - if (!isMe && !Object.hasOwn(this.following, note.userId)) return; - } else if (note.visibility === 'specified') { - if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return; - } - if (this.isNoteMutedOrBlocked(note)) return; + if (!this.isNoteVisibleToMe(note)) return; if (note.reply) { const reply = note.reply; - if ((this.following[note.userId]?.withReplies ?? false) || this.withReplies) { - // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; - } else { + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following[note.userId]?.withReplies && !this.withReplies) { // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; } } - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; - // 純粋なリノート(引用リノートでないリノート)の場合 if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { if (!this.withRenotes) return; if (note.renote.reply) { const reply = note.renote.reply; // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; + if (!this.isNoteVisibleToMe(reply)) return; } } From 5035287571cb8075585866eeee8221cc0c82864a Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 17:46:49 -0400 Subject: [PATCH 42/53] copy changes to global channel --- .../api/stream/channels/global-timeline.ts | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) 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 c899ad9490..515deb1253 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -48,20 +48,36 @@ class GlobalTimelineChannel extends Channel { @bindThis private async onNote(note: Packed<'Note'>) { + const isMe = this.user?.id === note.userId; + if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; if (!this.withBots && note.user.isBot) return; if (note.visibility !== 'public') return; if (note.channelId != null) return; - if (note.user.requireSigninToViewContents && this.user == null) return; - if (note.renote && note.renote.user.requireSigninToViewContents && this.user == null) return; - if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return; - - if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; - - if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return; if (this.isNoteMutedOrBlocked(note)) return; + if (!this.isNoteVisibleToMe(note)) return; + + if (note.reply) { + const reply = note.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following[note.userId]?.withReplies) { + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; + } + } + + // 純粋なリノート(引用リノートでないリノート)の場合 + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { + if (!this.withRenotes) return; + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (!this.isNoteVisibleToMe(reply)) return; + } + } const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); From 0866e87ce6bdc0b621cbb0663728cf837970f94c Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 17:47:03 -0400 Subject: [PATCH 43/53] fix null check in local/global/list channels --- .../backend/src/server/api/stream/channels/global-timeline.ts | 2 +- .../backend/src/server/api/stream/channels/local-timeline.ts | 4 ++-- packages/backend/src/server/api/stream/channels/user-list.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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 515deb1253..bac0277538 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -65,7 +65,7 @@ class GlobalTimelineChannel extends Channel { if (!this.isNoteVisibleToMe(reply)) return; if (!this.following[note.userId]?.withReplies) { // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; + if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return; } } 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 70b02d9580..2eb3460efa 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -50,7 +50,7 @@ class LocalTimelineChannel extends Channel { @bindThis private async onNote(note: Packed<'Note'>) { - const isMe = this.user!.id === note.userId; + const isMe = this.user?.id === note.userId; if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; if (!this.withBots && note.user.isBot) return; @@ -69,7 +69,7 @@ class LocalTimelineChannel extends Channel { if (!this.isNoteVisibleToMe(reply)) return; if (!this.following[note.userId]?.withReplies) { // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; + if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return; } } 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 8a7c2b2633..06619d1208 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -81,7 +81,7 @@ class UserListChannel extends Channel { @bindThis private async onNote(note: Packed<'Note'>) { - const isMe = this.user!.id === note.userId; + const isMe = this.user?.id === note.userId; // チャンネル投稿は無視する if (note.channelId) return; From 2e5792f213e99d91050ab7004c3d15e346b6ba07 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 17:58:39 -0400 Subject: [PATCH 44/53] copy changes to bubble channel --- .../api/stream/channels/bubble-timeline.ts | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) 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 6f41fbd437..393fe3883c 100644 --- a/packages/backend/src/server/api/stream/channels/bubble-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/bubble-timeline.ts @@ -5,11 +5,9 @@ import { Injectable } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; -import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; -import type { MiMeta } from '@/models/Meta.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; import { UtilityService } from '@/core/UtilityService.js'; @@ -22,10 +20,8 @@ class BubbleTimelineChannel extends Channel { private withRenotes: boolean; private withFiles: boolean; private withBots: boolean; - private instance: MiMeta; constructor( - private metaService: MetaService, private roleService: RoleService, private readonly utilityService: UtilityService, noteEntityService: NoteEntityService, @@ -44,7 +40,6 @@ class BubbleTimelineChannel extends Channel { this.withRenotes = !!(params.withRenotes ?? true); this.withFiles = !!(params.withFiles ?? false); this.withBots = !!(params.withBots ?? true); - this.instance = await this.metaService.fetch(); // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -52,6 +47,8 @@ class BubbleTimelineChannel extends Channel { @bindThis private async onNote(note: Packed<'Note'>) { + const isMe = this.user?.id === note.userId; + if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; if (!this.withBots && note.user.isBot) return; @@ -59,9 +56,28 @@ class BubbleTimelineChannel extends Channel { if (note.channelId != null) return; if (!this.utilityService.isBubbledHost(note.user.host)) return; - if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; if (this.isNoteMutedOrBlocked(note)) return; + if (note.reply) { + const reply = note.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following[note.userId]?.withReplies) { + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return; + } + } + + // 純粋なリノート(引用リノートでないリノート)の場合 + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { + if (!this.withRenotes) return; + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (!this.isNoteVisibleToMe(reply)) return; + } + } + const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); @@ -82,7 +98,6 @@ export class BubbleTimelineChannelService implements MiChannelService { public readonly kind = BubbleTimelineChannel.kind; constructor( - private metaService: MetaService, private roleService: RoleService, private noteEntityService: NoteEntityService, private readonly utilityService: UtilityService, @@ -92,7 +107,6 @@ export class BubbleTimelineChannelService implements MiChannelService { @bindThis public create(id: string, connection: Channel['connection']): BubbleTimelineChannel { return new BubbleTimelineChannel( - this.metaService, this.roleService, this.utilityService, this.noteEntityService, From 00a9233229bbdaa2f6096164d18e2ab440ac5b3b Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 17:59:25 -0400 Subject: [PATCH 45/53] copy changes to role channel --- .../api/stream/channels/role-timeline.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 78cd9bf868..f5984b5ae9 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -9,6 +9,7 @@ import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { JsonObject } from '@/misc/json-value.js'; +import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class RoleTimelineChannel extends Channel { @@ -40,7 +41,9 @@ class RoleTimelineChannel extends Channel { private async onEvent(data: GlobalEvents['roleTimeline']['payload']) { if (data.type === 'note') { const note = data.body; + const isMe = this.user?.id === note.userId; + // TODO this should be cached if (!(await this.roleservice.isExplorable({ id: this.roleId }))) { return; } @@ -48,6 +51,25 @@ class RoleTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; + if (note.reply) { + const reply = note.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following[note.userId]?.withReplies) { + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return; + } + } + + // 純粋なリノート(引用リノートでないリノート)の場合 + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (!this.isNoteVisibleToMe(reply)) return; + } + } + const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); From 5bd49d4aab0ea179f4c712095ef17fee311909be Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 17:59:46 -0400 Subject: [PATCH 46/53] fix user-list channel auth --- packages/backend/src/server/api/stream/channels/user-list.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 06619d1208..932fe84c0c 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -16,7 +16,8 @@ import Channel, { type MiChannelService } from '../channel.js'; class UserListChannel extends Channel { public readonly chName = 'userList'; public static shouldShare = false; - public static requireCredential = false as const; + public static requireCredential = true as const; + public static kind = 'read:account'; private listId: string; private membershipsMap: Record | undefined> = {}; private listUsersClock: NodeJS.Timeout; @@ -128,7 +129,7 @@ class UserListChannel extends Channel { } @Injectable() -export class UserListChannelService implements MiChannelService { +export class UserListChannelService implements MiChannelService { public readonly shouldShare = UserListChannel.shouldShare; public readonly requireCredential = UserListChannel.requireCredential; public readonly kind = UserListChannel.kind; From 5236d30ec53e811234b7ce1954fded7d25c3a31f Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 18:00:06 -0400 Subject: [PATCH 47/53] copy changes to list channel --- .../server/api/stream/channels/user-list.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) 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 932fe84c0c..3f1a5a8f8f 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -91,26 +91,28 @@ class UserListChannel extends Channel { if (!Object.hasOwn(this.membershipsMap, note.userId)) return; - if (note.visibility === 'followers') { - if (!isMe && !Object.hasOwn(this.following, note.userId)) return; - } else if (note.visibility === 'specified') { - if (!note.visibleUserIds!.includes(this.user!.id)) return; - } + if (this.isNoteMutedOrBlocked(note)) return; + if (!this.isNoteVisibleToMe(note)) return; if (note.reply) { const reply = note.reply; - if (this.membershipsMap[note.userId]?.withReplies) { - // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; - } else { + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く + if (!this.isNoteVisibleToMe(reply)) return; + if (!this.following[note.userId]?.withReplies) { // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; } } - if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; - - if (this.isNoteMutedOrBlocked(note)) return; + // 純粋なリノート(引用リノートでないリノート)の場合 + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { + if (!this.withRenotes) return; + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (!this.isNoteVisibleToMe(reply)) return; + } + } const clonedNote = await this.assignMyReaction(note); await this.hideNote(clonedNote); From 9d45041bd8d8c59e7e555f0f3aed945394c7989c Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 18:43:42 -0400 Subject: [PATCH 48/53] replace index on note.userHost --- ...48990452958-replace_note-userHost_index.js | 22 +++++++++++++++++++ packages/backend/src/models/Note.ts | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 packages/backend/migration/1748990452958-replace_note-userHost_index.js diff --git a/packages/backend/migration/1748990452958-replace_note-userHost_index.js b/packages/backend/migration/1748990452958-replace_note-userHost_index.js new file mode 100644 index 0000000000..55aadd8136 --- /dev/null +++ b/packages/backend/migration/1748990452958-replace_note-userHost_index.js @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ReplaceNoteUserHostIndex1748990452958 { + name = 'ReplaceNoteUserHostIndex1748990452958' + + async up(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_7125a826ab192eb27e11d358a5"`); + await queryRunner.query(` + create index "IDX_note_userHost_id" + on "note" ("userHost", "id" desc) + nulls not distinct`); + await queryRunner.query(`comment on index "IDX_note_userHost_id" is 'User host with ID included'`); + } + + async down(queryRunner) { + await queryRunner.query(`drop index if exists "IDX_note_userHost_id"`); + await queryRunner.query(`CREATE INDEX "IDX_7125a826ab192eb27e11d358a5" ON "note" ("userHost") `); + } +} diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index fa5839b6ec..b9a6f576dc 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -12,6 +12,7 @@ import { MiChannel } from './Channel.js'; import type { MiDriveFile } from './DriveFile.js'; @Index('IDX_724b311e6f883751f261ebe378', ['userId', 'id']) +@Index('IDX_note_userHost_id', { synchronize: false }) // (userHost, id desc) @Entity('note') export class MiNote { @PrimaryColumn(id()) @@ -216,7 +217,6 @@ export class MiNote { public processErrors: string[] | null; //#region Denormalized fields - @Index() @Column('varchar', { length: 128, nullable: true, comment: '[Denormalized]', From 8802fbb34d30700fcb9b6c1ddf7bc8810f9146af Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 18:47:02 -0400 Subject: [PATCH 49/53] fix query plans with IDX_instance_host_key --- ...1748990662839-fix-IDX_instance_host_key.js | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 packages/backend/migration/1748990662839-fix-IDX_instance_host_key.js diff --git a/packages/backend/migration/1748990662839-fix-IDX_instance_host_key.js b/packages/backend/migration/1748990662839-fix-IDX_instance_host_key.js new file mode 100644 index 0000000000..fc6d303743 --- /dev/null +++ b/packages/backend/migration/1748990662839-fix-IDX_instance_host_key.js @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class FixIDXInstanceHostKey1748990662839 { + async up(queryRunner) { + // must include host for index-only scans: https://www.postgresql.org/docs/current/indexes-index-only-scans.html + await queryRunner.query(`DROP INDEX "public"."IDX_instance_host_key"`); + await queryRunner.query(` + create index "IDX_instance_host_key" + on "instance" ((lower(reverse("host"::text)) || '.'::text) text_pattern_ops) + include ("host") + `); + await queryRunner.query(`comment on index "IDX_instance_host_key" is 'Expression index for finding instances by base domain'`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_instance_host_key"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_instance_host_key" ON "instance" (((lower(reverse("host")) || '.')::text) text_pattern_ops)`); + } +} From 99e3d4506232f13e311afe1a5bd942b721b2cabe Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 19:05:24 -0400 Subject: [PATCH 50/53] create IDX_note_for_timelines --- ...991828473-create-IDX_note_for_timelines.js | 19 +++++++++++++++++++ packages/backend/src/models/Note.ts | 1 + 2 files changed, 20 insertions(+) create mode 100644 packages/backend/migration/1748991828473-create-IDX_note_for_timelines.js diff --git a/packages/backend/migration/1748991828473-create-IDX_note_for_timelines.js b/packages/backend/migration/1748991828473-create-IDX_note_for_timelines.js new file mode 100644 index 0000000000..2ea7fe95d2 --- /dev/null +++ b/packages/backend/migration/1748991828473-create-IDX_note_for_timelines.js @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class CreateIDXNoteForTimelines1748991828473 { + async up(queryRunner) { + await queryRunner.query(` + create index "IDX_note_for_timelines" + on "note" ("id" desc, "channelId", "visibility", "userHost") + include ("userId", "userHost", "replyId", "replyUserId", "replyUserHost", "renoteId", "renoteUserId", "renoteUserHost") + NULLS NOT DISTINCT`); + await queryRunner.query(`comment on index "IDX_note_for_timelines" is 'Covering index for timeline queries'`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "IDX_note_for_timelines"`); + } +} diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index b9a6f576dc..90b874f29a 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -13,6 +13,7 @@ import type { MiDriveFile } from './DriveFile.js'; @Index('IDX_724b311e6f883751f261ebe378', ['userId', 'id']) @Index('IDX_note_userHost_id', { synchronize: false }) // (userHost, id desc) +@Index('IDX_note_for_timelines', { synchronize: false }) // (id desc, channelId, visibility, userHost) @Entity('note') export class MiNote { @PrimaryColumn(id()) From d34c4df031e9c03722af9a2743015fc53de58d73 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 19:08:20 -0400 Subject: [PATCH 51/53] create IDX_instance_host_filters --- ...92017688-create-IDX_instance_host_filters.js | 17 +++++++++++++++++ packages/backend/src/models/Instance.ts | 3 ++- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 packages/backend/migration/1748992017688-create-IDX_instance_host_filters.js diff --git a/packages/backend/migration/1748992017688-create-IDX_instance_host_filters.js b/packages/backend/migration/1748992017688-create-IDX_instance_host_filters.js new file mode 100644 index 0000000000..76cf16a6de --- /dev/null +++ b/packages/backend/migration/1748992017688-create-IDX_instance_host_filters.js @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class CreateIDXInstanceHostFilters1748992017688 { + async up(queryRunner) { + await queryRunner.query(` + create index "IDX_instance_host_filters" + on "instance" ("host", "isBlocked", "isSilenced", "isMediaSilenced", "isAllowListed", "isBubbled", "suspensionState")`); + await queryRunner.query(`comment on index "IDX_instance_host_filters" is 'Covering index for host filter queries'`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "IDX_instance_host_filters"`); + } +} diff --git a/packages/backend/src/models/Instance.ts b/packages/backend/src/models/Instance.ts index 0022e58933..c9200e1e35 100644 --- a/packages/backend/src/models/Instance.ts +++ b/packages/backend/src/models/Instance.ts @@ -6,7 +6,8 @@ import { Entity, PrimaryColumn, Index, Column } from 'typeorm'; import { id } from './util/id.js'; -@Index('IDX_instance_host_key', { synchronize: false }) +@Index('IDX_instance_host_key', { synchronize: false }) // ((lower(reverse("host"::text)) || '.'::text) +@Index('IDX_instance_host_filters', { synchronize: false }) // ("host", "isBlocked", "isSilenced", "isMediaSilenced", "isAllowListed", "isBubbled", "suspensionState") @Entity('instance') export class MiInstance { @PrimaryColumn(id()) From fb61668866f9af19a36704924192e6043cc2ec6d Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 19:15:19 -0400 Subject: [PATCH 52/53] create note/instance statistics --- .../1748992128683-create-statistics.js | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 packages/backend/migration/1748992128683-create-statistics.js diff --git a/packages/backend/migration/1748992128683-create-statistics.js b/packages/backend/migration/1748992128683-create-statistics.js new file mode 100644 index 0000000000..5d08868536 --- /dev/null +++ b/packages/backend/migration/1748992128683-create-statistics.js @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class CreateStatistics1748992128683 { + async up(queryRunner) { + await queryRunner.query(`CREATE STATISTICS "STTS_instance_isBlocked_isBubbled" (mcv) ON "isBlocked", "isBubbled" FROM "instance"`); + await queryRunner.query(`CREATE STATISTICS "STTS_instance_isBlocked_isSilenced" (mcv) ON "isBlocked", "isSilenced" FROM "instance"`); + await queryRunner.query(`CREATE STATISTICS "STTS_note_replyId_replyUserId_replyUserHost" (dependencies) ON "replyId", "replyUserId", "replyUserHost" FROM "note"`) + await queryRunner.query(`CREATE STATISTICS "STTS_note_renoteId_renoteUserId_renoteUserHost" (dependencies) ON "renoteId", "renoteUserId", "renoteUserHost" FROM "note"`); + await queryRunner.query(`CREATE STATISTICS "STTS_note_userId_userHost" (mcv) ON "userId", "userHost" FROM "note"`); + await queryRunner.query(`CREATE STATISTICS "STTS_note_replyUserId_replyUserHost" (mcv) ON "replyUserId", "replyUserHost" FROM "note"`); + await queryRunner.query(`CREATE STATISTICS "STTS_note_renoteUserId_renoteUserHost" (mcv) ON "renoteUserId", "renoteUserHost" FROM "note"`); + await queryRunner.query(`ANALYZE "note", "instance"`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP STATISTICS "STTS_instance_isBlocked_isBubbled"`); + await queryRunner.query(`DROP STATISTICS "STTS_instance_isBlocked_isSilenced"`); + await queryRunner.query(`DROP STATISTICS "STTS_note_replyId_replyUserId_replyUserHost"`); + await queryRunner.query(`DROP STATISTICS "STTS_note_renoteId_renoteUserId_renoteUserHost"`); + await queryRunner.query(`DROP STATISTICS "STTS_note_userId_userHost"`); + await queryRunner.query(`DROP STATISTICS "STTS_note_replyUserId_replyUserHost"`); + await queryRunner.query(`DROP STATISTICS "STTS_note_renoteUserId_renoteUserHost"`); + await queryRunner.query(`ANALYZE "note", "instance"`); + } +} From 97f8e054ce121a2c2f36202ae625d8177db2a975 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Tue, 3 Jun 2025 20:20:57 -0400 Subject: [PATCH 53/53] restore join to note.channel in channel/timeline.ts --- .../backend/src/server/api/endpoints/channels/timeline.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 19f1921e0a..b7152130d5 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -137,9 +137,9 @@ export default class extends Endpoint { // eslint- .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId') - .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId'); + .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId') + .leftJoinAndSelect('note.channel', 'channel'); this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateVisibilityQuery(query, me);