bsky-app/src/state/models/feeds/posts.ts
2023-04-06 16:28:45 -05:00

667 lines
16 KiB
TypeScript

import {makeAutoObservable, runInAction} from 'mobx'
import {
AppBskyFeedGetTimeline as GetTimeline,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
RichText,
jsonToLex,
} from '@atproto/api'
import AwaitLock from 'await-lock'
import {bundleAsync} from 'lib/async/bundle'
import sampleSize from 'lodash.samplesize'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
import {SUGGESTED_FOLLOWS} from 'lib/constants'
import {
getCombinedCursors,
getMultipleAuthorsPosts,
mergePosts,
} from 'lib/api/build-suggested-posts'
import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
type ReasonRepost = AppBskyFeedDefs.ReasonRepost
type PostView = AppBskyFeedDefs.PostView
const PAGE_SIZE = 30
let _idCounter = 0
export class PostsFeedItemModel {
// ui state
_reactKey: string = ''
// data
post: PostView
postRecord?: AppBskyFeedPost.Record
reply?: FeedViewPost['reply']
reason?: FeedViewPost['reason']
richText?: RichText
constructor(
public rootStore: RootStoreModel,
reactKey: string,
v: FeedViewPost,
) {
this._reactKey = reactKey
this.post = v.post
if (AppBskyFeedPost.isRecord(this.post.record)) {
const valid = AppBskyFeedPost.validateRecord(this.post.record)
if (valid.success) {
this.postRecord = this.post.record
this.richText = new RichText(this.postRecord, {cleanNewlines: true})
} else {
this.postRecord = undefined
this.richText = undefined
rootStore.log.warn(
'Received an invalid app.bsky.feed.post record',
valid.error,
)
}
} else {
this.postRecord = undefined
this.richText = undefined
rootStore.log.warn(
'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type',
this.post.record,
)
}
this.reply = v.reply
this.reason = v.reason
makeAutoObservable(this, {rootStore: false})
}
copy(v: FeedViewPost) {
this.post = v.post
this.reply = v.reply
this.reason = v.reason
}
copyMetrics(v: FeedViewPost) {
this.post.replyCount = v.post.replyCount
this.post.repostCount = v.post.repostCount
this.post.likeCount = v.post.likeCount
this.post.viewer = v.post.viewer
}
get reasonRepost(): ReasonRepost | undefined {
if (this.reason?.$type === 'app.bsky.feed.defs#reasonRepost') {
return this.reason as ReasonRepost
}
}
async toggleLike() {
if (this.post.viewer?.like) {
await this.rootStore.agent.deleteLike(this.post.viewer.like)
runInAction(() => {
this.post.likeCount = this.post.likeCount || 0
this.post.viewer = this.post.viewer || {}
this.post.likeCount--
this.post.viewer.like = undefined
})
} else {
const res = await this.rootStore.agent.like(this.post.uri, this.post.cid)
runInAction(() => {
this.post.likeCount = this.post.likeCount || 0
this.post.viewer = this.post.viewer || {}
this.post.likeCount++
this.post.viewer.like = res.uri
})
}
}
async toggleRepost() {
if (this.post.viewer?.repost) {
await this.rootStore.agent.deleteRepost(this.post.viewer.repost)
runInAction(() => {
this.post.repostCount = this.post.repostCount || 0
this.post.viewer = this.post.viewer || {}
this.post.repostCount--
this.post.viewer.repost = undefined
})
} else {
const res = await this.rootStore.agent.repost(
this.post.uri,
this.post.cid,
)
runInAction(() => {
this.post.repostCount = this.post.repostCount || 0
this.post.viewer = this.post.viewer || {}
this.post.repostCount++
this.post.viewer.repost = res.uri
})
}
}
async delete() {
await this.rootStore.agent.deletePost(this.post.uri)
this.rootStore.emitPostDeleted(this.post.uri)
}
}
export class PostsFeedSliceModel {
// ui state
_reactKey: string = ''
// data
items: PostsFeedItemModel[] = []
constructor(
public rootStore: RootStoreModel,
reactKey: string,
slice: FeedViewPostsSlice,
) {
this._reactKey = reactKey
for (const item of slice.items) {
this.items.push(
new PostsFeedItemModel(rootStore, `item-${_idCounter++}`, item),
)
}
makeAutoObservable(this, {rootStore: false})
}
get uri() {
if (this.isReply) {
return this.items[1].post.uri
}
return this.items[0].post.uri
}
get isThread() {
return (
this.items.length > 1 &&
this.items.every(
item => item.post.author.did === this.items[0].post.author.did,
)
)
}
get isReply() {
return this.items.length > 1 && !this.isThread
}
get rootItem() {
if (this.isReply) {
return this.items[1]
}
return this.items[0]
}
containsUri(uri: string) {
return !!this.items.find(item => item.post.uri === uri)
}
isThreadParentAt(i: number) {
if (this.items.length === 1) {
return false
}
return i < this.items.length - 1
}
isThreadChildAt(i: number) {
if (this.items.length === 1) {
return false
}
return i > 0
}
}
export class PostsFeedModel {
// state
isLoading = false
isRefreshing = false
hasNewLatest = false
hasLoaded = false
error = ''
loadMoreError = ''
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams
hasMore = true
loadMoreCursor: string | undefined
pollCursor: string | undefined
tuner = new FeedTuner()
// used to linearize async modifications to state
lock = new AwaitLock()
// data
slices: PostsFeedSliceModel[] = []
nextSlices: PostsFeedSliceModel[] = []
constructor(
public rootStore: RootStoreModel,
public feedType: 'home' | 'author' | 'suggested' | 'goodstuff',
params: GetTimeline.QueryParams | GetAuthorFeed.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
return (
!item.reply || // not a reply
isRepost || // but allow if it's a repost
(slice.isThread && // or a thread by the user
item.reply?.root.author.did === item.post.author.did)
)
})
} 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.nextSlices = []
this.tuner.reset()
}
switchFeedType(feedType: 'home' | 'suggested') {
if (this.feedType === feedType) {
return
}
this.feedType = feedType
return this.setup()
}
get feedTuners() {
if (this.feedType === 'goodstuff') {
return [
FeedTuner.dedupReposts,
FeedTuner.likedRepliesOnly,
FeedTuner.preferredLangOnly(
this.rootStore.preferences.contentLanguages,
),
]
}
if (this.feedType === 'home') {
return [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly]
}
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: PAGE_SIZE})
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: PAGE_SIZE,
})
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({autoPrepend}: {autoPrepend?: boolean} = {}) {
if (this.hasNewLatest || this.feedType === 'suggested') {
return
}
const res = await this._getFeed({limit: PAGE_SIZE})
const tuner = new FeedTuner()
const nextSlices = tuner.tune(res.data.feed, this.feedTuners)
if (nextSlices[0]?.uri !== this.slices[0]?.uri) {
const nextSlicesModels = nextSlices.map(
slice =>
new PostsFeedSliceModel(
this.rootStore,
`item-${_idCounter++}`,
slice,
),
)
if (autoPrepend) {
runInAction(() => {
this.slices = nextSlicesModels.concat(
this.slices.filter(slice1 =>
nextSlicesModels.find(slice2 => slice1.uri === slice2.uri),
),
)
this.setHasNewLatest(false)
})
} else {
runInAction(() => {
this.nextSlices = nextSlicesModels
})
this.setHasNewLatest(true)
}
} else {
this.setHasNewLatest(false)
}
}
/**
* Sets the current slices to the "next slices" loaded by checkForLatest
*/
resetToLatest() {
if (this.nextSlices.length) {
this.slices = this.nextSlices
}
this.setHasNewLatest(false)
}
/**
* 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.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) {
this.pollCursor = res.data.feed[0]?.post.uri
return this._appendAll(res, true)
}
async _appendAll(
res: GetTimeline.Response | GetAuthorFeed.Response,
replace = false,
) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
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)
}
})
}
_updateAll(res: GetTimeline.Response | GetAuthorFeed.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 = {},
): Promise<GetTimeline.Response | GetAuthorFeed.Response> {
params = Object.assign({}, this.params, params)
if (this.feedType === 'suggested') {
const responses = await getMultipleAuthorsPosts(
this.rootStore,
sampleSize(SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)), 20),
params.cursor,
20,
)
const combinedCursor = getCombinedCursors(responses)
const finalData = mergePosts(responses, {bestOfOnly: true})
const lastHeaders = responses[responses.length - 1].headers
return {
success: true,
data: {
feed: finalData,
cursor: combinedCursor,
},
headers: lastHeaders,
}
} else if (this.feedType === 'home') {
return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams)
} else if (this.feedType === 'goodstuff') {
const res = await getGoodStuff(
this.rootStore.session.currentSession?.accessJwt || '',
params as GetTimeline.QueryParams,
)
res.data.feed = (res.data.feed || []).filter(
item => !item.post.author.viewer?.muted,
)
return res
} else {
return this.rootStore.agent.getAuthorFeed(
params as GetAuthorFeed.QueryParams,
)
}
}
}
// HACK
// temporary off-spec route to get the good stuff
// -prf
async function getGoodStuff(
accessJwt: string,
params: GetTimeline.QueryParams,
): Promise<GetTimeline.Response> {
const controller = new AbortController()
const to = setTimeout(() => controller.abort(), 15e3)
const uri = new URL('https://bsky.social/xrpc/app.bsky.unspecced.getPopular')
let k: keyof GetTimeline.QueryParams
for (k in params) {
if (typeof params[k] !== 'undefined') {
uri.searchParams.set(k, String(params[k]))
}
}
const res = await fetch(String(uri), {
method: 'get',
headers: {
accept: 'application/json',
authorization: `Bearer ${accessJwt}`,
},
signal: controller.signal,
})
const resHeaders: Record<string, string> = {}
res.headers.forEach((value: string, key: string) => {
resHeaders[key] = value
})
let resBody = await res.json()
clearTimeout(to)
return {
success: res.status === 200,
headers: resHeaders,
data: jsonToLex(resBody),
}
}