Refactor feeds to use react-query (#1862)
* Update to react-query v5 * Introduce post-feed react query * Add feed refresh behaviors * Only fetch feeds of visible pages * Implement polling for latest on feeds * Add moderation filtering to slices * Handle block errors * Update feed error messages * Remove old models * Replace simple-feed option with disable-tuner option * Add missing useMemo * Implement the mergefeed and fixes to polling * Correctly handle failed load more state * Improve error and empty state behaviors * Clearer naming
This commit is contained in:
parent
51f04b9620
commit
c8c308e31e
31 changed files with 904 additions and 1081 deletions
|
@ -4,7 +4,7 @@ import {
|
|||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyEmbedRecord,
|
||||
} from '@atproto/api'
|
||||
import {FeedSourceInfo} from './feed/types'
|
||||
import {ReasonFeedSource} from './feed/types'
|
||||
import {isPostInLanguage} from '../../locale/helpers'
|
||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||
|
||||
|
@ -65,9 +65,9 @@ export class FeedViewPostsSlice {
|
|||
)
|
||||
}
|
||||
|
||||
get source(): FeedSourceInfo | undefined {
|
||||
get source(): ReasonFeedSource | undefined {
|
||||
return this.items.find(item => '__source' in item && !!item.__source)
|
||||
?.__source as FeedSourceInfo
|
||||
?.__source as ReasonFeedSource
|
||||
}
|
||||
|
||||
containsUri(uri: string) {
|
||||
|
@ -116,6 +116,17 @@ export class FeedViewPostsSlice {
|
|||
}
|
||||
}
|
||||
|
||||
export class NoopFeedTuner {
|
||||
reset() {}
|
||||
tune(
|
||||
feed: FeedViewPost[],
|
||||
_tunerFns: FeedTunerFn[] = [],
|
||||
_opts?: {dryRun: boolean; maintainOrder: boolean},
|
||||
): FeedViewPostsSlice[] {
|
||||
return feed.map(item => new FeedViewPostsSlice([item]))
|
||||
}
|
||||
}
|
||||
|
||||
export class FeedTuner {
|
||||
seenUris: Set<string> = new Set()
|
||||
|
||||
|
|
|
@ -1,38 +1,37 @@
|
|||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class AuthorFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public agent: BskyAgent,
|
||||
public params: GetAuthorFeed.QueryParams,
|
||||
) {}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.rootStore.agent.getAuthorFeed({
|
||||
const res = await this.agent.getAuthorFeed({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
const res = await this.rootStore.agent.getAuthorFeed({
|
||||
async fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.agent.getAuthorFeed({
|
||||
...this.params,
|
||||
cursor: this.cursor,
|
||||
cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
this.cursor = res.data.cursor
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
feed: this._filter(res.data.feed),
|
||||
|
|
|
@ -1,38 +1,37 @@
|
|||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetFeed as GetCustomFeed,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class CustomFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public agent: BskyAgent,
|
||||
public params: GetCustomFeed.QueryParams,
|
||||
) {}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
|
||||
const res = await this.agent.app.bsky.feed.getFeed({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
|
||||
async fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.agent.app.bsky.feed.getFeed({
|
||||
...this.params,
|
||||
cursor: this.cursor,
|
||||
cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
this.cursor = res.data.cursor
|
||||
// NOTE
|
||||
// some custom feeds fail to enforce the pagination limit
|
||||
// so we manually truncate here
|
||||
|
|
|
@ -1,30 +1,28 @@
|
|||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class FollowingFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
}
|
||||
constructor(public agent: BskyAgent) {}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.rootStore.agent.getTimeline({
|
||||
const res = await this.agent.getTimeline({
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
const res = await this.rootStore.agent.getTimeline({
|
||||
cursor: this.cursor,
|
||||
async fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.agent.getTimeline({
|
||||
cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
this.cursor = res.data.cursor
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
feed: res.data.feed,
|
||||
|
|
|
@ -1,38 +1,37 @@
|
|||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetActorLikes as GetActorLikes,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class LikesFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public agent: BskyAgent,
|
||||
public params: GetActorLikes.QueryParams,
|
||||
) {}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.rootStore.agent.getActorLikes({
|
||||
const res = await this.agent.getActorLikes({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
const res = await this.rootStore.agent.getActorLikes({
|
||||
async fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.agent.getActorLikes({
|
||||
...this.params,
|
||||
cursor: this.cursor,
|
||||
cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
this.cursor = res.data.cursor
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
feed: res.data.feed,
|
||||
|
|
|
@ -1,38 +1,37 @@
|
|||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetListFeed as GetListFeed,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class ListFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public agent: BskyAgent,
|
||||
public params: GetListFeed.QueryParams,
|
||||
) {}
|
||||
|
||||
reset() {
|
||||
this.cursor = undefined
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getListFeed({
|
||||
const res = await this.agent.app.bsky.feed.getListFeed({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getListFeed({
|
||||
async fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await this.agent.app.bsky.feed.getListFeed({
|
||||
...this.params,
|
||||
cursor: this.cursor,
|
||||
cursor,
|
||||
limit,
|
||||
})
|
||||
if (res.success) {
|
||||
this.cursor = res.data.cursor
|
||||
return {
|
||||
cursor: res.data.cursor,
|
||||
feed: res.data.feed,
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api'
|
||||
import {AppBskyFeedDefs, AppBskyFeedGetTimeline, BskyAgent} from '@atproto/api'
|
||||
import shuffle from 'lodash.shuffle'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {timeout} from 'lib/async/timeout'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {feedUriToHref} from 'lib/strings/url-helpers'
|
||||
import {FeedTuner} from '../feed-manip'
|
||||
import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types'
|
||||
import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
|
||||
import {FeedParams} from '#/state/queries/post-feed'
|
||||
import {FeedTunerFn} from '../feed-manip'
|
||||
|
||||
const REQUEST_WAIT_MS = 500 // 500ms
|
||||
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
|
||||
|
@ -17,28 +18,49 @@ export class MergeFeedAPI implements FeedAPI {
|
|||
itemCursor = 0
|
||||
sampleCursor = 0
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
this.following = new MergeFeedSource_Following(this.rootStore)
|
||||
constructor(
|
||||
public agent: BskyAgent,
|
||||
public params: FeedParams,
|
||||
public feedTuners: FeedTunerFn[],
|
||||
) {
|
||||
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.following = new MergeFeedSource_Following(this.rootStore)
|
||||
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
|
||||
this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
|
||||
this.feedCursor = 0
|
||||
this.itemCursor = 0
|
||||
this.sampleCursor = 0
|
||||
if (this.params.mergeFeedEnabled && this.params.mergeFeedSources) {
|
||||
this.customFeeds = shuffle(
|
||||
this.params.mergeFeedSources.map(
|
||||
feedUri =>
|
||||
new MergeFeedSource_Custom(this.agent, feedUri, this.feedTuners),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
this.customFeeds = []
|
||||
}
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await this.rootStore.agent.getTimeline({
|
||||
const res = await this.agent.getTimeline({
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
}
|
||||
|
||||
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
|
||||
// we capture here to ensure the data has loaded
|
||||
this._captureFeedsIfNeeded()
|
||||
async fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
if (!cursor) {
|
||||
this.reset()
|
||||
}
|
||||
|
||||
const promises = []
|
||||
|
||||
|
@ -76,7 +98,7 @@ export class MergeFeedAPI implements FeedAPI {
|
|||
}
|
||||
|
||||
return {
|
||||
cursor: posts.length ? 'fake' : undefined,
|
||||
cursor: posts.length ? String(this.itemCursor) : undefined,
|
||||
feed: posts,
|
||||
}
|
||||
}
|
||||
|
@ -107,28 +129,15 @@ export class MergeFeedAPI implements FeedAPI {
|
|||
// provide follow
|
||||
return this.following.take(1)
|
||||
}
|
||||
|
||||
_captureFeedsIfNeeded() {
|
||||
if (!this.rootStore.preferences.homeFeed.lab_mergeFeedEnabled) {
|
||||
return
|
||||
}
|
||||
if (this.customFeeds.length === 0) {
|
||||
this.customFeeds = shuffle(
|
||||
this.rootStore.preferences.savedFeeds.map(
|
||||
feedUri => new MergeFeedSource_Custom(this.rootStore, feedUri),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MergeFeedSource {
|
||||
sourceInfo: FeedSourceInfo | undefined
|
||||
sourceInfo: ReasonFeedSource | undefined
|
||||
cursor: string | undefined = undefined
|
||||
queue: AppBskyFeedDefs.FeedViewPost[] = []
|
||||
hasMore = true
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {}
|
||||
constructor(public agent: BskyAgent, public feedTuners: FeedTunerFn[]) {}
|
||||
|
||||
get numReady() {
|
||||
return this.queue.length
|
||||
|
@ -190,16 +199,12 @@ class MergeFeedSource_Following extends MergeFeedSource {
|
|||
cursor: string | undefined,
|
||||
limit: number,
|
||||
): Promise<AppBskyFeedGetTimeline.Response> {
|
||||
const res = await this.rootStore.agent.getTimeline({cursor, limit})
|
||||
const res = await this.agent.getTimeline({cursor, limit})
|
||||
// run the tuner pre-emptively to ensure better mixing
|
||||
const slices = this.tuner.tune(
|
||||
res.data.feed,
|
||||
this.rootStore.preferences.getFeedTuners('home'),
|
||||
{
|
||||
dryRun: false,
|
||||
maintainOrder: true,
|
||||
},
|
||||
)
|
||||
const slices = this.tuner.tune(res.data.feed, this.feedTuners, {
|
||||
dryRun: false,
|
||||
maintainOrder: true,
|
||||
})
|
||||
res.data.feed = slices.map(slice => slice.rootItem)
|
||||
return res
|
||||
}
|
||||
|
@ -208,14 +213,19 @@ class MergeFeedSource_Following extends MergeFeedSource {
|
|||
class MergeFeedSource_Custom extends MergeFeedSource {
|
||||
minDate: Date
|
||||
|
||||
constructor(public rootStore: RootStoreModel, public feedUri: string) {
|
||||
super(rootStore)
|
||||
constructor(
|
||||
public agent: BskyAgent,
|
||||
public feedUri: string,
|
||||
public feedTuners: FeedTunerFn[],
|
||||
) {
|
||||
super(agent, feedTuners)
|
||||
this.sourceInfo = {
|
||||
$type: 'reasonFeedSource',
|
||||
displayName: feedUri.split('/').pop() || '',
|
||||
uri: feedUriToHref(feedUri),
|
||||
}
|
||||
this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
|
||||
this.rootStore.agent.app.bsky.feed
|
||||
this.agent.app.bsky.feed
|
||||
.getFeedGenerator({
|
||||
feed: feedUri,
|
||||
})
|
||||
|
@ -234,7 +244,7 @@ class MergeFeedSource_Custom extends MergeFeedSource {
|
|||
limit: number,
|
||||
): Promise<AppBskyFeedGetTimeline.Response> {
|
||||
try {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
|
||||
const res = await this.agent.app.bsky.feed.getFeed({
|
||||
cursor,
|
||||
limit,
|
||||
feed: this.feedUri,
|
||||
|
|
|
@ -6,12 +6,27 @@ export interface FeedAPIResponse {
|
|||
}
|
||||
|
||||
export interface FeedAPI {
|
||||
reset(): void
|
||||
peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost>
|
||||
fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse>
|
||||
fetch({
|
||||
cursor,
|
||||
limit,
|
||||
}: {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse>
|
||||
}
|
||||
|
||||
export interface FeedSourceInfo {
|
||||
export interface ReasonFeedSource {
|
||||
$type: 'reasonFeedSource'
|
||||
uri: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export function isReasonFeedSource(v: unknown): v is ReasonFeedSource {
|
||||
return (
|
||||
!!v &&
|
||||
typeof v === 'object' &&
|
||||
'$type' in v &&
|
||||
v.$type === 'reasonFeedSource'
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue