388 lines
9.3 KiB
TypeScript
388 lines
9.3 KiB
TypeScript
import {makeAutoObservable, runInAction} from 'mobx'
|
|
import {
|
|
AppBskyFeedGetTimeline as GetTimeline,
|
|
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
|
AppBskyFeedGetFeed as GetCustomFeed,
|
|
AppBskyFeedGetActorLikes as GetActorLikes,
|
|
} from '@atproto/api'
|
|
import AwaitLock from 'await-lock'
|
|
import {bundleAsync} from 'lib/async/bundle'
|
|
import {RootStoreModel} from '../root-store'
|
|
import {cleanError} from 'lib/strings/errors'
|
|
import {FeedTuner} from 'lib/api/feed-manip'
|
|
import {PostsFeedSliceModel} from './posts-slice'
|
|
import {track} from 'lib/analytics/analytics'
|
|
import {FeedViewPostsSlice} from 'lib/api/feed-manip'
|
|
|
|
import {FeedAPI, FeedAPIResponse} from 'lib/api/feed/types'
|
|
import {FollowingFeedAPI} from 'lib/api/feed/following'
|
|
import {AuthorFeedAPI} from 'lib/api/feed/author'
|
|
import {LikesFeedAPI} from 'lib/api/feed/likes'
|
|
import {CustomFeedAPI} from 'lib/api/feed/custom'
|
|
import {MergeFeedAPI} from 'lib/api/feed/merge'
|
|
|
|
const PAGE_SIZE = 30
|
|
|
|
export type PostsFeedModelError = {
|
|
type: 'network' | 'upstream'
|
|
message: string
|
|
}
|
|
|
|
type Options = {
|
|
/**
|
|
* Formats the feed in a flat array with no threading of replies, just
|
|
* top-level posts.
|
|
*/
|
|
isSimpleFeed?: boolean
|
|
}
|
|
|
|
type QueryParams =
|
|
| GetTimeline.QueryParams
|
|
| GetAuthorFeed.QueryParams
|
|
| GetActorLikes.QueryParams
|
|
| GetCustomFeed.QueryParams
|
|
|
|
export class PostsFeedModel {
|
|
// state
|
|
isLoading = false
|
|
isRefreshing = false
|
|
hasNewLatest = false
|
|
hasLoaded = false
|
|
isBlocking = false
|
|
isBlockedBy = false
|
|
error = ''
|
|
loadMoreError = ''
|
|
cleanError?: PostsFeedModelError = undefined
|
|
params: QueryParams
|
|
hasMore = true
|
|
pollCursor: string | undefined
|
|
api: FeedAPI
|
|
tuner = new FeedTuner()
|
|
pageSize = PAGE_SIZE
|
|
options: Options = {}
|
|
|
|
// used to linearize async modifications to state
|
|
lock = new AwaitLock()
|
|
|
|
// used to track if a feed is coming up empty
|
|
emptyFetches = 0
|
|
|
|
// data
|
|
slices: PostsFeedSliceModel[] = []
|
|
|
|
constructor(
|
|
public rootStore: RootStoreModel,
|
|
public feedType: 'home' | 'following' | 'author' | 'custom' | 'likes',
|
|
params: QueryParams,
|
|
options?: Options,
|
|
) {
|
|
makeAutoObservable(
|
|
this,
|
|
{
|
|
rootStore: false,
|
|
params: false,
|
|
},
|
|
{autoBind: true},
|
|
)
|
|
this.params = params
|
|
this.options = options || {}
|
|
if (feedType === 'home') {
|
|
this.api = new MergeFeedAPI(rootStore)
|
|
} else if (feedType === 'following') {
|
|
this.api = new FollowingFeedAPI(rootStore)
|
|
} else if (feedType === 'author') {
|
|
this.api = new AuthorFeedAPI(
|
|
rootStore,
|
|
params as GetAuthorFeed.QueryParams,
|
|
)
|
|
} else if (feedType === 'likes') {
|
|
this.api = new LikesFeedAPI(
|
|
rootStore,
|
|
params as GetActorLikes.QueryParams,
|
|
)
|
|
} else if (feedType === 'custom') {
|
|
this.api = new CustomFeedAPI(
|
|
rootStore,
|
|
params as GetCustomFeed.QueryParams,
|
|
)
|
|
} else {
|
|
this.api = new FollowingFeedAPI(rootStore)
|
|
}
|
|
}
|
|
|
|
get hasContent() {
|
|
return this.slices.length !== 0
|
|
}
|
|
|
|
get hasError() {
|
|
return this.error !== ''
|
|
}
|
|
|
|
get isEmpty() {
|
|
return this.hasLoaded && !this.hasContent
|
|
}
|
|
|
|
get isLoadingMore() {
|
|
return this.isLoading && !this.isRefreshing
|
|
}
|
|
|
|
setHasNewLatest(v: boolean) {
|
|
this.hasNewLatest = v
|
|
}
|
|
|
|
// public api
|
|
// =
|
|
|
|
/**
|
|
* Nuke all data
|
|
*/
|
|
clear() {
|
|
this.rootStore.log.debug('FeedModel:clear')
|
|
this.isLoading = false
|
|
this.isRefreshing = false
|
|
this.hasNewLatest = false
|
|
this.hasLoaded = false
|
|
this.error = ''
|
|
this.cleanError = undefined
|
|
this.hasMore = true
|
|
this.pollCursor = undefined
|
|
this.slices = []
|
|
this.tuner.reset()
|
|
}
|
|
|
|
/**
|
|
* Load for first render
|
|
*/
|
|
setup = bundleAsync(async (isRefreshing: boolean = false) => {
|
|
this.rootStore.log.debug('FeedModel:setup', {isRefreshing})
|
|
if (isRefreshing) {
|
|
this.isRefreshing = true // set optimistically for UI
|
|
}
|
|
await this.lock.acquireAsync()
|
|
try {
|
|
this.setHasNewLatest(false)
|
|
this.api.reset()
|
|
this.tuner.reset()
|
|
this._xLoading(isRefreshing)
|
|
try {
|
|
const res = await this.api.fetchNext({limit: this.pageSize})
|
|
await this._replaceAll(res)
|
|
this._xIdle()
|
|
} catch (e: any) {
|
|
this._xIdle(e)
|
|
}
|
|
} finally {
|
|
this.lock.release()
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Register any event listeners. Returns a cleanup function.
|
|
*/
|
|
registerListeners() {
|
|
const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this))
|
|
return () => sub.remove()
|
|
}
|
|
|
|
/**
|
|
* Reset and load
|
|
*/
|
|
async refresh() {
|
|
await this.setup(true)
|
|
}
|
|
|
|
/**
|
|
* Load more posts to the end of the feed
|
|
*/
|
|
loadMore = bundleAsync(async () => {
|
|
await this.lock.acquireAsync()
|
|
try {
|
|
if (!this.hasMore || this.hasError) {
|
|
return
|
|
}
|
|
this._xLoading()
|
|
try {
|
|
const res = await this.api.fetchNext({
|
|
limit: this.pageSize,
|
|
})
|
|
await this._appendAll(res)
|
|
this._xIdle()
|
|
} catch (e: any) {
|
|
this._xIdle(undefined, e)
|
|
runInAction(() => {
|
|
this.hasMore = false
|
|
})
|
|
}
|
|
} finally {
|
|
this.lock.release()
|
|
if (this.feedType === 'custom') {
|
|
track('CustomFeed:LoadMore')
|
|
}
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Attempt to load more again after a failure
|
|
*/
|
|
async retryLoadMore() {
|
|
this.loadMoreError = ''
|
|
this.hasMore = true
|
|
return this.loadMore()
|
|
}
|
|
|
|
/**
|
|
* Check if new posts are available
|
|
*/
|
|
async checkForLatest() {
|
|
if (!this.hasLoaded || this.hasNewLatest || this.isLoading) {
|
|
return
|
|
}
|
|
const post = await this.api.peekLatest()
|
|
if (post) {
|
|
const slices = this.tuner.tune(
|
|
[post],
|
|
this.rootStore.preferences.getFeedTuners(this.feedType),
|
|
{
|
|
dryRun: true,
|
|
maintainOrder: true,
|
|
},
|
|
)
|
|
if (slices[0]) {
|
|
const sliceModel = new PostsFeedSliceModel(this.rootStore, slices[0])
|
|
if (sliceModel.moderation.content.filter) {
|
|
return
|
|
}
|
|
this.setHasNewLatest(sliceModel.uri !== this.pollCursor)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the UI after the user has created a post
|
|
*/
|
|
onPostCreated() {
|
|
if (!this.slices.length) {
|
|
return this.refresh()
|
|
} else {
|
|
this.setHasNewLatest(true)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes posts from the feed upon deletion.
|
|
*/
|
|
onPostDeleted(uri: string) {
|
|
let i
|
|
do {
|
|
i = this.slices.findIndex(slice => slice.containsUri(uri))
|
|
if (i !== -1) {
|
|
this.slices.splice(i, 1)
|
|
}
|
|
} while (i !== -1)
|
|
}
|
|
|
|
// state transitions
|
|
// =
|
|
|
|
_xLoading(isRefreshing = false) {
|
|
this.isLoading = true
|
|
this.isRefreshing = isRefreshing
|
|
this.error = ''
|
|
this.cleanError = undefined
|
|
}
|
|
|
|
_xIdle(error?: any, loadMoreError?: any) {
|
|
this.isLoading = false
|
|
this.isRefreshing = false
|
|
this.hasLoaded = true
|
|
this.isBlocking = error instanceof GetAuthorFeed.BlockedActorError
|
|
this.isBlockedBy = error instanceof GetAuthorFeed.BlockedByActorError
|
|
this.error = cleanError(error)
|
|
this.loadMoreError = cleanError(loadMoreError) // TODO
|
|
this.cleanError = this.error ? this._cleanError(this.error) : undefined
|
|
if (error) {
|
|
this.rootStore.log.error('Posts feed request failed', error)
|
|
}
|
|
if (loadMoreError) {
|
|
this.rootStore.log.error(
|
|
'Posts feed load-more request failed',
|
|
loadMoreError,
|
|
)
|
|
}
|
|
}
|
|
|
|
// helper functions
|
|
// =
|
|
|
|
async _replaceAll(res: FeedAPIResponse) {
|
|
this.pollCursor = res.feed[0]?.post.uri
|
|
return this._appendAll(res, true)
|
|
}
|
|
|
|
async _appendAll(res: FeedAPIResponse, replace = false) {
|
|
this.hasMore = !!res.cursor && res.feed.length > 0
|
|
if (replace) {
|
|
this.emptyFetches = 0
|
|
}
|
|
|
|
this.rootStore.me.follows.hydrateMany(
|
|
res.feed.map(item => item.post.author),
|
|
)
|
|
for (const item of res.feed) {
|
|
this.rootStore.posts.fromFeedItem(item)
|
|
}
|
|
|
|
const slices = this.options.isSimpleFeed
|
|
? res.feed.map(item => new FeedViewPostsSlice([item]))
|
|
: this.tuner.tune(
|
|
res.feed,
|
|
this.rootStore.preferences.getFeedTuners(this.feedType),
|
|
)
|
|
|
|
const toAppend: PostsFeedSliceModel[] = []
|
|
for (const slice of slices) {
|
|
const sliceModel = new PostsFeedSliceModel(this.rootStore, slice)
|
|
const dupTest = (item: PostsFeedSliceModel) =>
|
|
item._reactKey === sliceModel._reactKey
|
|
// sanity check
|
|
// if a duplicate _reactKey passes through, the UI breaks hard
|
|
if (!replace) {
|
|
if (this.slices.find(dupTest) || toAppend.find(dupTest)) {
|
|
continue
|
|
}
|
|
}
|
|
toAppend.push(sliceModel)
|
|
}
|
|
runInAction(() => {
|
|
if (replace) {
|
|
this.slices = toAppend
|
|
} else {
|
|
this.slices = this.slices.concat(toAppend)
|
|
}
|
|
if (toAppend.length === 0) {
|
|
this.emptyFetches++
|
|
if (this.emptyFetches >= 10) {
|
|
this.hasMore = false
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
_cleanError(error: string) {
|
|
const e: PostsFeedModelError = {
|
|
type: 'network',
|
|
message: `Hmmm, the feed appears to be offline. Please check your internet connection and try again.`,
|
|
}
|
|
|
|
if (
|
|
error.includes('could not find feed') ||
|
|
error.includes('server appears to be experiencing issues')
|
|
) {
|
|
e.type = 'upstream'
|
|
e.message = `Hmmm, we're having trouble finding this feed. It may have been deleted.`
|
|
}
|
|
|
|
return e
|
|
}
|
|
}
|