From faa8d2faed3e90f50b84fa5d014e841d1665c6a7 Mon Sep 17 00:00:00 2001 From: devin ivy Date: Tue, 19 Mar 2024 19:53:31 -0400 Subject: [PATCH] Exclude posts from author feed when they don't fit into a self-thread (#2347) * appview: exclude posts from author feed when they don't fit into a self-thread * tidy --- .../src/api/app/bsky/feed/getAuthorFeed.ts | 69 ++++++++++++++++++- packages/bsky/tests/views/author-feed.test.ts | 38 ++++++++-- 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts index 6d515b11f6c..f32712aa45d 100644 --- a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts @@ -15,7 +15,7 @@ import { Views } from '../../../../views' import { DataPlaneClient } from '../../../../data-plane' import { parseString } from '../../../../hydration/util' import { Actor } from '../../../../hydration/actor' -import { FeedItem } from '../../../../hydration/feed' +import { FeedItem, Post } from '../../../../hydration/feed' import { FeedType } from '../../../../proto/bsky_pb' export default function (server: Server, ctx: AppContext) { @@ -77,7 +77,7 @@ export const skeleton = async (inputs: { throw new InvalidRequestError('Profile not found') } if (clearlyBadCursor(params.cursor)) { - return { actor, items: [] } + return { actor, filter: params.filter, items: [] } } const res = await ctx.dataplane.getAuthorFeed({ actorDid: did, @@ -87,6 +87,7 @@ export const skeleton = async (inputs: { }) return { actor, + filter: params.filter, items: res.items.map((item) => ({ post: { uri: item.uri, cid: item.cid || undefined }, repost: item.repost @@ -129,12 +130,19 @@ const noBlocksOrMutedReposts = (inputs: { 'BlockedByActor', ) } + // for posts_and_author_threads, ensure replies are only included if the feed + // contains all replies up to the thread root (i.e. a complete self-thread.) + const selfThread = + skeleton.filter === 'posts_and_author_threads' + ? new SelfThreadTracker(skeleton.items, hydration) + : undefined skeleton.items = skeleton.items.filter((item) => { const bam = ctx.views.feedItemBlocksAndMutes(item, hydration) return ( !bam.authorBlocked && !bam.originatorBlocked && - !(bam.authorMuted && !bam.originatorMuted) + (!bam.authorMuted || bam.originatorMuted) && + (!selfThread || selfThread.eligible(item.post.uri)) ) }) return skeleton @@ -165,5 +173,60 @@ type Params = QueryParams & { type Skeleton = { actor: Actor items: FeedItem[] + filter: QueryParams['filter'] cursor?: string } + +class SelfThreadTracker { + feedUris = new Set() + cache = new Map() + + constructor(items: FeedItem[], private hydration: HydrationState) { + items.forEach((item) => { + if (!item.repost) { + this.feedUris.add(item.post.uri) + } + }) + } + + eligible(uri: string, loop = new Set()) { + // if we've already checked this uri, pull from the cache + if (this.cache.has(uri)) { + return this.cache.get(uri) ?? false + } + // loop detection + if (loop.has(uri)) { + this.cache.set(uri, false) + return false + } else { + loop.add(uri) + } + // cache through the result + const result = this._eligible(uri, loop) + this.cache.set(uri, result) + return result + } + + private _eligible(uri: string, loop: Set): boolean { + // must be in the feed to be in a self-thread + if (!this.feedUris.has(uri)) { + return false + } + // must be hydratable to be part of self-thread + const post = this.hydration.posts?.get(uri) + if (!post) { + return false + } + // root posts (no parent) are trivial case of self-thread + const parentUri = getParentUri(post) + if (parentUri === null) { + return true + } + // recurse w/ cache: this post is in a self-thread if its parent is. + return this.eligible(parentUri, loop) + } +} + +function getParentUri(post: Post) { + return post.record.reply?.parent.uri ?? null +} diff --git a/packages/bsky/tests/views/author-feed.test.ts b/packages/bsky/tests/views/author-feed.test.ts index 160608744a6..bd49eea73f4 100644 --- a/packages/bsky/tests/views/author-feed.test.ts +++ b/packages/bsky/tests/views/author-feed.test.ts @@ -1,9 +1,10 @@ -import AtpAgent from '@atproto/api' +import AtpAgent, { AtUri } from '@atproto/api' import { TestNetwork, SeedClient, authorFeedSeed } from '@atproto/dev-env' import { forSnapshot, paginateAll, stripViewerFromPost } from '../_util' -import { isRecord } from '../../src/lexicon/types/app/bsky/feed/post' +import { ReplyRef, isRecord } from '../../src/lexicon/types/app/bsky/feed/post' import { isView as isEmbedRecordWithMedia } from '../../src/lexicon/types/app/bsky/embed/recordWithMedia' import { isView as isImageEmbed } from '../../src/lexicon/types/app/bsky/embed/images' +import { isPostView } from '../../src/lexicon/types/app/bsky/feed/defs' describe('pds author feed views', () => { let network: TestNetwork @@ -282,13 +283,40 @@ describe('pds author feed views', () => { filter: 'posts_and_author_threads', }) - expect(eveFeed.feed.length).toEqual(7) + expect(eveFeed.feed.length).toEqual(5) expect( eveFeed.feed.some(({ post }) => { - return ( + const replyByEve = isRecord(post.record) && post.record.reply && post.author.did === eve - ) + return replyByEve + }), + ).toBeTruthy() + // does not include eve's replies to fred, even within her own thread. + expect( + eveFeed.feed.every(({ post, reply }) => { + if (!post || !isRecord(post.record) || !post.record.reply) { + return true // not a reply + } + const replyToEve = isReplyTo(post.record.reply, eve) + const replyToReplyByEve = + reply && + isPostView(reply.parent) && + isRecord(reply.parent.record) && + (!reply.parent.record.reply || + isReplyTo(reply.parent.record.reply, eve)) + return replyToEve && replyToReplyByEve }), ).toBeTruthy() }) }) + +function isReplyTo(reply: ReplyRef, did: string) { + return ( + getDidFromUri(reply.root.uri) === did && + getDidFromUri(reply.parent.uri) === did + ) +} + +function getDidFromUri(uri: string) { + return new AtUri(uri).hostname +}