diff --git a/CHANGELOG.md b/CHANGELOG.md index 32435d7375e2..db8fd914ea61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ - Fix: フォロー中のユーザーに関する"TLに他の人への返信を含める"の設定が分かりづらい問題を修正 - Fix: ランダム文字列でアップロードする際に拡張子内にドットが含まれるものを正常にアップロードできるように +### Server +- Fix: 自分のフォロワー限定投稿に対するリプライがホームタイムラインで見えないことが有る問題を修正 +- Fix: フォローしていないユーザによるフォロワー限定投稿に対するリプライがソーシャルタイムラインで表示されることがある問題を修正 + ## 2024.5.0 (merged to 2024.5.0-kinel.1) ### Note diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 5acc9706d3ae..8368384143cc 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -142,6 +142,12 @@ export default class extends Endpoint { // eslint- ]; } + const [ + followings, + ] = await Promise.all([ + this.cacheService.userFollowingsCache.fetch(me.id), + ]); + const redisTimeline = await this.fanoutTimelineEndpointService.timeline({ untilId, sinceId, @@ -152,6 +158,13 @@ export default class extends Endpoint { // eslint- useDbFallback: serverSettings.enableFanoutTimelineDbFallback, alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, + noteFilter: note => { + if (note.reply && note.reply.visibility === 'followers') { + if (!Object.hasOwn(followings, note.reply.userId) && note.reply.userId !== me.id) return false; + } + + return true; + }, dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ untilId, sinceId, diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 8b87908bd360..c9b43b535937 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -114,7 +114,7 @@ export default class extends Endpoint { // eslint- excludePureRenotes: !ps.withRenotes, noteFilter: note => { if (note.reply && note.reply.visibility === 'followers') { - if (!Object.hasOwn(followings, note.reply.userId)) return false; + if (!Object.hasOwn(followings, note.reply.userId) && note.reply.userId !== me.id) return false; } return true; diff --git a/packages/backend/src/server/api/endpoints/notes/vmimi-relay-hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/vmimi-relay-hybrid-timeline.ts index 027e16a3470f..bd1c8acd2f38 100644 --- a/packages/backend/src/server/api/endpoints/notes/vmimi-relay-hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/vmimi-relay-hybrid-timeline.ts @@ -17,6 +17,7 @@ import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointServ import { MiLocalUser } from '@/models/User.js'; import { MetaService } from '@/core/MetaService.js'; import { IdService } from '@/core/IdService.js'; +import { CacheService } from '@/core/CacheService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { FanoutTimelineName } from '@/core/FanoutTimelineService.js'; import { ApiError } from '../../error.js'; @@ -81,6 +82,7 @@ export default class extends Endpoint { // eslint- private roleService: RoleService, private activeUsersChart: ActiveUsersChart, private idService: IdService, + private cacheService: CacheService, private vmimiRelayTimelineService: VmimiRelayTimelineService, private userFollowingService: UserFollowingService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, @@ -135,6 +137,12 @@ export default class extends Endpoint { // eslint- ]; } + const [ + followings, + ] = await Promise.all([ + this.cacheService.userFollowingsCache.fetch(me.id), + ]); + const redisTimeline = await this.fanoutTimelineEndpointService.timeline({ untilId, sinceId, @@ -145,6 +153,13 @@ export default class extends Endpoint { // eslint- useDbFallback: serverSettings.enableFanoutTimelineDbFallback, alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, + noteFilter: note => { + if (note.reply && note.reply.visibility === 'followers') { + if (!Object.hasOwn(followings, note.reply.userId) && note.reply.userId !== me.id) return false; + } + + return true; + }, dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ untilId, sinceId, diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index 878a3180cb62..299e1f517a43 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -59,7 +59,7 @@ class HomeTimelineChannel extends Channel { const reply = note.reply; if (this.following[note.userId]?.withReplies) { // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; + if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; } else { // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return; @@ -72,7 +72,7 @@ class HomeTimelineChannel extends Channel { if (note.renote.reply) { const reply = note.renote.reply; // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; + if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; } } diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 575d23d53c18..2d413a8190d9 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -75,14 +75,22 @@ class HybridTimelineChannel extends Channel { const reply = note.reply; if ((this.following[note.userId]?.withReplies ?? false) || this.withReplies) { // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; + if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; } else { // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 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.withRenotes) return; + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; + } + } if (this.user && note.renoteId && !note.text) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { diff --git a/packages/backend/src/server/api/stream/channels/vmimi-relay-hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/vmimi-relay-hybrid-timeline.ts index 229a52d411d1..2c4ce9365b3c 100644 --- a/packages/backend/src/server/api/stream/channels/vmimi-relay-hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/vmimi-relay-hybrid-timeline.ts @@ -78,14 +78,22 @@ class VmimiRelayHybridTimelineChannel extends Channel { const reply = note.reply; if ((this.following[note.userId]?.withReplies ?? false) || this.withReplies) { // 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く - if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return; + if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; } else { // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 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.withRenotes) return; + if (note.renote.reply) { + const reply = note.renote.reply; + // 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く + if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return; + } + } if (this.user && note.renoteId && !note.text) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index b0a70074c6a3..72f26a38e0fd 100644 --- a/packages/backend/test/e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -34,6 +34,7 @@ describe('Streaming', () => { let kyoko: misskey.entities.SignupResponse; let chitose: misskey.entities.SignupResponse; let kanako: misskey.entities.SignupResponse; + let erin: misskey.entities.SignupResponse; // Remote users let akari: misskey.entities.SignupResponse; @@ -53,6 +54,7 @@ describe('Streaming', () => { kyoko = await signup({ username: 'kyoko' }); chitose = await signup({ username: 'chitose' }); kanako = await signup({ username: 'kanako' }); + erin = await signup({ username: 'erin' }); // erin: A generic fifth participant akari = await signup({ username: 'akari', host: 'example.com' }); chinatsu = await signup({ username: 'chinatsu', host: 'example.com' }); @@ -71,6 +73,12 @@ describe('Streaming', () => { // Follow: kyoko => chitose await api('following/create', { userId: chitose.id }, kyoko); + // Follow: erin <=> ayano each other. + // erin => ayano: withReplies: true + await api('following/create', { userId: ayano.id, withReplies: true }, erin); + // ayano => erin: withReplies: false + await api('following/create', { userId: erin.id, withReplies: false }, ayano); + // Mute: chitose => kanako await api('mute/create', { userId: kanako.id }, chitose); @@ -297,6 +305,28 @@ describe('Streaming', () => { assert.strictEqual(fired, true); }); + + test('withReplies: true のとき自分のfollowers投稿に対するリプライが流れる', async () => { + const erinNote = await post(erin, { text: 'hi', visibility: 'followers' }); + const fired = await waitFire( + erin, 'homeTimeline', // erin:home + () => api('notes/create', { text: 'hello', replyId: erinNote.id }, ayano), // ayano reply to erin's followers post + msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano + ); + + assert.strictEqual(fired, true); + }); + + test('withReplies: false でも自分の投稿に対するリプライが流れる', async () => { + const ayanoNote = await post(ayano, { text: 'hi', visibility: 'followers' }); + const fired = await waitFire( + ayano, 'homeTimeline', // ayano:home + () => api('notes/create', { text: 'hello', replyId: ayanoNote.id }, erin), // erin reply to ayano's followers post + msg => msg.type === 'note' && msg.body.userId === erin.id, // wait erin + ); + + assert.strictEqual(fired, true); + }); }); // Home describe('Local Timeline', () => { @@ -475,6 +505,38 @@ describe('Streaming', () => { assert.strictEqual(fired, false); }); + + test('withReplies: true のとき自分のfollowers投稿に対するリプライが流れる', async () => { + const erinNote = await post(erin, { text: 'hi', visibility: 'followers' }); + const fired = await waitFire( + erin, 'homeTimeline', // erin:home + () => api('notes/create', { text: 'hello', replyId: erinNote.id }, ayano), // ayano reply to erin's followers post + msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano + ); + + assert.strictEqual(fired, true); + }); + + test('withReplies: false でも自分の投稿に対するリプライが流れる', async () => { + const ayanoNote = await post(ayano, { text: 'hi', visibility: 'followers' }); + const fired = await waitFire( + ayano, 'homeTimeline', // ayano:home + () => api('notes/create', { text: 'hello', replyId: ayanoNote.id }, erin), // erin reply to ayano's followers post + msg => msg.type === 'note' && msg.body.userId === erin.id, // wait erin + ); + + assert.strictEqual(fired, true); + }); + + test('withReplies: true のフォローしていない人のfollowersノートに対するリプライが流れない', async () => { + const fired = await waitFire( + erin, 'homeTimeline', // erin:home + () => api('notes/create', { text: 'hello', replyId: chitose.id }, ayano), // ayano reply to chitose's post + msg => msg.type === 'note' && msg.body.userId === ayano.id, // wait ayano + ); + + assert.strictEqual(fired, false); + }); }); describe('Global Timeline', () => { diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 64c814897c27..05d7e5189bf1 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -118,6 +118,7 @@ describe('Timelines', () => { test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + await api('following/create', { userId: carol.id }, bob); await api('following/create', { userId: bob.id }, alice); await api('following/update', { userId: bob.id, withReplies: true }, alice); await sleep(1000); @@ -152,6 +153,24 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => note.id === carolNote.id).text, 'hi'); }); + test.concurrent('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: alice.id }, bob); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await sleep(1000); + const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); + const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + }); + test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの投稿への visibility: specified な返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); @@ -721,6 +740,62 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); + test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: carol.id }, bob); + await api('following/create', { userId: bob.id }, alice); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await sleep(1000); + const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: carol.id }, alice); + await api('following/create', { userId: carol.id }, bob); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await sleep(1000); + const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); + const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + assert.strictEqual(res.body.find((note: any) => note.id === carolNote.id).text, 'hi'); + }); + + test.concurrent('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('following/create', { userId: alice.id }, bob); + await api('following/update', { userId: bob.id, withReplies: true }, alice); + await sleep(1000); + const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); + const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + }); + test.concurrent('他人の他人への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);