* Rework feed polling to correctly detect when new content is available (close #344) * Tweak how the tuner works for consistency * Improve the feed-update behavior after posting * Load latest notifications when opening the tab
674 lines
16 KiB
TypeScript
674 lines
16 KiB
TypeScript
import {makeAutoObservable, runInAction} from 'mobx'
|
|
import {
|
|
AppBskyFeedGetTimeline as GetTimeline,
|
|
AppBskyFeedFeedViewPost,
|
|
AppBskyFeedPost,
|
|
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
|
} from '@atproto/api'
|
|
import AwaitLock from 'await-lock'
|
|
import {bundleAsync} from 'lib/async/bundle'
|
|
import sampleSize from 'lodash.samplesize'
|
|
type FeedViewPost = AppBskyFeedFeedViewPost.Main
|
|
type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost
|
|
type PostView = AppBskyFeedPost.View
|
|
import {AtUri} from '../../third-party/uri'
|
|
import {RootStoreModel} from './root-store'
|
|
import * as apilib from 'lib/api/index'
|
|
import {cleanError} from 'lib/strings/errors'
|
|
import {RichText} from 'lib/strings/rich-text'
|
|
import {SUGGESTED_FOLLOWS} from 'lib/constants'
|
|
import {
|
|
getCombinedCursors,
|
|
getMultipleAuthorsPosts,
|
|
mergePosts,
|
|
} from 'lib/api/build-suggested-posts'
|
|
|
|
import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
|
|
|
|
const PAGE_SIZE = 30
|
|
|
|
let _idCounter = 0
|
|
|
|
export class FeedItemModel {
|
|
// 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.text,
|
|
this.postRecord.entities,
|
|
{cleanNewlines: true},
|
|
)
|
|
} else {
|
|
rootStore.log.warn(
|
|
'Received an invalid app.bsky.feed.post record',
|
|
valid.error,
|
|
)
|
|
}
|
|
} else {
|
|
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.upvoteCount = v.post.upvoteCount
|
|
this.post.viewer = v.post.viewer
|
|
}
|
|
|
|
get reasonRepost(): ReasonRepost | undefined {
|
|
if (this.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost') {
|
|
return this.reason as ReasonRepost
|
|
}
|
|
}
|
|
|
|
async toggleUpvote() {
|
|
const wasUpvoted = !!this.post.viewer.upvote
|
|
const wasDownvoted = !!this.post.viewer.downvote
|
|
const res = await this.rootStore.api.app.bsky.feed.setVote({
|
|
subject: {
|
|
uri: this.post.uri,
|
|
cid: this.post.cid,
|
|
},
|
|
direction: wasUpvoted ? 'none' : 'up',
|
|
})
|
|
runInAction(() => {
|
|
if (wasDownvoted) {
|
|
this.post.downvoteCount--
|
|
}
|
|
if (wasUpvoted) {
|
|
this.post.upvoteCount--
|
|
} else {
|
|
this.post.upvoteCount++
|
|
}
|
|
this.post.viewer.upvote = res.data.upvote
|
|
this.post.viewer.downvote = res.data.downvote
|
|
})
|
|
}
|
|
|
|
async toggleDownvote() {
|
|
const wasUpvoted = !!this.post.viewer.upvote
|
|
const wasDownvoted = !!this.post.viewer.downvote
|
|
const res = await this.rootStore.api.app.bsky.feed.setVote({
|
|
subject: {
|
|
uri: this.post.uri,
|
|
cid: this.post.cid,
|
|
},
|
|
direction: wasDownvoted ? 'none' : 'down',
|
|
})
|
|
runInAction(() => {
|
|
if (wasUpvoted) {
|
|
this.post.upvoteCount--
|
|
}
|
|
if (wasDownvoted) {
|
|
this.post.downvoteCount--
|
|
} else {
|
|
this.post.downvoteCount++
|
|
}
|
|
this.post.viewer.upvote = res.data.upvote
|
|
this.post.viewer.downvote = res.data.downvote
|
|
})
|
|
}
|
|
|
|
async toggleRepost() {
|
|
if (this.post.viewer.repost) {
|
|
await apilib.unrepost(this.rootStore, this.post.viewer.repost)
|
|
runInAction(() => {
|
|
this.post.repostCount--
|
|
this.post.viewer.repost = undefined
|
|
})
|
|
} else {
|
|
const res = await apilib.repost(
|
|
this.rootStore,
|
|
this.post.uri,
|
|
this.post.cid,
|
|
)
|
|
runInAction(() => {
|
|
this.post.repostCount++
|
|
this.post.viewer.repost = res.uri
|
|
})
|
|
}
|
|
}
|
|
|
|
async delete() {
|
|
await this.rootStore.api.app.bsky.feed.post.delete({
|
|
did: this.post.author.did,
|
|
rkey: new AtUri(this.post.uri).rkey,
|
|
})
|
|
this.rootStore.emitPostDeleted(this.post.uri)
|
|
}
|
|
}
|
|
|
|
export class FeedSliceModel {
|
|
// ui state
|
|
_reactKey: string = ''
|
|
|
|
// data
|
|
items: FeedItemModel[] = []
|
|
|
|
constructor(
|
|
public rootStore: RootStoreModel,
|
|
reactKey: string,
|
|
slice: FeedViewPostsSlice,
|
|
) {
|
|
this._reactKey = reactKey
|
|
for (const item of slice.items) {
|
|
this.items.push(
|
|
new FeedItemModel(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 FeedModel {
|
|
// state
|
|
isLoading = false
|
|
isRefreshing = false
|
|
hasNewLatest = false
|
|
hasLoaded = false
|
|
error = ''
|
|
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams
|
|
hasMore = true
|
|
loadMoreCursor: string | undefined
|
|
pollCursor: string | undefined
|
|
tuner = new FeedTuner()
|
|
|
|
// used to linearize async modifications to state
|
|
private lock = new AwaitLock()
|
|
|
|
// data
|
|
slices: FeedSliceModel[] = []
|
|
nextSlices: FeedSliceModel[] = []
|
|
|
|
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.author ||
|
|
item?.reasonRepost?.by?.did === params.author
|
|
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()
|
|
}
|
|
|
|
private 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({
|
|
before: this.loadMoreCursor,
|
|
limit: PAGE_SIZE,
|
|
})
|
|
await this._appendAll(res)
|
|
this._xIdle()
|
|
} catch (e: any) {
|
|
this._xIdle() // don't bubble the error to the user
|
|
this.rootStore.log.error('FeedView: Failed to load more', {
|
|
params: this.params,
|
|
e,
|
|
})
|
|
this.hasMore = false
|
|
}
|
|
} finally {
|
|
this.lock.release()
|
|
}
|
|
})
|
|
|
|
/**
|
|
* 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({
|
|
before: 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 FeedSliceModel(this.rootStore, `item-${_idCounter++}`, slice),
|
|
)
|
|
if (autoPrepend) {
|
|
this.slices = nextSlicesModels.concat(
|
|
this.slices.filter(slice1 =>
|
|
nextSlicesModels.find(slice2 => slice1.uri === slice2.uri),
|
|
),
|
|
)
|
|
this.setHasNewLatest(false)
|
|
} else {
|
|
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
|
|
// =
|
|
|
|
private _xLoading(isRefreshing = false) {
|
|
this.isLoading = true
|
|
this.isRefreshing = isRefreshing
|
|
this.error = ''
|
|
}
|
|
|
|
private _xIdle(err?: any) {
|
|
this.isLoading = false
|
|
this.isRefreshing = false
|
|
this.hasLoaded = true
|
|
this.error = cleanError(err)
|
|
if (err) {
|
|
this.rootStore.log.error('Posts feed request failed', err)
|
|
}
|
|
}
|
|
|
|
// helper functions
|
|
// =
|
|
|
|
private async _replaceAll(
|
|
res: GetTimeline.Response | GetAuthorFeed.Response,
|
|
) {
|
|
this.pollCursor = res.data.feed[0]?.post.uri
|
|
return this._appendAll(res, true)
|
|
}
|
|
|
|
private async _appendAll(
|
|
res: GetTimeline.Response | GetAuthorFeed.Response,
|
|
replace = false,
|
|
) {
|
|
this.loadMoreCursor = res.data.cursor
|
|
this.hasMore = !!this.loadMoreCursor
|
|
|
|
const slices = this.tuner.tune(res.data.feed, this.feedTuners)
|
|
|
|
const toAppend: FeedSliceModel[] = []
|
|
for (const slice of slices) {
|
|
const sliceModel = new FeedSliceModel(
|
|
this.rootStore,
|
|
`item-${_idCounter++}`,
|
|
slice,
|
|
)
|
|
toAppend.push(sliceModel)
|
|
}
|
|
runInAction(() => {
|
|
if (replace) {
|
|
this.slices = toAppend
|
|
} else {
|
|
this.slices = this.slices.concat(toAppend)
|
|
}
|
|
})
|
|
}
|
|
|
|
private _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.before,
|
|
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.api.app.bsky.feed.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.api.app.bsky.feed.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: resBody,
|
|
}
|
|
}
|