diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index e7a6be99fb..972063fa52 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -732,7 +732,7 @@ export class UserFollowingService implements OnModuleInit { @bindThis public getFollowees(userId: MiUser['id']) { return this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') + .select(['following.followeeId', 'following.withReplies']) .where('following.followerId = :followerId', { followerId: userId }) .getMany(); } 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 0a3602df20..76cb0ead64 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -197,6 +197,10 @@ export default class extends Endpoint { // eslint- withReplies: boolean, }, me: MiLocalUser) { const followees = await this.userFollowingService.getFollowees(me.id); + const followeeIds = followees.map(f => f.followeeId); + const meOrFolloweeIds = [me.id, ...followeeIds]; + const followeeWithRepliesIds = followees.filter(f => f.withReplies).map(f => f.followeeId); + const meOrFolloweeWithRepliesIds = [...meOrFolloweeIds, ...followeeWithRepliesIds]; const mutingChannelIds = await this.channelMutingService .list({ requestUserId: me.id }, { idOnly: true }) @@ -207,14 +211,39 @@ export default class extends Endpoint { // eslint- const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .andWhere(new Brackets(qb => { + // 自分自身 + qb.where('note.userId = :meId', { meId: me.id }); + + // フォローしている人 if (followees.length > 0) { - const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; - qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); - qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); - } else { - qb.where('note.userId = :meId', { meId: me.id }); - qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); + qb.orWhere(new Brackets(qb => { + qb.where('note.userId IN (:...followeeIds)', { followeeIds }); + + // 自身に関係ないリプライを除外 + if (ps.withReplies) { + qb.andWhere(new Brackets(qb => { + qb.where('note.replyId IS NULL') + .orWhere('note.replyUserId IN (:...meOrFolloweeWithRepliesIds)', { meOrFolloweeWithRepliesIds }); + + if (followeeWithRepliesIds.length > 0) { + qb.orWhere(new Brackets(qb => { + qb.where('note.userId IN (:...followeeWithRepliesIds)', { followeeWithRepliesIds }) + .andWhere(new Brackets(qb => { + qb.where('reply.visibility != \'followers\'') + .orWhere('note.replyUserId IN (:...followeeIds)', { followeeIds }); + })); + })); + } + })); + } + })); } + + // ローカルのpublicノート + qb.orWhere(new Brackets(qb => { + qb.where('note.visibility = \'public\'') + .andWhere('note.userHost IS NULL'); + })); })) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') @@ -246,7 +275,8 @@ export default class extends Endpoint { // eslint- qb // 返信だけど投稿者自身への返信 .where('note.replyId IS NOT NULL') .andWhere('note.replyUserId = note.userId'); - })); + })) + .orWhere('note.replyUserId = :meId', { meId: me.id }); // 自分への返信 })); } diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 4fd826100d..4118e7299a 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -1664,9 +1664,6 @@ describe('Timelines', () => { }); test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { - /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ - if (!enableFanoutTimeline) return; - const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1694,16 +1691,13 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + const res = await api('notes/hybrid-timeline', { limit: 100, withReplies: true }, 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('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { - /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ - if (!enableFanoutTimeline) return; - const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1716,7 +1710,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + const res = await api('notes/hybrid-timeline', { limit: 100, withReplies: true }, 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); @@ -1724,9 +1718,6 @@ describe('Timelines', () => { }); test('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { - /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ - if (!enableFanoutTimeline) return; - const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1738,7 +1729,7 @@ describe('Timelines', () => { await waitForPushToTl(); - const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + const res = await api('notes/hybrid-timeline', { limit: 100, withReplies: true }, 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);