merge: Rework queries and add indexes to improve timeline performance (!1091)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1091

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
Hazelnoot 2025-06-04 12:40:13 +00:00
commit dae544b353
33 changed files with 743 additions and 548 deletions

View file

@ -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") `);
}
}

View file

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

View file

@ -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"`);
}
}

View file

@ -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"`);
}
}

View file

@ -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"`);
}
}

View file

@ -4,13 +4,14 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Brackets, ObjectLiteral } from 'typeorm'; import { Brackets, Not, WhereExpressionBuilder } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta } 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 { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import type { SelectQueryBuilder } from 'typeorm'; import type { SelectQueryBuilder, ObjectLiteral } from 'typeorm';
@Injectable() @Injectable()
export class QueryService { export class QueryService {
@ -36,6 +37,9 @@ export class QueryService {
@Inject(DI.renoteMutingsRepository) @Inject(DI.renoteMutingsRepository)
private renoteMutingsRepository: RenoteMutingsRepository, private renoteMutingsRepository: RenoteMutingsRepository,
@Inject(DI.instancesRepository)
private readonly instancesRepository: InstancesRepository,
@Inject(DI.meta) @Inject(DI.meta)
private meta: MiMeta, private meta: MiMeta,
@ -72,215 +76,349 @@ export class QueryService {
// ここでいうBlockedは被Blockedの意 // ここでいうBlockedは被Blockedの意
@bindThis @bindThis
public generateBlockedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { public generateBlockedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
.select('blocking.blockerId')
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
// 投稿の作者にブロックされていない かつ // 投稿の作者にブロックされていない かつ
// 投稿の返信先の作者にブロックされていない かつ // 投稿の返信先の作者にブロックされていない かつ
// 投稿の引用元の作者にブロックされていない // 投稿の引用元の作者にブロックされていない
q return this
.andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`) .andNotBlockingUser(q, 'note.userId', ':meId')
.andWhere(new Brackets(qb => { .andWhere(new Brackets(qb => this
qb .orNotBlockingUser(qb, 'note.replyUserId', ':meId')
.where('note.replyUserId IS NULL') .orWhere('note.replyUserId IS NULL')))
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`); .andWhere(new Brackets(qb => this
})) .orNotBlockingUser(qb, 'note.renoteUserId', ':meId')
.andWhere(new Brackets(qb => { .orWhere('note.renoteUserId IS NULL')))
qb .setParameters({ meId: me.id });
.where('note.renoteUserId IS NULL')
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
}));
q.setParameters(blockingQuery.getParameters());
} }
@bindThis @bindThis
public generateBlockQueryForUsers(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { public generateBlockQueryForUsers<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') this.andNotBlockingUser(q, ':meId', 'user.id');
.select('blocking.blockeeId') this.andNotBlockingUser(q, 'user.id', ':me.id');
.where('blocking.blockerId = :blockerId', { blockerId: me.id }); return q.setParameters({ meId: 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());
} }
@bindThis @bindThis
public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { public generateMutedNoteThreadQuery<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') return this
.select('threadMuted.threadId') .andNotMutingThread(q, ':meId', 'note.id')
.where('threadMuted.userId = :userId', { userId: me.id }); .andWhere(new Brackets(qb => this
.orNotMutingThread(qb, ':meId', 'note.threadId')
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); .orWhere('note.threadId IS NULL')))
q.andWhere(new Brackets(qb => { .setParameters({ meId: me.id });
qb
.where('note.threadId IS NULL')
.orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
}));
q.setParameters(mutedQuery.getParameters());
} }
@bindThis @bindThis
public generateMutedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void { public generateMutedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): SelectQueryBuilder<E> {
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 });
// 投稿の作者をミュートしていない かつ // 投稿の作者をミュートしていない かつ
// 投稿の返信先の作者をミュートしていない かつ // 投稿の返信先の作者をミュートしていない かつ
// 投稿の引用元の作者をミュートしていない // 投稿の引用元の作者をミュートしていない
q return this
.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`) .andNotMutingUser(q, ':meId', 'note.userId', exclude)
.andWhere(new Brackets(qb => { .andWhere(new Brackets(qb => this
qb .orNotMutingUser(qb, ':meId', 'note.replyUserId', exclude)
.where('note.replyUserId IS NULL') .orWhere('note.replyUserId IS NULL')))
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`); .andWhere(new Brackets(qb => this
})) .orNotMutingUser(qb, ':meId', 'note.renoteUserId', exclude)
.andWhere(new Brackets(qb => { .orWhere('note.renoteUserId IS NULL')))
qb // TODO exclude should also pass a host to skip these instances
.where('note.renoteUserId IS NULL')
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
}))
// mute instances // mute instances
.andWhere(new Brackets(qb => { .andWhere(new Brackets(qb => this
qb .andNotMutingInstance(qb, ':meId', 'note.userHost')
.andWhere('note.userHost IS NULL') .orWhere('note.userHost IS NULL')))
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`); .andWhere(new Brackets(qb => this
})) .orNotMutingInstance(qb, ':meId', 'note.replyUserHost')
.andWhere(new Brackets(qb => { .orWhere('note.replyUserHost IS NULL')))
qb .andWhere(new Brackets(qb => this
.where('note.replyUserHost IS NULL') .orNotMutingInstance(qb, ':meId', 'note.renoteUserHost')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`); .orWhere('note.renoteUserHost IS NULL')))
})) .setParameters({ meId: me.id });
.andWhere(new Brackets(qb => {
qb
.where('note.renoteUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
}));
q.setParameters(mutingQuery.getParameters());
q.setParameters(mutingInstanceQuery.getParameters());
} }
@bindThis @bindThis
public generateMutedUserQueryForUsers(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { public generateMutedUserQueryForUsers<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') return this
.select('muting.muteeId') .andNotMutingUser(q, ':meId', 'user.id')
.where('muting.muterId = :muterId', { muterId: me.id }); .setParameters({ meId: me.id });
q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`);
q.setParameters(mutingQuery.getParameters());
} }
// 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 @bindThis
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void { public generateVisibilityQuery<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null): SelectQueryBuilder<E> {
// This code must always be synchronized with the checks in Notes.isVisibleForMe. // This code must always be synchronized with the checks in Notes.isVisibleForMe.
if (me == null) { return q.andWhere(new Brackets(qb => {
q.andWhere(new Brackets(qb => { // Public post
qb qb.orWhere('note.visibility = \'public\'')
.where('note.visibility = \'public\'') .orWhere('note.visibility = \'home\'');
.orWhere('note.visibility = \'home\'');
}));
} else {
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :meId');
q.andWhere(new Brackets(qb => { if (me != null) {
qb qb
// 公開投稿である // My post
.where(new Brackets(qb => { .orWhere(':meId = note.userId')
qb // Reply to me
.where('note.visibility = \'public\'') .orWhere(':meId = note.replyUserId')
.orWhere('note.visibility = \'home\''); // DM to me
})) .orWhere(':meId = ANY (note.visibleUserIds)')
// または 自分自身 // Mentions me
.orWhere('note.userId = :meId') .orWhere(':meId = ANY (note.mentions)')
// または 自分宛て // Followers-only post
.orWhere(':meIdAsList <@ note.visibleUserIds') .orWhere(new Brackets(qb => this
.orWhere(new Brackets(qb => { .andFollowingUser(qb, ':meId', 'note.userId')
qb .andWhere('note.visibility = \'followers\'')));
// または フォロワー宛ての投稿であり、
.where('note.visibility = \'followers\'')
.andWhere(new Brackets(qb => {
qb
// 自分がフォロワーである
.where(`note.userId IN (${ followingQuery.getQuery() })`)
// または 自分の投稿へのリプライ
.orWhere('note.replyUserId = :meId')
.orWhere(':meIdAsList <@ note.mentions');
}));
}));
}));
q.setParameters({ meId: me.id, meIdAsList: [me.id] }); q.setParameters({ meId: me.id });
} }
}));
} }
@bindThis @bindThis
public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void { public generateMutedUserRenotesQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting') return q
.select('renote_muting.muteeId') .andWhere(new Brackets(qb => this
.where('renote_muting.muterId = :muterId', { muterId: me.id }); .orNotMutingRenote(qb, ':meId', 'note.userId')
q.andWhere(new Brackets(qb => {
qb
.orWhere('note.renoteId IS NULL') .orWhere('note.renoteId IS NULL')
.orWhere('note.text IS NOT NULL') .orWhere('note.text IS NOT NULL')
.orWhere('note.cw IS NOT NULL') .orWhere('note.cw IS NOT NULL')
.orWhere('note.replyId IS NOT NULL') .orWhere('note.replyId IS NOT NULL')
.orWhere('note.hasPoll = false') .orWhere('note.hasPoll = true')
.orWhere('note.fileIds != \'{}\'') .orWhere('note.fileIds != \'{}\'')))
.orWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`); .setParameters({ meId: me.id });
}));
q.setParameters(mutingQuery.getParameters());
} }
@bindThis @bindThis
public generateBlockedHostQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean, allowSilenced = true): void { public generateExcludedRenotesQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>): SelectQueryBuilder<E> {
function checkFor(key: 'user' | 'replyUser' | 'renoteUser') { return q
q.leftJoin(`note.${key}Instance`, `${key}Instance`); .andWhere(new Brackets(qb => qb
q.andWhere(new Brackets(qb => { .orWhere('note.renoteId IS NULL')
qb.orWhere(`note.${key}Id IS NULL`) // no corresponding user .orWhere('note.text IS NOT NULL')
.orWhere(`note.${key}Host IS NULL`); // local .orWhere('note.cw IS NOT NULL')
.orWhere('note.replyId IS NOT NULL')
.orWhere('note.hasPoll = true')
.orWhere('note.fileIds != \'{}\'')));
}
if (allowSilenced) { @bindThis
qb.orWhere(`${key}Instance.isBlocked = false`); // not blocked public generateBlockedHostQueryForNote<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, excludeAuthor?: boolean): SelectQueryBuilder<E> {
} else { const checkFor = (key: 'user' | 'replyUser' | 'renoteUser') => this
qb.orWhere(new Brackets(qbb => qbb .leftJoinInstance(q, `note.${key}Instance`, `${key}Instance`)
.andWhere(`${key}Instance.isBlocked = false`) // not blocked .andWhere(new Brackets(qb => {
.andWhere(`${key}Instance.isSilenced = false`))); // not silenced qb
} .orWhere(`"${key}Instance" IS NULL`) // local
.orWhere(`"${key}Instance"."isBlocked" = false`); // not blocked
if (excludeAuthor) { if (excludeAuthor) {
qb.orWhere(`note.userId = note.${key}Id`); // author qb.orWhere(`note.userId = note.${key}Id`); // author
} }
})); }));
}
if (!excludeAuthor) { if (!excludeAuthor) {
checkFor('user'); checkFor('user');
} }
checkFor('replyUser'); checkFor('replyUser');
checkFor('renoteUser'); checkFor('renoteUser');
return q;
}
@bindThis
public generateSilencedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null): SelectQueryBuilder<E> {
if (!me) {
return q.andWhere('user.isSilenced = false');
}
return this
.leftJoinInstance(q, 'note.userInstance', 'userInstance')
.andWhere(new Brackets(qb => this
// case 1: we are following the user
.orFollowingUser(qb, ':meId', 'note.userId')
// case 2: user not silenced AND instance not silenced
.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 });
}
/**
* 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 leftJoinInstance<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, relation: string | typeof MiInstance, alias: string, condition?: string): SelectQueryBuilder<E> {
// 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;
}
/**
* Adds OR condition that followerProp (user ID) is following followeeProp (user ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public orFollowingUser<Q extends WhereExpressionBuilder>(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 extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
return this.addFollowingUser(q, followerProp, followeeProp, 'andWhere');
}
private addFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere'): Q {
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('1')
.andWhere(`following.followerId = ${followerProp}`)
.andWhere(`following.followeeId = ${followeeProp}`);
return q[join](`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters());
};
/**
* Adds OR condition that blockerProp (user ID) is not blocking blockeeProp (user ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public orNotBlockingUser<Q extends WhereExpressionBuilder>(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 extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string): Q {
return this.excludeBlockingUser(q, blockerProp, blockeeProp, 'andWhere');
}
private excludeBlockingUser<Q extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string, join: 'andWhere' | 'orWhere'): Q {
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
.select('1')
.andWhere(`blocking.blockerId = ${blockerProp}`)
.andWhere(`blocking.blockeeId = ${blockeeProp}`);
return q[join](`NOT EXISTS (${blockingQuery.getQuery()})`, blockingQuery.getParameters());
};
/**
* Adds OR condition that muterProp (user ID) is not muting muteeProp (user ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public orNotMutingUser<Q extends WhereExpressionBuilder>(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 extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q {
return this.excludeMutingUser(q, muterProp, muteeProp, 'andWhere', exclude);
}
private excludeMutingUser<Q extends WhereExpressionBuilder>(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}`)
.andWhere(`muting.muteeId = ${muteeProp}`);
if (exclude) {
mutingQuery.andWhere({ muteeId: Not(exclude.id) });
}
return q[join](`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters());
}
/**
* Adds OR condition that muterProp (user ID) is not muting renotes by muteeProp (user ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public orNotMutingRenote<Q extends WhereExpressionBuilder>(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 extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
return this.excludeMutingRenote(q, muterProp, muteeProp, 'andWhere');
}
private excludeMutingRenote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting')
.select('1')
.andWhere(`renote_muting.muterId = ${muterProp}`)
.andWhere(`renote_muting.muteeId = ${muteeProp}`);
return q[join](`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters());
};
/**
* Adds OR condition that muterProp (user ID) is not muting muteeProp (instance host).
* Both props should be expressions, not raw values.
*/
@bindThis
public orNotMutingInstance<Q extends WhereExpressionBuilder>(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 extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
return this.excludeMutingInstance(q, muterProp, muteeProp, 'andWhere');
}
private excludeMutingInstance<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
.select('1')
.andWhere(`user_profile.userId = ${muterProp}`)
.andWhere(`"user_profile"."mutedInstances"::jsonb ? ${muteeProp}`);
return q[join](`NOT EXISTS (${mutingInstanceQuery.getQuery()})`, mutingInstanceQuery.getParameters());
}
/**
* Adds OR condition that muterProp (user ID) is not muting muteeProp (note ID).
* Both props should be expressions, not raw values.
*/
@bindThis
public orNotMutingThread<Q extends WhereExpressionBuilder>(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 extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
return this.excludeMutingThread(q, muterProp, muteeProp, 'andWhere');
}
private excludeMutingThread<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
const threadMutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
.select('1')
.andWhere(`threadMuted.userId = ${muterProp}`)
.andWhere(`threadMuted.threadId = ${muteeProp}`);
return q[join](`NOT EXISTS (${threadMutedQuery.getQuery()})`, threadMutedQuery.getParameters());
} }
} }

