Reorganize state models for clarity (#378)
This commit is contained in:
parent
9652d994dd
commit
2045c615a8
44 changed files with 163 additions and 171 deletions
574
src/state/models/feeds/notifications.ts
Normal file
574
src/state/models/feeds/notifications.ts
Normal file
|
@ -0,0 +1,574 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {
|
||||
AppBskyNotificationListNotifications as ListNotifications,
|
||||
AppBskyActorDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedRepost,
|
||||
AppBskyFeedLike,
|
||||
AppBskyGraphFollow,
|
||||
} from '@atproto/api'
|
||||
import AwaitLock from 'await-lock'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {PostThreadModel} from '../content/post-thread'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
|
||||
const GROUPABLE_REASONS = ['like', 'repost', 'follow']
|
||||
const PAGE_SIZE = 30
|
||||
const MS_1HR = 1e3 * 60 * 60
|
||||
const MS_2DAY = MS_1HR * 48
|
||||
|
||||
let _idCounter = 0
|
||||
|
||||
export interface GroupedNotification extends ListNotifications.Notification {
|
||||
additional?: ListNotifications.Notification[]
|
||||
}
|
||||
|
||||
type SupportedRecord =
|
||||
| AppBskyFeedPost.Record
|
||||
| AppBskyFeedRepost.Record
|
||||
| AppBskyFeedLike.Record
|
||||
| AppBskyGraphFollow.Record
|
||||
|
||||
export class NotificationsFeedItemModel {
|
||||
// ui state
|
||||
_reactKey: string = ''
|
||||
|
||||
// data
|
||||
uri: string = ''
|
||||
cid: string = ''
|
||||
author: AppBskyActorDefs.ProfileViewBasic = {
|
||||
did: '',
|
||||
handle: '',
|
||||
avatar: '',
|
||||
}
|
||||
reason: string = ''
|
||||
reasonSubject?: string
|
||||
record?: SupportedRecord
|
||||
isRead: boolean = false
|
||||
indexedAt: string = ''
|
||||
additional?: NotificationsFeedItemModel[]
|
||||
|
||||
// additional data
|
||||
additionalPost?: PostThreadModel
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
reactKey: string,
|
||||
v: GroupedNotification,
|
||||
) {
|
||||
makeAutoObservable(this, {rootStore: false})
|
||||
this._reactKey = reactKey
|
||||
this.copy(v)
|
||||
}
|
||||
|
||||
copy(v: GroupedNotification, preserve = false) {
|
||||
this.uri = v.uri
|
||||
this.cid = v.cid
|
||||
this.author = v.author
|
||||
this.reason = v.reason
|
||||
this.reasonSubject = v.reasonSubject
|
||||
this.record = this.toSupportedRecord(v.record)
|
||||
this.isRead = v.isRead
|
||||
this.indexedAt = v.indexedAt
|
||||
if (v.additional?.length) {
|
||||
this.additional = []
|
||||
for (const add of v.additional) {
|
||||
this.additional.push(
|
||||
new NotificationsFeedItemModel(this.rootStore, '', add),
|
||||
)
|
||||
}
|
||||
} else if (!preserve) {
|
||||
this.additional = undefined
|
||||
}
|
||||
}
|
||||
|
||||
get isLike() {
|
||||
return this.reason === 'like'
|
||||
}
|
||||
|
||||
get isRepost() {
|
||||
return this.reason === 'repost'
|
||||
}
|
||||
|
||||
get isMention() {
|
||||
return this.reason === 'mention'
|
||||
}
|
||||
|
||||
get isReply() {
|
||||
return this.reason === 'reply'
|
||||
}
|
||||
|
||||
get isQuote() {
|
||||
return this.reason === 'quote'
|
||||
}
|
||||
|
||||
get isFollow() {
|
||||
return this.reason === 'follow'
|
||||
}
|
||||
|
||||
get needsAdditionalData() {
|
||||
if (
|
||||
this.isLike ||
|
||||
this.isRepost ||
|
||||
this.isReply ||
|
||||
this.isQuote ||
|
||||
this.isMention
|
||||
) {
|
||||
return !this.additionalPost
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
get subjectUri(): string {
|
||||
if (this.reasonSubject) {
|
||||
return this.reasonSubject
|
||||
}
|
||||
const record = this.record
|
||||
if (
|
||||
AppBskyFeedRepost.isRecord(record) ||
|
||||
AppBskyFeedLike.isRecord(record)
|
||||
) {
|
||||
return record.subject.uri
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
toSupportedRecord(v: unknown): SupportedRecord | undefined {
|
||||
for (const ns of [
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedRepost,
|
||||
AppBskyFeedLike,
|
||||
AppBskyGraphFollow,
|
||||
]) {
|
||||
if (ns.isRecord(v)) {
|
||||
const valid = ns.validateRecord(v)
|
||||
if (valid.success) {
|
||||
return v
|
||||
} else {
|
||||
this.rootStore.log.warn('Received an invalid record', {
|
||||
record: v,
|
||||
error: valid.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
this.rootStore.log.warn(
|
||||
'app.bsky.notifications.list served an unsupported record type',
|
||||
v,
|
||||
)
|
||||
}
|
||||
|
||||
async fetchAdditionalData() {
|
||||
if (!this.needsAdditionalData) {
|
||||
return
|
||||
}
|
||||
let postUri
|
||||
if (this.isReply || this.isQuote || this.isMention) {
|
||||
postUri = this.uri
|
||||
} else if (this.isLike || this.isRepost) {
|
||||
postUri = this.subjectUri
|
||||
}
|
||||
if (postUri) {
|
||||
this.additionalPost = new PostThreadModel(this.rootStore, {
|
||||
uri: postUri,
|
||||
depth: 0,
|
||||
})
|
||||
await this.additionalPost.setup().catch(e => {
|
||||
this.rootStore.log.error(
|
||||
'Failed to load post needed by notification',
|
||||
e,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class NotificationsFeedModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
params: ListNotifications.QueryParams
|
||||
hasMore = true
|
||||
loadMoreCursor?: string
|
||||
|
||||
// used to linearize async modifications to state
|
||||
lock = new AwaitLock()
|
||||
|
||||
// data
|
||||
notifications: NotificationsFeedItemModel[] = []
|
||||
unreadCount = 0
|
||||
|
||||
// this is used to help trigger push notifications
|
||||
mostRecentNotificationUri: string | undefined
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
params: ListNotifications.QueryParams,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
params: false,
|
||||
mostRecentNotificationUri: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
this.params = params
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
return this.notifications.length !== 0
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.error !== ''
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
/**
|
||||
* Nuke all data
|
||||
*/
|
||||
clear() {
|
||||
this.rootStore.log.debug('NotificationsModel:clear')
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = false
|
||||
this.error = ''
|
||||
this.hasMore = true
|
||||
this.loadMoreCursor = undefined
|
||||
this.notifications = []
|
||||
this.unreadCount = 0
|
||||
this.rootStore.emitUnreadNotifications(0)
|
||||
this.mostRecentNotificationUri = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Load for first render
|
||||
*/
|
||||
setup = bundleAsync(async (isRefreshing: boolean = false) => {
|
||||
this.rootStore.log.debug('NotificationsModel:setup', {isRefreshing})
|
||||
if (isRefreshing) {
|
||||
this.isRefreshing = true // set optimistically for UI
|
||||
}
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
this._xLoading(isRefreshing)
|
||||
try {
|
||||
const params = Object.assign({}, this.params, {
|
||||
limit: PAGE_SIZE,
|
||||
})
|
||||
const res = await this.rootStore.agent.listNotifications(params)
|
||||
await this._replaceAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(e)
|
||||
}
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Reset and load
|
||||
*/
|
||||
async refresh() {
|
||||
return this.setup(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more posts to the end of the notifications
|
||||
*/
|
||||
loadMore = bundleAsync(async () => {
|
||||
if (!this.hasMore) {
|
||||
return
|
||||
}
|
||||
this.lock.acquireAsync()
|
||||
try {
|
||||
this._xLoading()
|
||||
try {
|
||||
const params = Object.assign({}, this.params, {
|
||||
limit: PAGE_SIZE,
|
||||
cursor: this.loadMoreCursor,
|
||||
})
|
||||
const res = await this.rootStore.agent.listNotifications(params)
|
||||
await this._appendAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle() // don't bubble the error to the user
|
||||
this.rootStore.log.error('NotificationsView: Failed to load more', {
|
||||
params: this.params,
|
||||
e,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Load more posts at the start of the notifications
|
||||
*/
|
||||
loadLatest = bundleAsync(async () => {
|
||||
if (this.notifications.length === 0 || this.unreadCount > PAGE_SIZE) {
|
||||
return this.refresh()
|
||||
}
|
||||
this.lock.acquireAsync()
|
||||
try {
|
||||
this._xLoading()
|
||||
try {
|
||||
const res = await this.rootStore.agent.listNotifications({
|
||||
limit: PAGE_SIZE,
|
||||
})
|
||||
await this._prependAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle() // don't bubble the error to the user
|
||||
this.rootStore.log.error('NotificationsView: Failed to load latest', {
|
||||
params: this.params,
|
||||
e,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Update content in-place
|
||||
*/
|
||||
update = bundleAsync(async () => {
|
||||
await this.lock.acquireAsync()
|
||||
try {
|
||||
if (!this.notifications.length) {
|
||||
return
|
||||
}
|
||||
this._xLoading()
|
||||
let numToFetch = this.notifications.length
|
||||
let cursor
|
||||
try {
|
||||
do {
|
||||
const res: ListNotifications.Response =
|
||||
await this.rootStore.agent.listNotifications({
|
||||
cursor,
|
||||
limit: Math.min(numToFetch, 100),
|
||||
})
|
||||
if (res.data.notifications.length === 0) {
|
||||
break // sanity check
|
||||
}
|
||||
this._updateAll(res)
|
||||
numToFetch -= res.data.notifications.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('NotificationsView: Failed to update', {
|
||||
params: this.params,
|
||||
e,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
this.lock.release()
|
||||
}
|
||||
})
|
||||
|
||||
// unread notification apis
|
||||
// =
|
||||
|
||||
/**
|
||||
* Get the current number of unread notifications
|
||||
* returns true if the number changed
|
||||
*/
|
||||
loadUnreadCount = bundleAsync(async () => {
|
||||
const old = this.unreadCount
|
||||
const res = await this.rootStore.agent.countUnreadNotifications()
|
||||
runInAction(() => {
|
||||
this.unreadCount = res.data.count
|
||||
})
|
||||
this.rootStore.emitUnreadNotifications(this.unreadCount)
|
||||
return this.unreadCount !== old
|
||||
})
|
||||
|
||||
/**
|
||||
* Update read/unread state
|
||||
*/
|
||||
async markAllRead() {
|
||||
try {
|
||||
this.unreadCount = 0
|
||||
this.rootStore.emitUnreadNotifications(0)
|
||||
for (const notif of this.notifications) {
|
||||
notif.isRead = true
|
||||
}
|
||||
await this.rootStore.agent.updateSeenNotifications()
|
||||
} catch (e: any) {
|
||||
this.rootStore.log.warn('Failed to update notifications read state', e)
|
||||
}
|
||||
}
|
||||
|
||||
async getNewMostRecent(): Promise<NotificationsFeedItemModel | undefined> {
|
||||
let old = this.mostRecentNotificationUri
|
||||
const res = await this.rootStore.agent.listNotifications({
|
||||
limit: 1,
|
||||
})
|
||||
if (!res.data.notifications[0] || old === res.data.notifications[0].uri) {
|
||||
return
|
||||
}
|
||||
this.mostRecentNotificationUri = res.data.notifications[0].uri
|
||||
const notif = new NotificationsFeedItemModel(
|
||||
this.rootStore,
|
||||
'mostRecent',
|
||||
res.data.notifications[0],
|
||||
)
|
||||
await notif.fetchAdditionalData()
|
||||
return notif
|
||||
}
|
||||
|
||||
// state transitions
|
||||
// =
|
||||
|
||||
_xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
_xIdle(err?: any) {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
if (err) {
|
||||
this.rootStore.log.error('Failed to fetch notifications', err)
|
||||
}
|
||||
}
|
||||
|
||||
// helper functions
|
||||
// =
|
||||
|
||||
async _replaceAll(res: ListNotifications.Response) {
|
||||
if (res.data.notifications[0]) {
|
||||
this.mostRecentNotificationUri = res.data.notifications[0].uri
|
||||
}
|
||||
return this._appendAll(res, true)
|
||||
}
|
||||
|
||||
async _appendAll(res: ListNotifications.Response, replace = false) {
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
this.hasMore = !!this.loadMoreCursor
|
||||
const promises = []
|
||||
const itemModels: NotificationsFeedItemModel[] = []
|
||||
for (const item of groupNotifications(res.data.notifications)) {
|
||||
const itemModel = new NotificationsFeedItemModel(
|
||||
this.rootStore,
|
||||
`item-${_idCounter++}`,
|
||||
item,
|
||||
)
|
||||
if (itemModel.needsAdditionalData) {
|
||||
promises.push(itemModel.fetchAdditionalData())
|
||||
}
|
||||
itemModels.push(itemModel)
|
||||
}
|
||||
await Promise.all(promises).catch(e => {
|
||||
this.rootStore.log.error(
|
||||
'Uncaught failure during notifications-view _appendAll()',
|
||||
e,
|
||||
)
|
||||
})
|
||||
runInAction(() => {
|
||||
if (replace) {
|
||||
this.notifications = itemModels
|
||||
} else {
|
||||
this.notifications = this.notifications.concat(itemModels)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async _prependAll(res: ListNotifications.Response) {
|
||||
const promises = []
|
||||
const itemModels: NotificationsFeedItemModel[] = []
|
||||
const dedupedNotifs = res.data.notifications.filter(
|
||||
n1 =>
|
||||
!this.notifications.find(
|
||||
n2 => isEq(n1, n2) || n2.additional?.find(n3 => isEq(n1, n3)),
|
||||
),
|
||||
)
|
||||
for (const item of groupNotifications(dedupedNotifs)) {
|
||||
const itemModel = new NotificationsFeedItemModel(
|
||||
this.rootStore,
|
||||
`item-${_idCounter++}`,
|
||||
item,
|
||||
)
|
||||
if (itemModel.needsAdditionalData) {
|
||||
promises.push(itemModel.fetchAdditionalData())
|
||||
}
|
||||
itemModels.push(itemModel)
|
||||
}
|
||||
await Promise.all(promises).catch(e => {
|
||||
this.rootStore.log.error(
|
||||
'Uncaught failure during notifications-view _prependAll()',
|
||||
e,
|
||||
)
|
||||
})
|
||||
runInAction(() => {
|
||||
this.notifications = itemModels.concat(this.notifications)
|
||||
})
|
||||
}
|
||||
|
||||
_updateAll(res: ListNotifications.Response) {
|
||||
for (const item of res.data.notifications) {
|
||||
const existingItem = this.notifications.find(item2 => isEq(item, item2))
|
||||
if (existingItem) {
|
||||
existingItem.copy(item, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function groupNotifications(
|
||||
items: ListNotifications.Notification[],
|
||||
): GroupedNotification[] {
|
||||
const items2: GroupedNotification[] = []
|
||||
for (const item of items) {
|
||||
const ts = +new Date(item.indexedAt)
|
||||
let grouped = false
|
||||
if (GROUPABLE_REASONS.includes(item.reason)) {
|
||||
for (const item2 of items2) {
|
||||
const ts2 = +new Date(item2.indexedAt)
|
||||
if (
|
||||
Math.abs(ts2 - ts) < MS_2DAY &&
|
||||
item.reason === item2.reason &&
|
||||
item.reasonSubject === item2.reasonSubject &&
|
||||
item.author.did !== item2.author.did
|
||||
) {
|
||||
item2.additional = item2.additional || []
|
||||
item2.additional.push(item)
|
||||
grouped = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!grouped) {
|
||||
items2.push(item)
|
||||
}
|
||||
}
|
||||
return items2
|
||||
}
|
||||
|
||||
type N = ListNotifications.Notification | NotificationsFeedItemModel
|
||||
function isEq(a: N, b: N) {
|
||||
// this function has a key subtlety- the indexedAt comparison
|
||||
// the reason for this is reposts: they set the URI of the original post, not of the repost record
|
||||
// the indexedAt time will be for the repost however, so we use that to help us
|
||||
return a.uri === b.uri && a.indexedAt === b.indexedAt
|
||||
}
|
648
src/state/models/feeds/posts.ts
Normal file
648
src/state/models/feeds/posts.ts
Normal file
|
@ -0,0 +1,648 @@
|
|||
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.feedViewPost#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 = ''
|
||||
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() // 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({
|
||||
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(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
|
||||
// =
|
||||
|
||||
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
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue