From c68d87538a8486e5c236a142e42221c70b157861 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?=
 <46447427+samunohito@users.noreply.github.com>
Date: Sun, 3 Dec 2023 10:19:37 +0900
Subject: [PATCH] =?UTF-8?q?=E3=83=AA=E3=82=B9=E3=83=88=E3=82=BF=E3=82=A4?=
 =?UTF-8?q?=E3=83=A0=E3=83=A9=E3=82=A4=E3=83=B3=E3=81=A7=E3=83=9F=E3=83=A5?=
 =?UTF-8?q?=E3=83=BC=E3=83=88=E3=81=8C=E8=B2=AB=E9=80=9A=E3=81=97=E3=81=A6?=
 =?UTF-8?q?=E3=81=97=E3=81=BE=E3=81=86=E5=95=8F=E9=A1=8C=E3=81=AB=E5=AF=BE?=
 =?UTF-8?q?=E5=87=A6=20(#12534)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* ユーザリストTL系の各種動作を修正・統一

* fix

* fix CHANGELOG.md

* テスト追加
---
 CHANGELOG.md                                  |   1 +
 .../backend/src/misc/is-instance-muted.ts     |   9 +-
 .../api/endpoints/notes/user-list-timeline.ts |   4 +
 .../src/server/api/stream/Connection.ts       |   2 +
 .../backend/src/server/api/stream/channel.ts  |   4 +
 .../server/api/stream/channels/user-list.ts   |   8 +-
 packages/backend/test/e2e/streaming.ts        | 108 +++++++++++++++++-
 7 files changed, 130 insertions(+), 6 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2d96a02325..07b7cc6b9b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -49,6 +49,7 @@
 - Fix: 招待コードが使い回せる問題を修正
 - Fix: 特定の条件下でチャンネルやユーザーのノート一覧に最新のノートが表示されなくなる問題を修正
 - Fix: 何もノートしていないユーザーのフィードにアクセスするとエラーになる問題を修正
+- Fix: リストタイムラインにてミュートが機能しないケースがある問題と、チャンネル投稿がストリーミングで流れてきてしまう問題を修正 #10443
 
 ## 2023.11.1
 
diff --git a/packages/backend/src/misc/is-instance-muted.ts b/packages/backend/src/misc/is-instance-muted.ts
index b231058a95..35fe11849d 100644
--- a/packages/backend/src/misc/is-instance-muted.ts
+++ b/packages/backend/src/misc/is-instance-muted.ts
@@ -3,12 +3,13 @@
  * SPDX-License-Identifier: AGPL-3.0-only
  */
 
+import { MiNote } from '@/models/Note.js';
 import type { Packed } from './json-schema.js';
 
-export function isInstanceMuted(note: Packed<'Note'>, mutedInstances: Set<string>): boolean {
-	if (mutedInstances.has(note.user.host ?? '')) return true;
-	if (mutedInstances.has(note.reply?.user.host ?? '')) return true;
-	if (mutedInstances.has(note.renote?.user.host ?? '')) return true;
+export function isInstanceMuted(note: Packed<'Note'> | MiNote, mutedInstances: Set<string>): boolean {
+	if (mutedInstances.has(note.user?.host ?? '')) return true;
+	if (mutedInstances.has(note.reply?.user?.host ?? '')) return true;
+	if (mutedInstances.has(note.renote?.user?.host ?? '')) return true;
 
 	return false;
 }
diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts
index f39cac5c3e..f8f64738fe 100644
--- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts
@@ -18,6 +18,7 @@ import { QueryService } from '@/core/QueryService.js';
 import { MiLocalUser } from '@/models/User.js';
 import { MetaService } from '@/core/MetaService.js';
 import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
+import { isInstanceMuted } from '@/misc/is-instance-muted.js';
 import { ApiError } from '../../error.js';
 
 export const meta = {
@@ -124,10 +125,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				userIdsWhoMeMuting,
 				userIdsWhoMeMutingRenotes,
 				userIdsWhoBlockingMe,
+				userMutedInstances,
 			] = await Promise.all([
 				this.cacheService.userMutingsCache.fetch(me.id),
 				this.cacheService.renoteMutingsCache.fetch(me.id),
 				this.cacheService.userBlockedCache.fetch(me.id),
+				this.cacheService.userProfileCache.fetch(me.id).then(p => new Set(p.mutedInstances)),
 			]);
 
 			const timeline = await this.fanoutTimelineEndpointService.timeline({
@@ -150,6 +153,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 							if (ps.withRenotes === false) return false;
 						}
 					}
+					if (isInstanceMuted(note, userMutedInstances)) return false;
 
 					return true;
 				},
diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts
index 2d8fec30b1..4180ccc56a 100644
--- a/packages/backend/src/server/api/stream/Connection.ts
+++ b/packages/backend/src/server/api/stream/Connection.ts
@@ -36,6 +36,7 @@ export default class Connection {
 	public userIdsWhoMeMuting: Set<string> = new Set();
 	public userIdsWhoBlockingMe: Set<string> = new Set();
 	public userIdsWhoMeMutingRenotes: Set<string> = new Set();
+	public userMutedInstances: Set<string> = new Set();
 	private fetchIntervalId: NodeJS.Timeout | null = null;
 
 	constructor(
@@ -69,6 +70,7 @@ export default class Connection {
 		this.userIdsWhoMeMuting = userIdsWhoMeMuting;
 		this.userIdsWhoBlockingMe = userIdsWhoBlockingMe;
 		this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes;
+		this.userMutedInstances = new Set(userProfile.mutedInstances);
 	}
 
 	@bindThis
diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts
index 3aa0d69c0b..46b0709773 100644
--- a/packages/backend/src/server/api/stream/channel.ts
+++ b/packages/backend/src/server/api/stream/channel.ts
@@ -41,6 +41,10 @@ export default abstract class Channel {
 		return this.connection.userIdsWhoBlockingMe;
 	}
 
+	protected get userMutedInstances() {
+		return this.connection.userMutedInstances;
+	}
+
 	protected get followingChannels() {
 		return this.connection.followingChannels;
 	}
diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts
index 4b6628df6f..fe293e2b4d 100644
--- a/packages/backend/src/server/api/stream/channels/user-list.ts
+++ b/packages/backend/src/server/api/stream/channels/user-list.ts
@@ -5,12 +5,12 @@
 
 import { Inject, Injectable } from '@nestjs/common';
 import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js';
-import type { MiUser } from '@/models/User.js';
 import { isUserRelated } from '@/misc/is-user-related.js';
 import type { Packed } from '@/misc/json-schema.js';
 import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
 import { DI } from '@/di-symbols.js';
 import { bindThis } from '@/decorators.js';
+import { isInstanceMuted } from '@/misc/is-instance-muted.js';
 import Channel from '../channel.js';
 
 class UserListChannel extends Channel {
@@ -80,6 +80,9 @@ class UserListChannel extends Channel {
 	private async onNote(note: Packed<'Note'>) {
 		const isMe = this.user!.id === note.userId;
 
+		// チャンネル投稿は無視する
+		if (note.channelId) return;
+
 		if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
 
 		if (!Object.hasOwn(this.membershipsMap, note.userId)) return;
@@ -115,6 +118,9 @@ class UserListChannel extends Channel {
 			}
 		}
 
+		// 流れてきたNoteがミュートしているインスタンスに関わるものだったら無視する
+		if (isInstanceMuted(note, this.userMutedInstances)) return;
+
 		this.connection.cacheNote(note);
 
 		this.send('note', note);
diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts
index f9f385e2b2..c4824f50ce 100644
--- a/packages/backend/test/e2e/streaming.ts
+++ b/packages/backend/test/e2e/streaming.ts
@@ -7,7 +7,7 @@ process.env.NODE_ENV = 'test';
 
 import * as assert from 'assert';
 import { MiFollowing } from '@/models/Following.js';
-import { connectStream, signup, api, post, startServer, initTestDb, waitFire } from '../utils.js';
+import { signup, api, post, startServer, initTestDb, waitFire } from '../utils.js';
 import type { INestApplicationContext } from '@nestjs/common';
 import type * as misskey from 'misskey-js';
 
@@ -34,12 +34,16 @@ describe('Streaming', () => {
 		let ayano: misskey.entities.MeSignup;
 		let kyoko: misskey.entities.MeSignup;
 		let chitose: misskey.entities.MeSignup;
+		let kanako: misskey.entities.MeSignup;
 
 		// Remote users
 		let akari: misskey.entities.MeSignup;
 		let chinatsu: misskey.entities.MeSignup;
+		let takumi: misskey.entities.MeSignup;
 
 		let kyokoNote: any;
+		let kanakoNote: any;
+		let takumiNote: any;
 		let list: any;
 
 		beforeAll(async () => {
@@ -50,11 +54,15 @@ describe('Streaming', () => {
 			ayano = await signup({ username: 'ayano' });
 			kyoko = await signup({ username: 'kyoko' });
 			chitose = await signup({ username: 'chitose' });
+			kanako = await signup({ username: 'kanako' });
 
 			akari = await signup({ username: 'akari', host: 'example.com' });
 			chinatsu = await signup({ username: 'chinatsu', host: 'example.com' });
+			takumi = await signup({ username: 'takumi', host: 'example.com' });
 
 			kyokoNote = await post(kyoko, { text: 'foo' });
+			kanakoNote = await post(kanako, { text: 'hoge' });
+			takumiNote = await post(takumi, { text: 'piyo' });
 
 			// Follow: ayano => kyoko
 			await api('following/create', { userId: kyoko.id }, ayano);
@@ -62,6 +70,9 @@ describe('Streaming', () => {
 			// Follow: ayano => akari
 			await follow(ayano, akari);
 
+			// Mute: chitose => kanako
+			await api('mute/create', { userId: kanako.id }, chitose);
+
 			// List: chitose => ayano, kyoko
 			list = await api('users/lists/create', {
 				name: 'my list',
@@ -76,6 +87,11 @@ describe('Streaming', () => {
 				listId: list.id,
 				userId: kyoko.id,
 			}, chitose);
+
+			await api('users/lists/push', {
+				listId: list.id,
+				userId: takumi.id,
+			}, chitose);
 		}, 1000 * 60 * 2);
 
 		afterAll(async () => {
@@ -452,6 +468,96 @@ describe('Streaming', () => {
 
 				assert.strictEqual(fired, false);
 			});
+
+			// #10443
+			test('チャンネル投稿は流れない', async () => {
+				// リスインしている kyoko が 任意のチャンネルに投降した時の動きを見たい
+				const fired = await waitFire(
+					chitose, 'userList',
+					() => api('notes/create', { text: 'foo', channelId: 'dummy' }, kyoko),
+					msg => msg.type === 'note' && msg.body.userId === kyoko.id,
+					{ listId: list.id },
+				);
+
+				assert.strictEqual(fired, false);
+			});
+
+			// #10443
+			test('ミュートしているユーザへのリプライがリストTLに流れない', async () => {
+				// chitose が kanako をミュートしている状態で、リスインしている kyoko が kanako にリプライした時の動きを見たい
+				const fired = await waitFire(
+					chitose, 'userList',
+					() => api('notes/create', { text: 'foo', replyId: kanakoNote.id }, kyoko),
+					msg => msg.type === 'note' && msg.body.userId === kyoko.id,
+					{ listId: list.id },
+				);
+
+				assert.strictEqual(fired, false);
+			});
+
+			// #10443
+			test('ミュートしているユーザの投稿をリノートしたときリストTLに流れない', async () => {
+				// chitose が kanako をミュートしている状態で、リスインしている kyoko が kanako のノートをリノートした時の動きを見たい
+				const fired = await waitFire(
+					chitose, 'userList',
+					() => api('notes/create', { renoteId: kanakoNote.id }, kyoko),
+					msg => msg.type === 'note' && msg.body.userId === kyoko.id,
+					{ listId: list.id },
+				);
+
+				assert.strictEqual(fired, false);
+			});
+
+			// #10443
+			test('ミュートしているサーバのノートがリストTLに流れない', async () => {
+				await api('/i/update', {
+					mutedInstances: ['example.com'],
+				}, chitose);
+
+				// chitose が example.com をミュートしている状態で、リスインしている takumi が ノートした時の動きを見たい
+				const fired = await waitFire(
+					chitose, 'userList',
+					() => api('notes/create', { text: 'foo' }, takumi),
+					msg => msg.type === 'note' && msg.body.userId === kyoko.id,
+					{ listId: list.id },
+				);
+
+				assert.strictEqual(fired, false);
+			});
+
+			// #10443
+			test('ミュートしているサーバのノートに対するリプライがリストTLに流れない', async () => {
+				await api('/i/update', {
+					mutedInstances: ['example.com'],
+				}, chitose);
+
+				// chitose が example.com をミュートしている状態で、リスインしている kyoko が takumi のノートにリプライした時の動きを見たい
+				const fired = await waitFire(
+					chitose, 'userList',
+					() => api('notes/create', { text: 'foo', replyId: takumiNote.id }, kyoko),
+					msg => msg.type === 'note' && msg.body.userId === kyoko.id,
+					{ listId: list.id },
+				);
+
+				assert.strictEqual(fired, false);
+			});
+
+			// #10443
+			test('ミュートしているサーバのノートに対するリノートがリストTLに流れない', async () => {
+				await api('/i/update', {
+					mutedInstances: ['example.com'],
+				}, chitose);
+
+				// chitose が example.com をミュートしている状態で、リスインしている kyoko が takumi のノートをリノートした時の動きを見たい
+				const fired = await waitFire(
+					chitose, 'userList',
+					() => api('notes/create', { renoteId: takumiNote.id }, kyoko),
+					msg => msg.type === 'note' && msg.body.userId === kyoko.id,
+					{ listId: list.id },
+				);
+
+				assert.strictEqual(fired, false);
+			});
 		});
 
 		// XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac"