diff --git a/src/components/verification/VerifierDialog.tsx b/src/components/verification/VerifierDialog.tsx index 352bc70..ead90f0 100644 --- a/src/components/verification/VerifierDialog.tsx +++ b/src/components/verification/VerifierDialog.tsx @@ -83,7 +83,7 @@ function Inner({ }, ]} alt={_( - msg`An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts.`, + msg`An illustration showing that trusted verifiers verify individual user accounts.`, )} useAppleWebpCodec /> @@ -100,8 +100,7 @@ function Inner({ {' '} - can verify others. These trusted verifiers are selected by - Bluesky. + can verify others. They can verify individual accounts. diff --git a/src/lib/api/feed/author.ts b/src/lib/api/feed/author.ts index 27f2dd2..d41afee 100644 --- a/src/lib/api/feed/author.ts +++ b/src/lib/api/feed/author.ts @@ -5,7 +5,7 @@ import { } from '@atproto/api' import {MICROCOSM_ENABLED} from '#/lib/microcosm/config' -import {buildAuthorFeed} from '#/lib/microcosm/feed' +import {type AuthorFeedFilter, buildAuthorFeed} from '#/lib/microcosm/feed' import {type FeedAPI, type FeedAPIResponse} from './types' export class AuthorFeedAPI implements FeedAPI { @@ -51,6 +51,7 @@ export class AuthorFeedAPI implements FeedAPI { viewerDid: this.agent.did, limit, cursor, + filter: this.params.filter as AuthorFeedFilter | undefined, }) if (page.feed.length) { return {cursor: page.cursor, feed: this._filter(page.feed)} diff --git a/src/lib/microcosm/feed.ts b/src/lib/microcosm/feed.ts index 562f37b..3998cb9 100644 --- a/src/lib/microcosm/feed.ts +++ b/src/lib/microcosm/feed.ts @@ -14,15 +14,21 @@ */ import {type AppBskyFeedDefs, type AppBskyFeedPost} from '@atproto/api' -import {hydratePost, hydratePostFromRecord} from '#/lib/microcosm/hydrate' +import { + hydratePost, + hydratePostFromRecord, + hydrateProfileBasic, +} from '#/lib/microcosm/hydrate' import { getRecordByUri, hydrateFeedSkeleton, listRecords, + resolveMiniDoc, } from '#/lib/microcosm/slingshot' export type FollowingFeedItem = { post: AppBskyFeedDefs.PostView + reply?: AppBskyFeedDefs.ReplyRef } export type FollowingFeedPage = { @@ -206,37 +212,186 @@ export async function buildCustomFeed( // --- Author feed (a single account's posts), independent of the AppView --- +/** + * Author-feed filter modes, mirroring app.bsky.feed.getAuthorFeed. We apply the + * reply/media filter against the raw post RECORD (its `reply` field, its embed) + * before hydrating, so e.g. the "Posts" tab (posts_no_replies) doesn't leak + * replies the way the AppView's filter would have caught. + */ +export type AuthorFeedFilter = + | 'posts_with_replies' + | 'posts_no_replies' + | 'posts_with_media' + | 'posts_and_author_threads' + | 'posts_with_video' + +function recordIsReply(record: AppBskyFeedPost.Record | undefined): boolean { + return !!record?.reply +} + +/** + * For a reply record, the DID of the account being replied to (parsed from the + * parent at-uri `at:///...`). Returns undefined for non-replies or if the + * parent uri is malformed. + */ +function replyParentDid( + record: AppBskyFeedPost.Record | undefined, +): string | undefined { + const parentUri = record?.reply?.parent?.uri + if (!parentUri) return undefined + return parentUri.match(/^at:\/\/([^/]+)\//)?.[1] +} + +/** + * Build the `reply` context (root + parent + grandparentAuthor) for a reply + * record by hydrating the referenced posts, so the UI can show "replying to @X" + * and the parent thread, the way the AppView's getAuthorFeed does. Returns + * undefined for non-replies or if the parent can't be hydrated. + */ +async function buildReplyRef( + record: AppBskyFeedPost.Record | undefined, + viewerDid: string | undefined, + signal?: AbortSignal, +): Promise { + const parentUri = record?.reply?.parent?.uri + const rootUri = record?.reply?.root?.uri + if (!parentUri) return undefined + + const [parent, root] = await Promise.all([ + hydratePost(parentUri, viewerDid, signal, 1, {lite: true}).catch( + () => undefined, + ), + rootUri && rootUri !== parentUri + ? hydratePost(rootUri, viewerDid, signal, 1, {lite: true}).catch( + () => undefined, + ) + : Promise.resolve(undefined), + ]) + if (!parent) return undefined + + // grandparentAuthor: the author of the post the PARENT is replying to. We have + // the parent's record, so resolve that author's identity from its uri. + const grandparentUri = (parent.record as AppBskyFeedPost.Record | undefined) + ?.reply?.parent?.uri + const grandparentDid = grandparentUri?.match(/^at:\/\/([^/]+)\//)?.[1] + const grandparentAuthor = grandparentDid + ? await hydrateProfileBasic(grandparentDid, signal).catch(() => undefined) + : undefined + + return { + $type: 'app.bsky.feed.defs#replyRef', + root: (root ?? parent) as AppBskyFeedDefs.ReplyRef['root'], + parent: parent as AppBskyFeedDefs.ReplyRef['parent'], + grandparentAuthor, + } +} + +function recordHasMedia(record: AppBskyFeedPost.Record | undefined): boolean { + const embedType = (record?.embed as {$type?: string} | undefined)?.$type + return ( + embedType === 'app.bsky.embed.images' || + embedType === 'app.bsky.embed.video' || + embedType === 'app.bsky.embed.recordWithMedia' + ) +} + +function recordHasVideo(record: AppBskyFeedPost.Record | undefined): boolean { + const embed = record?.embed as {$type?: string; media?: {$type?: string}} | undefined + return ( + embed?.$type === 'app.bsky.embed.video' || + (embed?.$type === 'app.bsky.embed.recordWithMedia' && + embed.media?.$type === 'app.bsky.embed.video') + ) +} + /** * One page of an author's posts, paginated via listRecords on their PDS and * hydrated. Unlike the timeline/custom feeds this paginates cleanly (listRecords * returns a cursor). Does not include reposts (those are app.bsky.feed.repost * records pointing elsewhere) — just the author's own posts. + * + * `filter` excludes records that don't belong in the requested tab, applied to + * the raw records before hydration: + * - posts_no_replies: drop all replies + * - posts_and_author_threads (the default "Posts" tab): drop replies to OTHER + * accounts, keep the author's self-replies (continuations of their own thread) + * - posts_with_media / posts_with_video: keep only records with that embed + * - posts_with_replies: keep everything */ export async function buildAuthorFeed( actor: string, - opts: {viewerDid?: string; limit?: number; cursor?: string} = {}, + opts: { + viewerDid?: string + limit?: number + cursor?: string + filter?: AuthorFeedFilter + } = {}, signal?: AbortSignal, ): Promise { - const page = await listRecords( - { - repo: actor, - collection: 'app.bsky.feed.post', - limit: opts.limit ?? 30, - cursor: opts.cursor, - }, - signal, - ) + const filter = opts.filter ?? 'posts_with_replies' + const wanted = opts.limit ?? 30 - const posts = await Promise.all( - page.records.map(rec => - hydratePost(rec.uri, opts.viewerDid, signal, 1, {lite: true}).catch(() => undefined), - ), + // Resolve to a DID so reply.parent.uri (which is DID-keyed) compares correctly + // even when `actor` is passed as a handle. + const actorDid = await resolveMiniDoc(actor, signal) + .then(m => m.did) + .catch(() => actor) + + // When filtering drops records, one PDS page may not yield enough posts, so + // page forward until we have `wanted` (bounded) or run out. + type Rec = Awaited>['records'][number] + const kept: Rec[] = [] + let cursor = opts.cursor + let pages = 0 + while (kept.length < wanted && pages < 5) { + const page = await listRecords( + { + repo: actorDid, + collection: 'app.bsky.feed.post', + limit: Math.max(wanted, 30), + cursor, + }, + signal, + ) + pages++ + for (const rec of page.records) { + const record = rec.value as AppBskyFeedPost.Record | undefined + if (filter === 'posts_no_replies' && recordIsReply(record)) continue + if ( + filter === 'posts_and_author_threads' && + recordIsReply(record) && + replyParentDid(record) !== actorDid + ) { + // A reply to someone else - belongs in "Replies", not "Posts". + continue + } + if (filter === 'posts_with_media' && !recordHasMedia(record)) continue + if (filter === 'posts_with_video' && !recordHasVideo(record)) continue + kept.push(rec) + if (kept.length >= wanted) break + } + cursor = page.cursor + if (!cursor || page.records.length === 0) break + } + + const items = await Promise.all( + kept.map(async (rec): Promise => { + const record = rec.value as AppBskyFeedPost.Record | undefined + const [post, reply] = await Promise.all([ + // Full hydration (not lite): the profile feed shows like/reply/repost + // counts and the viewer's like/repost state, same as the AppView. + hydratePost(rec.uri, opts.viewerDid, signal, 1).catch(() => undefined), + recordIsReply(record) + ? buildReplyRef(record, opts.viewerDid, signal).catch(() => undefined) + : Promise.resolve(undefined), + ]) + if (!post) return undefined + return reply ? {post, reply} : {post} + }), ) return { - cursor: page.cursor, - feed: posts - .filter((p): p is AppBskyFeedDefs.PostView => !!p) - .map(post => ({post})), + cursor, + feed: items.filter((i): i is FollowingFeedItem => !!i), } }