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),
}
}