diff options
Diffstat (limited to 'src/state/models/feeds')
-rw-r--r-- | src/state/models/feeds/notifications.ts | 574 | ||||
-rw-r--r-- | src/state/models/feeds/posts.ts | 648 |
2 files changed, 1222 insertions, 0 deletions
diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts new file mode 100644 index 000000000..ea3538438 --- /dev/null +++ b/src/state/models/feeds/notifications.ts @@ -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 +} diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts new file mode 100644 index 000000000..9e593f313 --- /dev/null +++ b/src/state/models/feeds/posts.ts @@ -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), + } +} |