Skip to content

Commit

Permalink
Exclude posts from author feed when they don't fit into a self-thread (
Browse files Browse the repository at this point in the history
…#2347)

* appview: exclude posts from author feed when they don't fit into a self-thread

* tidy
  • Loading branch information
devinivy authored Mar 19, 2024
1 parent dbda260 commit faa8d2f
Show file tree
Hide file tree
Showing 2 changed files with 99 additions and 8 deletions.
69 changes: 66 additions & 3 deletions packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -165,5 +173,60 @@ type Params = QueryParams & {
type Skeleton = {
actor: Actor
items: FeedItem[]
filter: QueryParams['filter']
cursor?: string
}

class SelfThreadTracker {
feedUris = new Set<string>()
cache = new Map<string, boolean>()

constructor(items: FeedItem[], private hydration: HydrationState) {
items.forEach((item) => {
if (!item.repost) {
this.feedUris.add(item.post.uri)
}
})
}

eligible(uri: string, loop = new Set<string>()) {
// 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<string>): 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
}
38 changes: 33 additions & 5 deletions packages/bsky/tests/views/author-feed.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
}

0 comments on commit faa8d2f

Please sign in to comment.