View file

@ -617,6 +617,7 @@ export class UserEntityService implements OnModuleInit {
iconUrl: instance.iconUrl, iconUrl: instance.iconUrl,
faviconUrl: instance.faviconUrl, faviconUrl: instance.faviconUrl,
themeColor: instance.themeColor, themeColor: instance.themeColor,
isSilenced: instance.isSilenced,
} : undefined) : undefined, } : undefined) : undefined,
followersCount: followersCount ?? 0, followersCount: followersCount ?? 0,
followingCount: followingCount ?? 0, followingCount: followingCount ?? 0,

View file

@ -6,7 +6,8 @@
import { Entity, PrimaryColumn, Index, Column } from 'typeorm'; import { Entity, PrimaryColumn, Index, Column } from 'typeorm';
import { id } from './util/id.js'; 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') @Entity('instance')
export class MiInstance { export class MiInstance {
@PrimaryColumn(id()) @PrimaryColumn(id())

View file

@ -12,6 +12,8 @@ import { MiChannel } from './Channel.js';
import type { MiDriveFile } from './DriveFile.js'; import type { MiDriveFile } from './DriveFile.js';
@Index('IDX_724b311e6f883751f261ebe378', ['userId', 'id']) @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') @Entity('note')
export class MiNote { export class MiNote {
@PrimaryColumn(id()) @PrimaryColumn(id())
@ -216,7 +218,6 @@ export class MiNote {
public processErrors: string[] | null; public processErrors: string[] | null;
//#region Denormalized fields //#region Denormalized fields
@Index()
@Column('varchar', { @Column('varchar', {
length: 128, nullable: true, length: 128, nullable: true,
comment: '[Denormalized]', comment: '[Denormalized]',

View file

@ -200,6 +200,10 @@ export const packedUserLiteSchema = {
type: 'string', type: 'string',
nullable: true, optional: false, nullable: true, optional: false,
}, },
isSilenced: {
type: 'boolean',
nullable: false, optional: false,
},
}, },
}, },
emojis: { emojis: {

View file

@ -145,7 +145,10 @@ class MyCustomLogger implements Logger {
@bindThis @bindThis
private transformParameters(parameters?: any[]) { private transformParameters(parameters?: any[]) {
if (this.props.enableQueryParamLogging && parameters && parameters.length > 0) { 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<string, string>);
} }
return undefined; return undefined;
@ -158,7 +161,8 @@ class MyCustomLogger implements Logger {
const prefix = (this.props.printReplicationMode && queryRunner) const prefix = (this.props.printReplicationMode && queryRunner)
? `[${queryRunner.getReplicationMode()}] ` ? `[${queryRunner.getReplicationMode()}] `
: undefined; : 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 @bindThis

View file

@ -14,6 +14,7 @@ import { IdService } from '@/core/IdService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { trackPromise } from '@/misc/promise-tracker.js'; import { trackPromise } from '@/misc/promise-tracker.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -75,6 +76,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService, private queryService: QueryService,
private fanoutTimelineService: FanoutTimelineService, private fanoutTimelineService: FanoutTimelineService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private readonly activeUsersChart: ActiveUsersChart,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@ -106,13 +108,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return []; 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 }) .where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
// NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。 // NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。
// https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255 // https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255
@ -124,11 +127,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateMutedUserRenotesQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
const notes = await query.getMany(); const notes = await query.getMany();
if (sinceId != null && untilId == null) {
notes.sort((a, b) => a.id < b.id ? -1 : 1); process.nextTick(() => {
} else { this.activeUsersChart.read(me);
notes.sort((a, b) => a.id > b.id ? -1 : 1); });
}
return await this.noteEntityService.packMany(notes, me); return await this.noteEntityService.packMany(notes, me);
}); });

View file

@ -96,7 +96,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchChannel); throw new ApiError(meta.errors.noSuchChannel);
} }
if (me) this.activeUsersChart.read(me); if (me) {
process.nextTick(() => {
this.activeUsersChart.read(me);
});
}
if (!this.serverSettings.enableFanoutTimeline) { 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); return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me), me);
@ -133,8 +137,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId')
.leftJoinAndSelect('note.channel', 'channel'); .leftJoinAndSelect('note.channel', 'channel');
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
@ -142,22 +146,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me) { if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(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) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); query.andWhere('note.fileIds != \'{}\'');
} }
if (!ps.withRenotes) {
this.queryService.generateExcludedRenotesQueryForNotes(query);
} else if (me) {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
//#endregion //#endregion
return await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();

View file

@ -1,13 +1,16 @@
/*
* SPDX-FileCopyrightText: Marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm'; import type { NotesRepository } from '@/models/_.js';
import type { NotesRepository, MiMeta } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { CacheService } from '@/core/CacheService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -56,9 +59,6 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
@Inject(DI.meta)
private serverSettings: MiMeta,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@ -66,7 +66,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService, private queryService: QueryService,
private roleService: RoleService, private roleService: RoleService,
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private cacheService: CacheService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me ? me.id : null); const policies = await this.roleService.getUserPolicies(me ? me.id : null);
@ -74,27 +73,33 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.btlDisabled); throw new ApiError(meta.errors.btlDisabled);
} }
const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : undefined;
//#region Construct query //#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.visibility = \'public\'') .andWhere('note.visibility = \'public\'')
.andWhere('note.channelId IS NULL') .andWhere('note.channelId IS NULL')
.andWhere('note.userHost IS NOT NULL') .andWhere('note.userHost IS NOT NULL')
.andWhere('userInstance.isBubbled = true') // This comes from generateBlockedHostQueryForNote below
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser'); .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.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); if (me) {
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
if (!me) query.andWhere('user.requireSigninToViewContents = false'); this.queryService.generateBlockedUserQueryForNotes(query, me);
}
if (ps.withFiles) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); query.andWhere('note.fileIds != \'{}\'');
@ -103,34 +108,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!ps.withBots) query.andWhere('user.isBot = FALSE'); if (!ps.withBots) query.andWhere('user.isBot = FALSE');
if (!ps.withRenotes) { if (!ps.withRenotes) {
query.andWhere(new Brackets(qb => qb this.queryService.generateExcludedRenotesQueryForNotes(query);
.orWhere('note.renoteId IS NULL') } else if (me) {
.orWhere('note.text IS NOT NULL') this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
.orWhere('note.cw IS NOT NULL')
.orWhere('note.replyId IS NOT NULL')
.orWhere('note.hasPoll = false')
.orWhere('note.fileIds != \'{}\'')));
} }
//#endregion //#endregion
let timeline = await query.limit(ps.limit).getMany(); const timeline = await query.limit(ps.limit).getMany();
timeline = timeline.filter(note => { if (me) {
if (note.user?.isSilenced) { process.nextTick(() => {
if (!me) return false;
if (!followings) return false;
if (note.userId !== me.id) {
return followings[note.userId];
}
}
return true;
});
process.nextTick(() => {
if (me) {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);
} });
}); }
return await this.noteEntityService.packMany(timeline, me); return await this.noteEntityService.packMany(timeline, me);
}); });

View file

@ -12,6 +12,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { ApiError } from '@/server/api/error.js'; import { ApiError } from '@/server/api/error.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@ -76,8 +77,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService, private readonly noteEntityService: NoteEntityService,
private queryService: QueryService, private readonly queryService: QueryService,
private readonly activeUsersChart: ActiveUsersChart,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
if (ps.includeReplies && ps.filesOnly) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); if (ps.includeReplies && ps.filesOnly) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles);
@ -128,8 +130,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId')
// Exclude channel notes // Exclude channel notes
.andWhere({ channelId: IsNull() }) .andWhere({ channelId: IsNull() })
@ -157,11 +159,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Support pagination // Support pagination
this.queryService this.queryService
.makePaginationQuery(query, ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .makePaginationQuery(query, ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.orderBy('note.id', 'DESC')
.take(ps.limit); .take(ps.limit);
// Query and return the next page // Query and return the next page
const notes = await query.getMany(); const notes = await query.getMany();
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(notes, me, { skipHide: true }); return await this.noteEntityService.packMany(notes, me, { skipHide: true });
}); });
} }

View file

@ -12,7 +12,6 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { CacheService } from '@/core/CacheService.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -68,7 +67,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService, private queryService: QueryService,
private roleService: RoleService, private roleService: RoleService,
private activeUsersChart: ActiveUsersChart, private activeUsersChart: ActiveUsersChart,
private cacheService: CacheService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me ? me.id : null); const policies = await this.roleService.getUserPolicies(me ? me.id : null);
@ -76,8 +74,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.gtlDisabled); throw new ApiError(meta.errors.gtlDisabled);
} }
const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : {};
//#region Construct query //#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
@ -86,15 +82,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) { if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
} }
if (ps.withFiles) { if (ps.withFiles) {
@ -103,29 +98,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!ps.withBots) query.andWhere('user.isBot = FALSE'); if (!ps.withBots) query.andWhere('user.isBot = FALSE');
if (ps.withRenotes === false) { if (!ps.withRenotes) {
query.andWhere(new Brackets(qb => { this.queryService.generateExcludedRenotesQueryForNotes(query);
qb.where('note.renoteId IS NULL'); } else if (me) {
qb.orWhere(new Brackets(qb => { this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
qb.where('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
} }
//#endregion //#endregion
let timeline = await query.limit(ps.limit).getMany(); const timeline = await query.limit(ps.limit).getMany();
timeline = timeline.filter(note => { if (me) {
if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false; process.nextTick(() => {
return true;
});
process.nextTick(() => {
if (me) {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);
} });
}); }
return await this.noteEntityService.packMany(timeline, me); return await this.noteEntityService.packMany(timeline, me);
}); });

View file

@ -66,9 +66,6 @@ export const paramDef = {
sinceDate: { type: 'integer' }, sinceDate: { type: 'integer' },
untilDate: { type: 'integer' }, untilDate: { type: 'integer' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default 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 }, withFiles: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true },
withReplies: { type: 'boolean', default: false }, withReplies: { type: 'boolean', default: false },
@ -114,12 +111,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId, untilId,
sinceId, sinceId,
limit: ps.limit, limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles, withFiles: ps.withFiles,
withReplies: ps.withReplies, withReplies: ps.withReplies,
withBots: ps.withBots, withBots: ps.withBots,
withRenotes: ps.withRenotes,
}, me); }, me);
process.nextTick(() => { process.nextTick(() => {
@ -178,12 +173,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId, untilId,
sinceId, sinceId,
limit, limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles, withFiles: ps.withFiles,
withReplies: ps.withReplies, withReplies: ps.withReplies,
withBots: ps.withBots, withBots: ps.withBots,
withRenotes: ps.withRenotes,
}, me), }, me),
}); });
@ -199,12 +192,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId: string | null, untilId: string | null,
sinceId: string | null, sinceId: string | null,
limit: number, limit: number,
includeMyRenotes: boolean,
includeRenotedMyNotes: boolean,
includeLocalRenotes: boolean,
withFiles: boolean, withFiles: boolean,
withReplies: boolean, withReplies: boolean,
withBots: boolean, withBots: boolean,
withRenotes: boolean,
}, me: MiLocalUser) { }, me: MiLocalUser) {
const followees = await this.userFollowingService.getFollowees(me.id); const followees = await this.userFollowingService.getFollowees(me.id);
const followingChannels = await this.channelFollowingsRepository.find({ const followingChannels = await this.channelFollowingsRepository.find({
@ -227,8 +218,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
if (followingChannels.length > 0) { if (followingChannels.length > 0) {
const followingChannelIds = followingChannels.map(x => x.followeeId); const followingChannelIds = followingChannels.map(x => x.followeeId);
@ -255,45 +246,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me); this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(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) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); query.andWhere('note.fileIds != \'{}\'');
} }
if (!ps.withBots) query.andWhere('user.isBot = FALSE'); if (!ps.withBots) query.andWhere('user.isBot = FALSE');
if (!ps.withRenotes) {
this.queryService.generateExcludedRenotesQueryForNotes(query);
} else {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
//#endregion //#endregion
return await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();

View file

@ -103,13 +103,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withFiles: ps.withFiles, withFiles: ps.withFiles,
withReplies: ps.withReplies, withReplies: ps.withReplies,
withBots: ps.withBots, withBots: ps.withBots,
withRenotes: ps.withRenotes,
}, me); }, me);
process.nextTick(() => { if (me) {
if (me) { process.nextTick(() => {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);
} });
}); }
return await this.noteEntityService.packMany(timeline, me); return await this.noteEntityService.packMany(timeline, me);
} }
@ -136,14 +137,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withFiles: ps.withFiles, withFiles: ps.withFiles,
withReplies: ps.withReplies, withReplies: ps.withReplies,
withBots: ps.withBots, withBots: ps.withBots,
withRenotes: ps.withRenotes,
}, me), }, me),
}); });
process.nextTick(() => { if (me) {
if (me) { process.nextTick(() => {
this.activeUsersChart.read(me); this.activeUsersChart.read(me);
} });
}); }
return timeline; return timeline;
}); });
@ -156,26 +158,38 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withFiles: boolean, withFiles: boolean,
withReplies: boolean, withReplies: boolean,
withBots: boolean, withBots: boolean,
withRenotes: boolean,
}, me: MiLocalUser | null) { }, me: MiLocalUser | null) {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId) 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') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); if (me) {
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
}
if (ps.withFiles) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); 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) { if (!ps.withReplies) {
query.andWhere(new Brackets(qb => { query.andWhere(new Brackets(qb => {
qb qb
@ -188,8 +202,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
})); }));
} }
if (!ps.withBots) query.andWhere('user.isBot = FALSE');
return await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();
} }
} }

View file

@ -10,6 +10,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
export const meta = { export const meta = {
tags: ['notes'], tags: ['notes'],
@ -57,43 +58,44 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService, private queryService: QueryService,
private readonly activeUsersChart: ActiveUsersChart,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const followingQuery = this.followingsRepository.createQueryBuilder('following') const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
.select('following.followeeId') ps.sinceId, ps.untilId)
.where('following.followerId = :followerId', { followerId: me.id }); .andWhere(new Brackets(qb => qb
.orWhere(':meId = ANY (note.mentions)')
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .orWhere(':meId = ANY (note.visibleUserIds)')))
.andWhere(new Brackets(qb => { .setParameters({ meId: me.id })
qb // このmeIdAsListパラメータはqueryServiceのgenerateVisibilityQueryでセットされる
.where(':meIdAsList <@ note.mentions')
.orWhere(':meIdAsList <@ note.visibleUserIds');
}))
// Avoid scanning primary key index // Avoid scanning primary key index
.orderBy('CONCAT(note.id)', 'DESC') .orderBy('CONCAT(note.id)', 'DESC')
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
this.queryService.generateVisibilityQuery(query, me); this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me); this.queryService.generateMutedNoteThreadQuery(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.visibility) { if (ps.visibility) {
query.andWhere('note.visibility = :visibility', { visibility: ps.visibility }); query.andWhere('note.visibility = :visibility', { visibility: ps.visibility });
} }
if (ps.following) { if (ps.following) {
query.andWhere(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id }); this.queryService.andFollowingUser(query, ':meId', 'note.userId');
query.setParameters(followingQuery.getParameters());
} }
const mentions = await query.limit(ps.limit).getMany(); const mentions = await query.limit(ps.limit).getMany();
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(mentions, me); return await this.noteEntityService.packMany(mentions, me);
}); });
} }

View file

@ -96,7 +96,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!this.serverSettings.enableBotTrending) query.andWhere('user.isBot = FALSE'); 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.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);

View file

@ -49,9 +49,6 @@ export const paramDef = {
sinceDate: { type: 'integer' }, sinceDate: { type: 'integer' },
untilDate: { type: 'integer' }, untilDate: { type: 'integer' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default 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 }, withFiles: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true }, withRenotes: { type: 'boolean', default: true },
withBots: { type: 'boolean', default: true }, withBots: { type: 'boolean', default: true },
@ -88,9 +85,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId, untilId,
sinceId, sinceId,
limit: ps.limit, limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles, withFiles: ps.withFiles,
withRenotes: ps.withRenotes, withRenotes: ps.withRenotes,
withBots: ps.withBots, withBots: ps.withBots,
@ -131,9 +125,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId, untilId,
sinceId, sinceId,
limit, limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles, withFiles: ps.withFiles,
withRenotes: ps.withRenotes, withRenotes: ps.withRenotes,
withBots: ps.withBots, withBots: ps.withBots,
@ -148,7 +139,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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 followees = await this.userFollowingService.getFollowees(me.id);
const followingChannels = await this.channelFollowingsRepository.find({ const followingChannels = await this.channelFollowingsRepository.find({
where: { where: {
@ -161,8 +152,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
if (followees.length > 0 && followingChannels.length > 0) { if (followees.length > 0 && followingChannels.length > 0) {
// ユーザー・チャンネルともにフォローあり // ユーザー・チャンネルともにフォローあり
@ -212,47 +203,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(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) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); query.andWhere('note.fileIds != \'{}\'');
} }
if (ps.withRenotes === false) {
query.andWhere('note.renoteId IS NULL');
}
if (!ps.withBots) query.andWhere('user.isBot = FALSE'); if (!ps.withBots) query.andWhere('user.isBot = FALSE');
if (!ps.withRenotes) {
this.queryService.generateExcludedRenotesQueryForNotes(query);
} else if (me) {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
//#endregion //#endregion
return await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();

View file

@ -57,9 +57,6 @@ export const paramDef = {
sinceDate: { type: 'integer' }, sinceDate: { type: 'integer' },
untilDate: { type: 'integer' }, untilDate: { type: 'integer' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default 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 }, withRenotes: { type: 'boolean', default: true },
withFiles: { withFiles: {
type: 'boolean', type: 'boolean',
@ -109,14 +106,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId, untilId,
sinceId, sinceId,
limit: ps.limit, limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles, withFiles: ps.withFiles,
withRenotes: ps.withRenotes, withRenotes: ps.withRenotes,
}, me); }, me);
this.activeUsersChart.read(me); process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(timeline, me); return await this.noteEntityService.packMany(timeline, me);
} }
@ -135,15 +131,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId, untilId,
sinceId, sinceId,
limit, limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles, withFiles: ps.withFiles,
withRenotes: ps.withRenotes, withRenotes: ps.withRenotes,
}, me), }, me),
}); });
this.activeUsersChart.read(me); process.nextTick(() => {
this.activeUsersChart.read(me);
});
return timeline; return timeline;
}); });
@ -153,9 +148,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId: string | null, untilId: string | null,
sinceId: string | null, sinceId: string | null,
limit: number, limit: number,
includeMyRenotes: boolean,
includeRenotedMyNotes: boolean,
includeLocalRenotes: boolean,
withFiles: boolean, withFiles: boolean,
withRenotes: boolean, withRenotes: boolean,
}, me: MiLocalUser) { }, me: MiLocalUser) {
@ -165,8 +157,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId')
.andWhere('userListMemberships.userListId = :userListId', { userListId: list.id }) .andWhere('userListMemberships.userListId = :userListId', { userListId: list.id })
.andWhere('note.channelId IS NULL') // チャンネルノートではない .andWhere('note.channelId IS NULL') // チャンネルノートではない
.andWhere(new Brackets(qb => { .andWhere(new Brackets(qb => {
@ -193,51 +185,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(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) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); query.andWhere('note.fileIds != \'{}\'');
} }
if (!ps.withRenotes) {
this.queryService.generateExcludedRenotesQueryForNotes(query);
} else if (me) {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
//#endregion //#endregion
return await query.limit(ps.limit).getMany(); return await query.limit(ps.limit).getMany();

View file

@ -12,6 +12,7 @@ import { DI } from '@/di-symbols.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -74,6 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService, private queryService: QueryService,
private fanoutTimelineService: FanoutTimelineService, private fanoutTimelineService: FanoutTimelineService,
private readonly activeUsersChart: ActiveUsersChart,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@ -101,11 +103,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.notesRepository.createQueryBuilder('note') const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds }) .where('note.id IN (:...noteIds)', { noteIds: noteIds })
.andWhere('(note.visibility = \'public\')') .andWhere('(note.visibility = \'public\')')
.orderBy('note.id', 'DESC')
.innerJoinAndSelect('note.user', 'user') .innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('reply.user', 'replyUser', 'replyUser.id = note.replyUserId')
.leftJoinAndSelect('renote.user', 'renoteUser'); .leftJoinAndSelect('renote.user', 'renoteUser', 'renoteUser.id = note.renoteUserId');
this.queryService.generateBlockedHostQueryForNote(query); this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedUserQueryForNotes(query, me);
@ -113,7 +116,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateMutedUserRenotesQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
const notes = await query.getMany(); 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); return await this.noteEntityService.packMany(notes, me);
}); });

View file

@ -61,6 +61,21 @@ export default abstract class Channel {
return this.connection.subscriber; 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);
}
/* /*
* *
*/ */
@ -69,7 +84,7 @@ export default abstract class Channel {
if (note.user.requireSigninToViewContents && !this.user) return true; if (note.user.requireSigninToViewContents && !this.user) return true;
// 流れてきたNoteがインスタンスミュートしたインスタンスが関わる // 流れてきたNoteがインスタンスミュートしたインスタンスが関わる
if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? [])) && !this.following[note.userId]) return true; if (isInstanceMuted(note, this.userMutedInstances) && !this.following[note.userId]) return true;
// 流れてきたNoteがミュートしているユーザーが関わる // 流れてきたNoteがミュートしているユーザーが関わる
if (isUserRelated(note, this.userIdsWhoMeMuting)) return true; if (isUserRelated(note, this.userIdsWhoMeMuting)) return true;
@ -82,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 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; 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; return false;
} }

