Fix: ミュート対象ユーザーが引用されているノートがRNされたときにミュートを貫通してしまう問題 (#16009)

* chore: change 3rd parameter of generateMutedUserQueryForNotes to options

* chore: allow specifying note column for note/block query

* chore: check for mute / block for renote of note with DB query

* chore: check for mute / block for renote of note with FTT

* refactor: ミュート・ブロックのためのクエリ呼び出しを一つの関数にまとめる

* docs(changelog): ミュート対象ユーザーが引用されているノートがRNされたときにミュートを貫通してしまう問題を修正

* fix missing default parameter

* Update is-user-related.ts

* test: add tests for mutes

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
This commit is contained in:
anatawa12 2025-05-29 13:13:07 +09:00 committed by GitHub
parent e4b7a1f4e1
commit 367dac4edd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 232 additions and 90 deletions

View File

@ -64,6 +64,7 @@
- Fix: チャットルームが削除された場合・チャットルームから抜けた場合に、未読状態が残り続けることがあるのを修正
- Fix: ユーザ除外アンテナをインポートできない問題を修正
- Fix: アンテナのセンシティブなチャンネルのノートを含むかどうかの情報がエクスポートされない問題を修正
- Fix: ミュート対象ユーザーが引用されているートがRNされたときにミュートを貫通してしまう問題を修正 #16009
- Fix: 連合モードが「なし」の場合に、生成されるHTML内のactivity jsonへのリンクタグを省略するように
- Fix: コントロールパネルから招待コードを作成すると作成者の情報が記録されない問題を修正

View File

@ -120,6 +120,8 @@ export class FanoutTimelineEndpointService {
filter = (note) => {
if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false;
if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false;
if (isUserRelated(note.renote, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false;
if (isUserRelated(note.renote, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false;
if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false;
if (isInstanceMuted(note, userMutedInstances)) return false;

View File

@ -77,9 +77,51 @@ export class QueryService {
return q;
}
/**
* 使
*
*
*
* Notes for future maintainers:
* 1) FanoutTimelineEndpointService
* FanoutTimelineEndpointService
* 2) queryService
*
* - packages/backend/src/server/api/endpoints/clips/notes.ts
*/
@bindThis
public generateBaseNoteFilteringQuery(
query: SelectQueryBuilder<any>,
me: { id: MiUser['id'] } | null,
{
excludeUserFromMute,
excludeAuthor,
}: {
excludeUserFromMute?: MiUser['id'],
excludeAuthor?: boolean,
} = {},
): void {
this.generateBlockedHostQueryForNote(query, excludeAuthor);
this.generateSuspendedUserQueryForNote(query, excludeAuthor);
if (me) {
this.generateMutedUserQueryForNotes(query, me, { excludeUserFromMute });
this.generateBlockedUserQueryForNotes(query, me);
this.generateMutedUserQueryForNotes(query, me, { noteColumn: 'renote', excludeUserFromMute });
this.generateBlockedUserQueryForNotes(query, me, { noteColumn: 'renote' });
}
}
// ここでいうBlockedは被Blockedの意
@bindThis
public generateBlockedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
public generateBlockedUserQueryForNotes(
q: SelectQueryBuilder<any>,
me: { id: MiUser['id'] },
{
noteColumn = 'note',
}: {
noteColumn?: string,
} = {},
): void {
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
.select('blocking.blockerId')
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
@ -88,16 +130,20 @@ export class QueryService {
// 投稿の返信先の作者にブロックされていない かつ
// 投稿の引用元の作者にブロックされていない
q
.andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`)
.andWhere(new Brackets(qb => {
qb
.where('note.replyUserId IS NULL')
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
.where(`${noteColumn}.userId IS NULL`)
.orWhere(`${noteColumn}.userId NOT IN (${ blockingQuery.getQuery() })`);
}))
.andWhere(new Brackets(qb => {
qb
.where('note.renoteUserId IS NULL')
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
.where(`${noteColumn}.replyUserId IS NULL`)
.orWhere(`${noteColumn}.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
}))
.andWhere(new Brackets(qb => {
qb
.where(`${noteColumn}.renoteUserId IS NULL`)
.orWhere(`${noteColumn}.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
}));
q.setParameters(blockingQuery.getParameters());
@ -137,13 +183,23 @@ export class QueryService {
}
@bindThis
public generateMutedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void {
public generateMutedUserQueryForNotes(
q: SelectQueryBuilder<any>,
me: { id: MiUser['id'] },
{
excludeUserFromMute,
noteColumn = 'note',
}: {
excludeUserFromMute?: MiUser['id'],
noteColumn?: string,
} = {},
): void {
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
.select('muting.muteeId')
.where('muting.muterId = :muterId', { muterId: me.id });
if (exclude) {
mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id });
if (excludeUserFromMute) {
mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: excludeUserFromMute });
}
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
@ -154,32 +210,36 @@ export class QueryService {
// 投稿の返信先の作者をミュートしていない かつ
// 投稿の引用元の作者をミュートしていない
q
.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`)
.andWhere(new Brackets(qb => {
qb
.where('note.replyUserId IS NULL')
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
.where(`${noteColumn}.userId IS NULL`)
.orWhere(`${noteColumn}.userId NOT IN (${ mutingQuery.getQuery() })`);
}))
.andWhere(new Brackets(qb => {
qb
.where('note.renoteUserId IS NULL')
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
.where(`${noteColumn}.replyUserId IS NULL`)
.orWhere(`${noteColumn}.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
}))
.andWhere(new Brackets(qb => {
qb
.where(`${noteColumn}.renoteUserId IS NULL`)
.orWhere(`${noteColumn}.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
}))
// mute instances
.andWhere(new Brackets(qb => {
qb
.andWhere('note.userHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
.andWhere(`${noteColumn}.userHost IS NULL`)
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? ${noteColumn}.userHost)`);
}))
.andWhere(new Brackets(qb => {
qb
.where('note.replyUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
.where(`${noteColumn}.replyUserHost IS NULL`)
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? ${noteColumn}.replyUserHost)`);
}))
.andWhere(new Brackets(qb => {
qb
.where('note.renoteUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
.where(`${noteColumn}.renoteUserHost IS NULL`)
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? ${noteColumn}.renoteUserHost)`);
}));
q.setParameters(mutingQuery.getParameters());

View File

@ -234,10 +234,7 @@ export class SearchService {
}
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me);
return query.limit(pagination.limit).getMany();
}

View File

@ -3,7 +3,17 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function isUserRelated(note: any, userIds: Set<string>, ignoreAuthor = false): boolean {
import type { MiUser } from '@/models/_.js';
interface NoteLike {
userId: MiUser['id'];
reply?: NoteLike | null;
renote?: NoteLike | null;
replyUserId?: MiUser['id'] | null;
renoteUserId?: MiUser['id'] | null;
}
export function isUserRelated(note: NoteLike | null | undefined, userIds: Set<string>, ignoreAuthor = false): boolean {
if (!note) {
return false;
}
@ -12,13 +22,16 @@ export function isUserRelated(note: any, userIds: Set<string>, ignoreAuthor = fa
return true;
}
if (note.reply != null && note.reply.userId !== note.userId && userIds.has(note.reply.userId)) {
const replyUserId = note.replyUserId ?? note.reply?.userId;
if (replyUserId != null && replyUserId !== note.userId && userIds.has(replyUserId)) {
return true;
}
if (note.renote != null && note.renote.userId !== note.userId && userIds.has(note.renote.userId)) {
const renoteUserId = note.renoteUserId ?? note.renote?.userId;
if (renoteUserId != null && renoteUserId !== note.userId && userIds.has(renoteUserId)) {
return true;
}
return false;
}

View File

@ -111,11 +111,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。
// https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me);
const notes = await query.getMany();
if (sinceId != null && untilId == null) {

View File

@ -121,12 +121,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('note.channel', 'channel');
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
}
this.queryService.generateBaseNoteFilteringQuery(query, me);
//#endregion
return await query.limit(ps.limit).getMany();

View File

@ -91,6 +91,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me, { noteColumn: 'renote' });
this.queryService.generateBlockedUserQueryForNotes(query, me, { noteColumn: 'renote' });
}
const notes = await query

View File

@ -70,12 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
}
this.queryService.generateBaseNoteFilteringQuery(query, me);
const notes = await query.limit(ps.limit).getMany();

View File

@ -78,11 +78,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
this.queryService.generateBaseNoteFilteringQuery(query, me);
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');

View File

@ -243,10 +243,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {

View File

@ -156,10 +156,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me);
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.withFiles) {

View File

@ -72,11 +72,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
if (ps.visibility) {
query.andWhere('note.visibility = :visibility', { visibility: ps.visibility });

View File

@ -72,10 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me);
const renotes = await query.limit(ps.limit).getMany();

View File

@ -56,10 +56,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me);
const timeline = await query.limit(ps.limit).getMany();

View File

@ -96,10 +96,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me);
try {
if ('tag' in ps) {

View File

@ -199,10 +199,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}));
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {

View File

@ -184,10 +184,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}));
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {

View File

@ -102,10 +102,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me);
const notes = await query.getMany();
notes.sort((a, b) => a.id > b.id ? -1 : 1);

View File

@ -186,12 +186,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query, true);
this.queryService.generateSuspendedUserQueryForNote(query, true);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me, { id: ps.userId });
this.queryService.generateBlockedUserQueryForNotes(query, me);
}
this.queryService.generateBaseNoteFilteringQuery(query, me, {
excludeAuthor: true,
excludeUserFromMute: ps.userId,
});
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');

View File

@ -64,6 +64,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateMutedUserQueryForUsers(query, me);
this.queryService.generateBlockQueryForUsers(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me, { noteColumn: 'renote' });
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')

View File

@ -345,6 +345,44 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
});
test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによる引用ノートの、フォローしているユーザーによるリノートが含まれない', async () => {
const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]);
await api('following/create', { userId: bob.id }, alice);
await api('mute/create', { userId: carol.id }, alice);
await setTimeout(1000);
const carolNote = await post(carol, { text: 'hi' });
const daveNote = await post(dave, { text: 'quote hi', renoteId: carolNote.id });
const bobNote = await post(bob, { renoteId: daveNote.id });
await waitForPushToTl();
const res = await api('notes/timeline', { limit: 100 }, alice);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
assert.strictEqual(res.body.some(note => note.id === daveNote.id), false);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
});
test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによるリプライの、フォローしているユーザーによるリノートが含まれない', async () => {
const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]);
await api('following/create', { userId: bob.id }, alice);
await api('mute/create', { userId: carol.id }, alice);
await setTimeout(1000);
const carolNote = await post(carol, { text: 'hi' });
const daveNote = await post(dave, { text: 'quote hi', replyId: carolNote.id });
const bobNote = await post(bob, { renoteId: daveNote.id });
await waitForPushToTl();
const res = await api('notes/timeline', { limit: 100 }, alice);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
assert.strictEqual(res.body.some(note => note.id === daveNote.id), false);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
});
test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]);
@ -687,6 +725,42 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
});
test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによる引用ノートの、リノートが含まれない', async () => {
const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]);
await api('mute/create', { userId: carol.id }, alice);
await setTimeout(1000);
const carolNote = await post(carol, { text: 'hi' });
const daveNote = await post(dave, { text: 'quote hi', renoteId: carolNote.id });
const bobNote = await post(bob, { renoteId: daveNote.id });
await waitForPushToTl();
const res = await api('notes/local-timeline', { limit: 100 }, alice);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
assert.strictEqual(res.body.some(note => note.id === daveNote.id), false);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
});
test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによるリプライの、リノートが含まれない', async () => {
const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]);
await api('mute/create', { userId: carol.id }, alice);
await setTimeout(1000);
const carolNote = await post(carol, { text: 'hi' });
const daveNote = await post(dave, { text: 'quote hi', replyId: carolNote.id });
const bobNote = await post(bob, { renoteId: daveNote.id });
await waitForPushToTl();
const res = await api('notes/local-timeline', { limit: 100 }, alice);
assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
assert.strictEqual(res.body.some(note => note.id === daveNote.id), false);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
});
test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
@ -1383,6 +1457,39 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
});
test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによる引用ノートの、リノートが含まれない', async () => {
const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]);
await api('mute/create', { userId: carol.id }, alice);
await setTimeout(1000);
const carolNote = await post(carol, { text: 'hi' });
const daveNote = await post(dave, { text: 'quote hi', renoteId: carolNote.id });
const bobNote = await post(bob, { renoteId: daveNote.id });
await waitForPushToTl();
const res = await api('users/notes', { userId: bob.id, limit: 100 }, alice);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
});
test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによるリプライの、リノートが含まれない', async () => {
const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]);
await api('following/create', { userId: bob.id }, alice);
await api('mute/create', { userId: carol.id }, alice);
await setTimeout(1000);
const carolNote = await post(carol, { text: 'hi' });
const daveNote = await post(dave, { text: 'quote hi', replyId: carolNote.id });
const bobNote = await post(bob, { renoteId: daveNote.id });
await waitForPushToTl();
const res = await api('users/notes', { userId: bob.id, limit: 100 }, alice);
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
});
test.concurrent('ミュートしていても userId に指定したユーザーの投稿が含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
@ -1391,6 +1498,8 @@ describe('Timelines', () => {
const bobNote1 = await post(bob, { text: 'hi' });
const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id });
const bobNote3 = await post(bob, { text: 'hi', renoteId: bobNote1.id });
const bobNote4 = await post(bob, { renoteId: bobNote2.id });
const bobNote5 = await post(bob, { renoteId: bobNote3.id });
await waitForPushToTl();
@ -1399,6 +1508,8 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote3.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote4.id), true);
assert.strictEqual(res.body.some(note => note.id === bobNote5.id), true);
});
test.concurrent('自身の visibility: specified なノートが含まれる', async () => {