From 8b4f284786a516f6029fd55f16e8d148fe7da06d Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 3 Aug 2026 18:42:02 +0300 Subject: [PATCH] migrate the custom, merge and home feed apis to the appview client Co-Authored-By: Claude Fable 5 --- src/lib/api/feed/custom.ts | 88 +++++++++++++++-------------- src/lib/api/feed/home.ts | 24 ++++---- src/lib/api/feed/merge.ts | 111 ++++++++++++++++++++++--------------- 3 files changed, 123 insertions(+), 100 deletions(-) diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts index 54d9dc9067..3b1ec3bec3 100644 --- a/src/lib/api/feed/custom.ts +++ b/src/lib/api/feed/custom.ts @@ -1,46 +1,46 @@ -import { - type AppBskyFeedDefs, - type AppBskyFeedGetFeed as GetCustomFeed, - AtpAgent, - jsonStringToLex, -} from '@atproto/api' +import {type AppBskyFeedDefs, AtpAgent, jsonStringToLex} from '@atproto/api' +import {type Client, type XrpcRequestParams} from '@atproto/lex' import { getAppLanguageAsContentLanguage, getContentLanguages, } from '#/state/preferences/languages' +import {app} from '#/lexicons' import {type FeedAPI, type FeedAPIResponse} from './types' import {createBskyTopicsHeader, isBlueskyOwnedFeed} from './utils' +type GetCustomFeedParams = XrpcRequestParams + export class CustomFeedAPI implements FeedAPI { - agent: AtpAgent - params: GetCustomFeed.QueryParams + client: Client + params: GetCustomFeedParams userInterests?: string constructor({ - agent, + client, feedParams, userInterests, }: { - agent: AtpAgent - feedParams: GetCustomFeed.QueryParams + client: Client + feedParams: GetCustomFeedParams userInterests?: string }) { - this.agent = agent + this.client = client this.params = feedParams this.userInterests = userInterests } async peekLatest(): Promise { const contentLangs = getContentLanguages().join(',') - const res = await this.agent.app.bsky.feed.getFeed( + const data = await this.client.call( + app.bsky.feed.getFeed, { ...this.params, limit: 1, }, {headers: {'Accept-Language': contentLangs}}, ) - return res.data.feed[0] + return data.feed[0] } async fetch({ @@ -51,11 +51,17 @@ export class CustomFeedAPI implements FeedAPI { limit: number }): Promise { const contentLangs = getContentLanguages().join(',') - const agent = this.agent const isBlueskyOwned = isBlueskyOwnedFeed(this.params.feed) - const res = agent.did - ? await this.agent.app.bsky.feed.getFeed( + /* + * The authed branch rejects on failure, so the error propagates to the + * query and drives the feed error UI (feedgen offline, misconfigured, rate + * limited). Only the logged-out branch can resolve without data, and it + * signals that with a null body. + */ + const data = this.client.did + ? await this.client.call( + app.bsky.feed.getFeed, { ...this.params, cursor, @@ -71,21 +77,22 @@ export class CustomFeedAPI implements FeedAPI { }, ) : await loggedOutFetch({...this.params, cursor, limit}) - if (res.success) { - // NOTE - // some custom feeds fail to enforce the pagination limit - // so we manually truncate here - // -prf - if (res.data.feed.length > limit) { - res.data.feed = res.data.feed.slice(0, limit) - } + + if (!data) { return { - cursor: res.data.feed.length ? res.data.cursor : undefined, - feed: res.data.feed, + feed: [], } } + + // NOTE + // some custom feeds fail to enforce the pagination limit + // so we manually truncate here + // -prf + const feed = + data.feed.length > limit ? data.feed.slice(0, limit) : data.feed return { - feed: [], + cursor: feed.length ? data.cursor : undefined, + feed, } } } @@ -105,7 +112,7 @@ async function loggedOutFetch({ feed: string limit: number cursor?: string -}) { +}): Promise { let contentLangs = getAppLanguageAsContentLanguage() /** @@ -128,14 +135,15 @@ async function loggedOutFetch({ headers: {'Accept-Language': contentLangs, ...labelersHeader}, }, ) + /* + * The response is hand-decoded rather than validated, so the lex output shape + * is asserted here just as the old-world one was. + */ let data = res.ok - ? (jsonStringToLex(await res.text()) as GetCustomFeed.OutputSchema) + ? (jsonStringToLex(await res.text()) as app.bsky.feed.getFeed.$OutputBody) : null if (data?.feed?.length) { - return { - success: true, - data, - } + return data } // no data, try again with language headers removed @@ -146,17 +154,11 @@ async function loggedOutFetch({ {method: 'GET', headers: {'Accept-Language': '', ...labelersHeader}}, ) data = res.ok - ? (jsonStringToLex(await res.text()) as GetCustomFeed.OutputSchema) + ? (jsonStringToLex(await res.text()) as app.bsky.feed.getFeed.$OutputBody) : null if (data?.feed?.length) { - return { - success: true, - data, - } + return data } - return { - success: false, - data: {feed: []}, - } + return null } diff --git a/src/lib/api/feed/home.ts b/src/lib/api/feed/home.ts index aa13c70bf0..a5ff0f3b93 100644 --- a/src/lib/api/feed/home.ts +++ b/src/lib/api/feed/home.ts @@ -1,4 +1,6 @@ -import {type AppBskyFeedDefs, type AtpAgent} from '@atproto/api' +import {type AppBskyFeedDefs} from '@atproto/api' +import {type Client} from '@atproto/lex' +import {type AtUriString} from '@atproto/syntax' import {PROD_DEFAULT_FEED} from '#/lib/constants' import {CustomFeedAPI} from './custom' @@ -27,7 +29,7 @@ export const FALLBACK_MARKER_POST: AppBskyFeedDefs.FeedViewPost = { } export class HomeFeedAPI implements FeedAPI { - agent: AtpAgent + client: Client following: FollowingFeedAPI discover: CustomFeedAPI usingDiscover = false @@ -36,25 +38,25 @@ export class HomeFeedAPI implements FeedAPI { constructor({ userInterests, - agent, + client, }: { userInterests?: string - agent: AtpAgent + client: Client }) { - this.agent = agent - this.following = new FollowingFeedAPI({agent}) + this.client = client + this.following = new FollowingFeedAPI({client}) this.discover = new CustomFeedAPI({ - agent, - feedParams: {feed: PROD_DEFAULT_FEED('whats-hot')}, + client, + feedParams: {feed: PROD_DEFAULT_FEED('whats-hot') as AtUriString}, }) this.userInterests = userInterests } reset() { - this.following = new FollowingFeedAPI({agent: this.agent}) + this.following = new FollowingFeedAPI({client: this.client}) this.discover = new CustomFeedAPI({ - agent: this.agent, - feedParams: {feed: PROD_DEFAULT_FEED('whats-hot')}, + client: this.client, + feedParams: {feed: PROD_DEFAULT_FEED('whats-hot') as AtUriString}, userInterests: this.userInterests, }) this.usingDiscover = false diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts index c341dd53a0..2e3abcb8e3 100644 --- a/src/lib/api/feed/merge.ts +++ b/src/lib/api/feed/merge.ts @@ -1,8 +1,6 @@ -import { - type AppBskyFeedDefs, - type AppBskyFeedGetTimeline, - type AtpAgent, -} from '@atproto/api' +import {type AppBskyFeedDefs} from '@atproto/api' +import {type Client} from '@atproto/lex' +import {type AtUriString} from '@atproto/syntax' import shuffle from 'lodash.shuffle' import {bundleAsync} from '#/lib/async/bundle' @@ -10,6 +8,7 @@ import {timeout} from '#/lib/async/timeout' import {feedUriToHref} from '#/lib/strings/url-helpers' import {getContentLanguages} from '#/state/preferences/languages' import {type FeedParams} from '#/state/queries/post-feed' +import {app} from '#/lexicons' import {FeedTuner} from '../feed-manip' import {type FeedTunerFn} from '../feed-manip' import { @@ -22,9 +21,19 @@ import {createBskyTopicsHeader, isBlueskyOwnedFeed} from './utils' const REQUEST_WAIT_MS = 500 // 500ms const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours +/** + * A page of feed items, or `null` when the source could not produce one. Only + * sources that deliberately swallow their own errors return `null`; the rest + * reject so the error reaches the caller. + */ +type MergeFeedPage = { + cursor?: string + feed: AppBskyFeedDefs.FeedViewPost[] +} | null + export class MergeFeedAPI implements FeedAPI { userInterests?: string - agent: AtpAgent + client: Client params: FeedParams feedTuners: FeedTunerFn[] following: MergeFeedSource_Following @@ -34,29 +43,29 @@ export class MergeFeedAPI implements FeedAPI { sampleCursor = 0 constructor({ - agent, + client, feedParams, feedTuners, userInterests, }: { - agent: AtpAgent + client: Client feedParams: FeedParams feedTuners: FeedTunerFn[] userInterests?: string }) { - this.agent = agent + this.client = client this.params = feedParams this.feedTuners = feedTuners this.userInterests = userInterests this.following = new MergeFeedSource_Following({ - agent: this.agent, + client: this.client, feedTuners: this.feedTuners, }) } reset() { this.following = new MergeFeedSource_Following({ - agent: this.agent, + client: this.client, feedTuners: this.feedTuners, }) this.customFeeds = [] @@ -68,7 +77,7 @@ export class MergeFeedAPI implements FeedAPI { this.params.mergeFeedSources.map( feedUri => new MergeFeedSource_Custom({ - agent: this.agent, + client: this.client, feedUri, feedTuners: this.feedTuners, userInterests: this.userInterests, @@ -81,10 +90,10 @@ export class MergeFeedAPI implements FeedAPI { } async peekLatest(): Promise { - const res = await this.agent.getTimeline({ + const data = await this.client.call(app.bsky.feed.getTimeline, { limit: 1, }) - return res.data.feed[0] + return data.feed[0] } async fetch({ @@ -175,7 +184,7 @@ export class MergeFeedAPI implements FeedAPI { } class MergeFeedSource { - agent: AtpAgent + client: Client feedTuners: FeedTunerFn[] sourceInfo: ReasonFeedSource | undefined cursor: string | undefined = undefined @@ -183,13 +192,13 @@ class MergeFeedSource { hasMore = true constructor({ - agent, + client, feedTuners, }: { - agent: AtpAgent + client: Client feedTuners: FeedTunerFn[] }) { - this.agent = agent + this.client = client this.feedTuners = feedTuners } @@ -210,11 +219,11 @@ class MergeFeedSource { } _fetchNextInner = bundleAsync(async (n: number) => { - const res = await this._getFeed(this.cursor, n) - if (res.success) { - this.cursor = res.data.cursor - if (res.data.feed.length) { - this.queue = this.queue.concat(res.data.feed) + const page = await this._getFeed(this.cursor, n) + if (page) { + this.cursor = page.cursor + if (page.feed.length) { + this.queue = this.queue.concat(page.feed) } else { this.hasMore = false } @@ -226,7 +235,7 @@ class MergeFeedSource { protected _getFeed( _cursor: string | undefined, _limit: number, - ): Promise { + ): Promise { throw new Error('Must be overridden') } } @@ -238,42 +247,49 @@ class MergeFeedSource_Following extends MergeFeedSource { return this._fetchNextInner(n) } + /* + * No error handling: a failed timeline read rejects, which is what the agent + * did too, so the error still reaches `MergeFeedAPI.fetch` and the query. + */ protected async _getFeed( cursor: string | undefined, limit: number, - ): Promise { - const res = await this.agent.getTimeline({cursor, limit}) + ): Promise { + const data = await this.client.call(app.bsky.feed.getTimeline, { + cursor, + limit, + }) // run the tuner pre-emptively to ensure better mixing - const slices = this.tuner.tune(res.data.feed, { + const slices = this.tuner.tune(data.feed, { dryRun: false, }) - res.data.feed = slices.map(slice => slice._feedPost) - return res + return { + cursor: data.cursor, + feed: slices.map(slice => slice._feedPost), + } } } class MergeFeedSource_Custom extends MergeFeedSource { - agent: AtpAgent minDate: Date feedUri: string userInterests?: string constructor({ - agent, + client, feedUri, feedTuners, userInterests, }: { - agent: AtpAgent + client: Client feedUri: string feedTuners: FeedTunerFn[] userInterests?: string }) { super({ - agent, + client, feedTuners, }) - this.agent = agent this.feedUri = feedUri this.userInterests = userInterests this.sourceInfo = { @@ -287,15 +303,16 @@ class MergeFeedSource_Custom extends MergeFeedSource { protected async _getFeed( cursor: string | undefined, limit: number, - ): Promise { + ): Promise { try { const contentLangs = getContentLanguages().join(',') const isBlueskyOwned = isBlueskyOwnedFeed(this.feedUri) - const res = await this.agent.app.bsky.feed.getFeed( + const data = await this.client.call( + app.bsky.feed.getFeed, { cursor, limit, - feed: this.feedUri, + feed: this.feedUri as AtUriString, }, { headers: { @@ -310,22 +327,24 @@ class MergeFeedSource_Custom extends MergeFeedSource { // some custom feeds fail to enforce the pagination limit // so we manually truncate here // -prf - if (limit && res.data.feed.length > limit) { - res.data.feed = res.data.feed.slice(0, limit) - } + let feed: AppBskyFeedDefs.FeedViewPost[] = + limit && data.feed.length > limit + ? data.feed.slice(0, limit) + : data.feed // filter out older posts - res.data.feed = res.data.feed.filter( - post => new Date(post.post.indexedAt) > this.minDate, - ) + feed = feed.filter(post => new Date(post.post.indexedAt) > this.minDate) // attach source info - for (const post of res.data.feed) { + for (const post of feed) { // @ts-ignore post.__source = this.sourceInfo } - return res + return { + cursor: data.cursor, + feed, + } } catch { // dont bubble custom-feed errors - return {success: false, headers: {}, data: {feed: []}} + return null } } }