435 lines
11 KiB
TypeScript
435 lines
11 KiB
TypeScript
import {makeAutoObservable, runInAction} from 'mobx'
|
|
import {
|
|
AppBskyFeedGetTimeline as GetTimeline,
|
|
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
|
AppBskyFeedGetFeed as GetCustomFeed,
|
|
} 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, FeedViewPostsSlice} from 'lib/api/feed-manip'
|
|
import {PostsFeedSliceModel} from './post'
|
|
|
|
const PAGE_SIZE = 30
|
|
let _idCounter = 0
|
|
|
|
export class PostsFeedModel {
|
|
// state
|
|
isLoading = false
|
|
isRefreshing = false
|
|
hasNewLatest = false
|
|
hasLoaded = false
|
|
isBlocking = false
|
|
isBlockedBy = false
|
|
error = ''
|
|
loadMoreError = ''
|
|
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams
|
|
hasMore = true
|
|
loadMoreCursor: string | undefined
|
|
pollCursor: string | undefined
|
|
tuner = new FeedTuner()
|
|
pageSize = PAGE_SIZE
|
|
|
|
// used to linearize async modifications to state
|
|
lock = new AwaitLock()
|
|
|
|
// used to track if what's hot is coming up empty
|
|
emptyFetches = 0
|
|
|
|
// data
|
|
slices: PostsFeedSliceModel[] = []
|
|
|
|
constructor(
|
|
public rootStore: RootStoreModel,
|
|
public feedType: 'home' | 'author' | 'custom',
|
|
params:
|
|
| GetTimeline.QueryParams
|
|
| GetAuthorFeed.QueryParams
|
|
| GetCustomFeed.QueryParams,
|
|
) {
|
|
makeAutoObservable(
|
|
this,
|
|
{
|
|
rootStore: false,
|
|
params: false,
|
|
loadMoreCursor: false,
|
|
},
|
|
{autoBind: true},
|
|
)
|
|
this.params = params
|
|
}
|
|
|
|
get hasContent() {
|
|
return this.slices.length !== 0
|
|
}
|
|
|
|
get hasError() {
|
|
return this.error !== ''
|
|
}
|
|
|
|
get isEmpty() {
|
|
return this.hasLoaded && !this.hasContent
|
|
}
|
|
|
|
get nonReplyFeed() {
|
|
if (this.feedType === 'author') {
|
|
return this.slices.filter(slice => {
|
|
const params = this.params as GetAuthorFeed.QueryParams
|
|
const item = slice.rootItem
|
|
const isRepost =
|
|
item?.reasonRepost?.by?.handle === params.actor ||
|
|
item?.reasonRepost?.by?.did === params.actor
|
|
const allow =
|
|
!item.postRecord?.reply || // not a reply
|
|
isRepost // but allow if it's a repost
|
|
return allow
|
|
})
|
|
} else {
|
|
return this.slices
|
|
}
|
|
}
|
|
|
|
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.hasMore = true
|
|
this.loadMoreCursor = undefined
|
|
this.pollCursor = undefined
|
|
this.slices = []
|
|
this.tuner.reset()
|
|
}
|
|
|
|
get feedTuners() {
|
|
const areRepliesEnabled = this.rootStore.preferences.homeFeedRepliesEnabled
|
|
const repliesThreshold = this.rootStore.preferences.homeFeedRepliesThreshold
|
|
const areRepostsEnabled = this.rootStore.preferences.homeFeedRepostsEnabled
|
|
const areQuotePostsEnabled =
|
|
this.rootStore.preferences.homeFeedQuotePostsEnabled
|
|
|
|
if (this.feedType === 'custom') {
|
|
return [
|
|
FeedTuner.dedupReposts,
|
|
FeedTuner.preferredLangOnly(
|
|
this.rootStore.preferences.contentLanguages,
|
|
),
|
|
]
|
|
}
|
|
if (this.feedType === 'home') {
|
|
return [
|
|
areRepostsEnabled && FeedTuner.dedupReposts,
|
|
!areRepostsEnabled && FeedTuner.removeReposts,
|
|
areRepliesEnabled && FeedTuner.likedRepliesOnly({repliesThreshold}),
|
|
!areRepliesEnabled && FeedTuner.removeReplies,
|
|
!areQuotePostsEnabled && FeedTuner.removeQuotePosts,
|
|
].filter(Boolean)
|
|
}
|
|
return []
|
|
}
|
|
|
|
/**
|
|
* 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.tuner.reset()
|
|
this._xLoading(isRefreshing)
|
|
try {
|
|
const res = await this._getFeed({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._getFeed({
|
|
cursor: this.loadMoreCursor,
|
|
limit: this.pageSize,
|
|
})
|
|
await this._appendAll(res)
|
|
this._xIdle()
|
|
} catch (e: any) {
|
|
this._xIdle(undefined, e)
|
|
runInAction(() => {
|
|
this.hasMore = false
|
|
})
|
|
}
|
|
} finally {
|
|
this.lock.release()
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Attempt to load more again after a failure
|
|
*/
|
|
async retryLoadMore() {
|
|
this.loadMoreError = ''
|
|
this.hasMore = true
|
|
return this.loadMore()
|
|
}
|
|
|
|
/**
|
|
* Update content in-place
|
|
*/
|
|
update = bundleAsync(async () => {
|
|
await this.lock.acquireAsync()
|
|
try {
|
|
if (!this.slices.length) {
|
|
return
|
|
}
|
|
this._xLoading()
|
|
let numToFetch = this.slices.length
|
|
let cursor
|
|
try {
|
|
do {
|
|
const res: GetTimeline.Response = await this._getFeed({
|
|
cursor,
|
|
limit: Math.min(numToFetch, 100),
|
|
})
|
|
if (res.data.feed.length === 0) {
|
|
break // sanity check
|
|
}
|
|
this._updateAll(res)
|
|
numToFetch -= res.data.feed.length
|
|
cursor = res.data.cursor
|
|
} while (cursor && numToFetch > 0)
|
|
this._xIdle()
|
|
} catch (e: any) {
|
|
this._xIdle() // don't bubble the error to the user
|
|
this.rootStore.log.error('FeedView: Failed to update', {
|
|
params: this.params,
|
|
e,
|
|
})
|
|
}
|
|
} finally {
|
|
this.lock.release()
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Check if new posts are available
|
|
*/
|
|
async checkForLatest() {
|
|
if (this.hasNewLatest) {
|
|
return
|
|
}
|
|
const res = await this._getFeed({limit: this.pageSize})
|
|
const tuner = new FeedTuner()
|
|
const slices = tuner.tune(res.data.feed, this.feedTuners)
|
|
this.setHasNewLatest(slices[0]?.uri !== this.slices[0]?.uri)
|
|
}
|
|
|
|
/**
|
|
* Fetches the given post and adds it to the top
|
|
* Used by the composer to add their new posts
|
|
*/
|
|
async addPostToTop(uri: string) {
|
|
if (!this.slices.length) {
|
|
return this.refresh()
|
|
}
|
|
try {
|
|
const res = await this.rootStore.agent.app.bsky.feed.getPosts({
|
|
uris: [uri],
|
|
})
|
|
const toPrepend = new PostsFeedSliceModel(
|
|
this.rootStore,
|
|
uri,
|
|
new FeedViewPostsSlice(res.data.posts.map(post => ({post}))),
|
|
)
|
|
runInAction(() => {
|
|
this.slices = [toPrepend].concat(this.slices)
|
|
})
|
|
} catch (e) {
|
|
this.rootStore.log.error('Failed to load post to prepend', {e})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 = ''
|
|
}
|
|
|
|
_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)
|
|
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: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
|
|
) {
|
|
this.pollCursor = res.data.feed[0]?.post.uri
|
|
return this._appendAll(res, true)
|
|
}
|
|
|
|
async _appendAll(
|
|
res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
|
|
replace = false,
|
|
) {
|
|
this.loadMoreCursor = res.data.cursor
|
|
this.hasMore = !!this.loadMoreCursor
|
|
if (replace) {
|
|
this.emptyFetches = 0
|
|
}
|
|
|
|
this.rootStore.me.follows.hydrateProfiles(
|
|
res.data.feed.map(item => item.post.author),
|
|
)
|
|
|
|
const slices = this.tuner.tune(res.data.feed, this.feedTuners)
|
|
|
|
const toAppend: PostsFeedSliceModel[] = []
|
|
for (const slice of slices) {
|
|
const sliceModel = new PostsFeedSliceModel(
|
|
this.rootStore,
|
|
`item-${_idCounter++}`,
|
|
slice,
|
|
)
|
|
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
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
_updateAll(
|
|
res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
|
|
) {
|
|
for (const item of res.data.feed) {
|
|
const existingSlice = this.slices.find(slice =>
|
|
slice.containsUri(item.post.uri),
|
|
)
|
|
if (existingSlice) {
|
|
const existingItem = existingSlice.items.find(
|
|
item2 => item2.post.uri === item.post.uri,
|
|
)
|
|
if (existingItem) {
|
|
existingItem.copyMetrics(item)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
protected async _getFeed(
|
|
params:
|
|
| GetTimeline.QueryParams
|
|
| GetAuthorFeed.QueryParams
|
|
| GetCustomFeed.QueryParams,
|
|
): Promise<
|
|
GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response
|
|
> {
|
|
params = Object.assign({}, this.params, params)
|
|
if (this.feedType === 'home') {
|
|
return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams)
|
|
} else if (this.feedType === 'custom') {
|
|
const res = await this.rootStore.agent.app.bsky.feed.getFeed(
|
|
params as GetCustomFeed.QueryParams,
|
|
)
|
|
// NOTE
|
|
// some custom feeds fail to enforce the pagination limit
|
|
// so we manually truncate here
|
|
// -prf
|
|
if (params.limit && res.data.feed.length > params.limit) {
|
|
res.data.feed = res.data.feed.slice(0, params.limit)
|
|
}
|
|
return res
|
|
} else {
|
|
return this.rootStore.agent.getAuthorFeed(
|
|
params as GetAuthorFeed.QueryParams,
|
|
)
|
|
}
|
|
}
|
|
}
|