View file

@ -5,11 +5,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import { MetaService } from '@/core/MetaService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import type { MiMeta } from '@/models/Meta.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js'; import type { JsonObject } from '@/misc/json-value.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
@ -22,10 +20,8 @@ class BubbleTimelineChannel extends Channel {
private withRenotes: boolean; private withRenotes: boolean;
private withFiles: boolean; private withFiles: boolean;
private withBots: boolean; private withBots: boolean;
private instance: MiMeta;
constructor( constructor(
private metaService: MetaService,
private roleService: RoleService, private roleService: RoleService,
private readonly utilityService: UtilityService, private readonly utilityService: UtilityService,
noteEntityService: NoteEntityService, noteEntityService: NoteEntityService,
@ -44,7 +40,6 @@ class BubbleTimelineChannel extends Channel {
this.withRenotes = !!(params.withRenotes ?? true); this.withRenotes = !!(params.withRenotes ?? true);
this.withFiles = !!(params.withFiles ?? false); this.withFiles = !!(params.withFiles ?? false);
this.withBots = !!(params.withBots ?? true); this.withBots = !!(params.withBots ?? true);
this.instance = await this.metaService.fetch();
// Subscribe events // Subscribe events
this.subscriber.on('notesStream', this.onNote); this.subscriber.on('notesStream', this.onNote);
@ -52,24 +47,37 @@ class BubbleTimelineChannel extends Channel {
@bindThis @bindThis
private async onNote(note: Packed<'Note'>) { 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.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (!this.withBots && note.user.isBot) return; if (!this.withBots && note.user.isBot) return;
if (note.visibility !== 'public') return; if (note.visibility !== 'public') return;
if (note.channelId != null) return; if (note.channelId != null) return;
if (note.user.host == null) return;
if (!this.utilityService.isBubbledHost(note.user.host)) 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;
if (note.user.isSilenced) {
if (!this.user) return;
if (note.userId !== this.user.id && !this.following[note.userId]) return;
}
if (this.isNoteMutedOrBlocked(note)) 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); const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote); await this.hideNote(clonedNote);
@ -90,7 +98,6 @@ export class BubbleTimelineChannelService implements MiChannelService<false> {
public readonly kind = BubbleTimelineChannel.kind; public readonly kind = BubbleTimelineChannel.kind;
constructor( constructor(
private metaService: MetaService,
private roleService: RoleService, private roleService: RoleService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private readonly utilityService: UtilityService, private readonly utilityService: UtilityService,
@ -100,7 +107,6 @@ export class BubbleTimelineChannelService implements MiChannelService<false> {
@bindThis @bindThis
public create(id: string, connection: Channel['connection']): BubbleTimelineChannel { public create(id: string, connection: Channel['connection']): BubbleTimelineChannel {
return new BubbleTimelineChannel( return new BubbleTimelineChannel(
this.metaService,
this.roleService, this.roleService,
this.utilityService, this.utilityService,
this.noteEntityService, this.noteEntityService,

View file

@ -48,20 +48,36 @@ class GlobalTimelineChannel extends Channel {
@bindThis @bindThis
private async onNote(note: Packed<'Note'>) { 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.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (!this.withBots && note.user.isBot) return; if (!this.withBots && note.user.isBot) return;
if (note.visibility !== 'public') return; if (note.visibility !== 'public') return;
if (note.channelId != null) 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.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); const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote); await this.hideNote(clonedNote);

View file

@ -50,37 +50,29 @@ class HomeTimelineChannel extends Channel {
if (!isMe && !Object.hasOwn(this.following, note.userId)) return; if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
} }
if (note.visibility === 'followers') { if (this.isNoteMutedOrBlocked(note)) return;
if (!isMe && !Object.hasOwn(this.following, note.userId)) return; if (!this.isNoteVisibleToMe(note)) return;
} else if (note.visibility === 'specified') {
if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return;
}
if (note.reply) { if (note.reply) {
const reply = note.reply; const reply = note.reply;
if (this.following[note.userId]?.withReplies) { // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く if (!this.isNoteVisibleToMe(reply)) return;
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; if (!this.following[note.userId]?.withReplies) {
} else {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; 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 (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return; if (!this.withRenotes) return;
if (note.renote.reply) { if (note.renote.reply) {
const reply = note.renote.reply; const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; if (!this.isNoteVisibleToMe(reply)) return;
} }
} }
if (this.isNoteMutedOrBlocked(note)) return;
const clonedNote = await this.assignMyReaction(note); const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote); await this.hideNote(clonedNote);

View file

@ -67,34 +67,26 @@ class HybridTimelineChannel extends Channel {
(note.channelId != null && this.followingChannels.has(note.channelId)) (note.channelId != null && this.followingChannels.has(note.channelId))
)) return; )) 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.isNoteMutedOrBlocked(note)) return;
if (!this.isNoteVisibleToMe(note)) return;
if (note.reply) { if (note.reply) {
const reply = note.reply; const reply = note.reply;
if ((this.following[note.userId]?.withReplies ?? false) || this.withReplies) { // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く if (!this.isNoteVisibleToMe(reply)) return;
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; if (!this.following[note.userId]?.withReplies && !this.withReplies) {
} else {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; 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 (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return; if (!this.withRenotes) return;
if (note.renote.reply) { if (note.renote.reply) {
const reply = note.renote.reply; const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; if (!this.isNoteVisibleToMe(reply)) return;
} }
} }

View file

@ -50,28 +50,37 @@ class LocalTimelineChannel extends Channel {
@bindThis @bindThis
private async onNote(note: Packed<'Note'>) { 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.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (!this.withBots && note.user.isBot) return; if (!this.withBots && note.user.isBot) return;
if (note.user.host !== null) return; if (note.user.host !== null) return;
if (note.visibility !== 'public') return; if (note.visibility !== 'public') return;
if (note.channelId != null) 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.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); const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote); await this.hideNote(clonedNote);

View file

@ -32,10 +32,12 @@ class MainChannel extends Channel {
switch (data.type) { switch (data.type) {
case 'notification': { case 'notification': {
// Ignore notifications from instances the user has muted // Ignore notifications from instances the user has muted
if (isUserFromMutedInstance(data.body, new Set<string>(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.userId && this.userIdsWhoMeMuting.has(data.body.userId)) return;
if (data.body.note && data.body.note.isHidden) { 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, { const note = await this.noteEntityService.pack(data.body.note.id, this.user, {
detail: true, detail: true,
}); });
@ -44,9 +46,7 @@ class MainChannel extends Channel {
break; break;
} }
case 'mention': { case 'mention': {
if (isInstanceMuted(data.body, new Set<string>(this.userProfile?.mutedInstances ?? []))) return; if (this.isNoteMutedOrBlocked(data.body)) return;
if (this.userIdsWhoMeMuting.has(data.body.userId)) return;
if (data.body.isHidden) { if (data.body.isHidden) {
const note = await this.noteEntityService.pack(data.body.id, this.user, { const note = await this.noteEntityService.pack(data.body.id, this.user, {
detail: true, detail: true,

View file

@ -9,6 +9,7 @@ import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { JsonObject } from '@/misc/json-value.js'; import type { JsonObject } from '@/misc/json-value.js';
import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js';
import Channel, { type MiChannelService } from '../channel.js'; import Channel, { type MiChannelService } from '../channel.js';
class RoleTimelineChannel extends Channel { class RoleTimelineChannel extends Channel {
@ -40,7 +41,9 @@ class RoleTimelineChannel extends Channel {
private async onEvent(data: GlobalEvents['roleTimeline']['payload']) { private async onEvent(data: GlobalEvents['roleTimeline']['payload']) {
if (data.type === 'note') { if (data.type === 'note') {
const note = data.body; const note = data.body;
const isMe = this.user?.id === note.userId;
// TODO this should be cached
if (!(await this.roleservice.isExplorable({ id: this.roleId }))) { if (!(await this.roleservice.isExplorable({ id: this.roleId }))) {
return; return;
} }
@ -48,6 +51,25 @@ class RoleTimelineChannel extends Channel {
if (this.isNoteMutedOrBlocked(note)) 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 (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (!this.isNoteVisibleToMe(reply)) return;
}
}
const clonedNote = await this.assignMyReaction(note); const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote); await this.hideNote(clonedNote);

View file

@ -16,7 +16,8 @@ import Channel, { type MiChannelService } from '../channel.js';
class UserListChannel extends Channel { class UserListChannel extends Channel {
public readonly chName = 'userList'; public readonly chName = 'userList';
public static shouldShare = false; 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 listId: string;
private membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {}; private membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {};
private listUsersClock: NodeJS.Timeout; private listUsersClock: NodeJS.Timeout;
@ -81,7 +82,7 @@ class UserListChannel extends Channel {
@bindThis @bindThis
private async onNote(note: Packed<'Note'>) { private async onNote(note: Packed<'Note'>) {
const isMe = this.user!.id === note.userId; const isMe = this.user?.id === note.userId;
// チャンネル投稿は無視する // チャンネル投稿は無視する
if (note.channelId) return; if (note.channelId) return;
@ -90,26 +91,28 @@ class UserListChannel extends Channel {
if (!Object.hasOwn(this.membershipsMap, note.userId)) return; if (!Object.hasOwn(this.membershipsMap, note.userId)) return;
if (note.visibility === 'followers') { if (this.isNoteMutedOrBlocked(note)) return;
if (!isMe && !Object.hasOwn(this.following, note.userId)) return; if (!this.isNoteVisibleToMe(note)) return;
} else if (note.visibility === 'specified') {
if (!note.visibleUserIds!.includes(this.user!.id)) return;
}
if (note.reply) { if (note.reply) {
const reply = note.reply; const reply = note.reply;
if (this.membershipsMap[note.userId]?.withReplies) { // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く if (!this.isNoteVisibleToMe(reply)) return;
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; if (!this.following[note.userId]?.withReplies) {
} else {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
} }
} }
if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; // 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (this.isNoteMutedOrBlocked(note)) return; 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); const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote); await this.hideNote(clonedNote);
@ -128,7 +131,7 @@ class UserListChannel extends Channel {
} }
@Injectable() @Injectable()
export class UserListChannelService implements MiChannelService<false> { export class UserListChannelService implements MiChannelService<true> {
public readonly shouldShare = UserListChannel.shouldShare; public readonly shouldShare = UserListChannel.shouldShare;
public readonly requireCredential = UserListChannel.requireCredential; public readonly requireCredential = UserListChannel.requireCredential;
public readonly kind = UserListChannel.kind; public readonly kind = UserListChannel.kind;

View file

@ -4281,6 +4281,7 @@ export type components = {
iconUrl: string | null; iconUrl: string | null;
faviconUrl: string | null; faviconUrl: string | null;
themeColor: string | null; themeColor: string | null;
isSilenced: boolean;
}; };
emojis: { emojis: {
[key: string]: string; [key: string]: string;
@ -27232,12 +27233,6 @@ export type operations = {
untilDate?: number; untilDate?: number;
/** @default false */ /** @default false */
allowPartial?: boolean; allowPartial?: boolean;
/** @default true */
includeMyRenotes?: boolean;
/** @default true */
includeRenotedMyNotes?: boolean;
/** @default true */
includeLocalRenotes?: boolean;
/** @default false */ /** @default false */
withFiles?: boolean; withFiles?: boolean;
/** @default true */ /** @default true */
@ -28653,12 +28648,6 @@ export type operations = {
untilDate?: number; untilDate?: number;
/** @default false */ /** @default false */
allowPartial?: boolean; allowPartial?: boolean;
/** @default true */
includeMyRenotes?: boolean;
/** @default true */
includeRenotedMyNotes?: boolean;
/** @default true */
includeLocalRenotes?: boolean;
/** @default false */ /** @default false */
withFiles?: boolean; withFiles?: boolean;
/** @default true */ /** @default true */
@ -28860,12 +28849,6 @@ export type operations = {
/** @default false */ /** @default false */
allowPartial?: boolean; allowPartial?: boolean;
/** @default true */ /** @default true */
includeMyRenotes?: boolean;
/** @default true */
includeRenotedMyNotes?: boolean;
/** @default true */
includeLocalRenotes?: boolean;
/** @default true */
withRenotes?: boolean; withRenotes?: boolean;
/** /**
* @description Only show notes that have attached files. * @description Only show notes that have attached files.