diff options
Diffstat (limited to 'src/state/models/feeds')
-rw-r--r-- | src/state/models/feeds/multi-feed.ts | 227 | ||||
-rw-r--r-- | src/state/models/feeds/posts-slice.ts | 3 | ||||
-rw-r--r-- | src/state/models/feeds/posts.ts | 172 |
3 files changed, 60 insertions, 342 deletions
diff --git a/src/state/models/feeds/multi-feed.ts b/src/state/models/feeds/multi-feed.ts deleted file mode 100644 index 95574fb56..000000000 --- a/src/state/models/feeds/multi-feed.ts +++ /dev/null @@ -1,227 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {AtUri} from '@atproto/api' -import {bundleAsync} from 'lib/async/bundle' -import {RootStoreModel} from '../root-store' -import {CustomFeedModel} from './custom-feed' -import {PostsFeedModel} from './posts' -import {PostsFeedSliceModel} from './posts-slice' -import {makeProfileLink} from 'lib/routes/links' - -const FEED_PAGE_SIZE = 10 -const FEEDS_PAGE_SIZE = 3 - -export type MultiFeedItem = - | { - _reactKey: string - type: 'header' - } - | { - _reactKey: string - type: 'feed-header' - avatar: string | undefined - title: string - } - | { - _reactKey: string - type: 'feed-slice' - slice: PostsFeedSliceModel - } - | { - _reactKey: string - type: 'feed-loading' - } - | { - _reactKey: string - type: 'feed-error' - error: string - } - | { - _reactKey: string - type: 'feed-footer' - title: string - uri: string - } - | { - _reactKey: string - type: 'footer' - } - -export class PostsMultiFeedModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - hasMore = true - - // data - feedInfos: CustomFeedModel[] = [] - feeds: PostsFeedModel[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this, {rootStore: false}, {autoBind: true}) - } - - get hasContent() { - return this.feeds.length !== 0 - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - get items() { - const items: MultiFeedItem[] = [{_reactKey: '__header__', type: 'header'}] - for (let i = 0; i < this.feedInfos.length; i++) { - if (!this.feeds[i]) { - break - } - const feed = this.feeds[i] - const feedInfo = this.feedInfos[i] - const urip = new AtUri(feedInfo.uri) - items.push({ - _reactKey: `__feed_header_${i}__`, - type: 'feed-header', - avatar: feedInfo.data.avatar, - title: feedInfo.displayName, - }) - if (feed.isLoading) { - items.push({ - _reactKey: `__feed_loading_${i}__`, - type: 'feed-loading', - }) - } else if (feed.hasError) { - items.push({ - _reactKey: `__feed_error_${i}__`, - type: 'feed-error', - error: feed.error, - }) - } else { - for (let j = 0; j < feed.slices.length; j++) { - items.push({ - _reactKey: `__feed_slice_${i}_${j}__`, - type: 'feed-slice', - slice: feed.slices[j], - }) - } - } - items.push({ - _reactKey: `__feed_footer_${i}__`, - type: 'feed-footer', - title: feedInfo.displayName, - uri: makeProfileLink(feedInfo.data.creator, 'feed', urip.rkey), - }) - } - if (!this.hasMore && this.hasContent) { - // only show if hasContent to avoid double discover-feed links - items.push({_reactKey: '__footer__', type: 'footer'}) - } - return items - } - - // public api - // = - - /** - * Nuke all data - */ - clear() { - this.rootStore.log.debug('MultiFeedModel:clear') - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = false - this.hasMore = true - this.feeds = [] - } - - /** - * 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() { - this.feedInfos = this.rootStore.me.savedFeeds.all.slice() // capture current feeds - await this.loadMore(true) - } - - /** - * Load latest in the active feeds - */ - loadLatest() { - for (const feed of this.feeds) { - /* dont await */ feed.refresh() - } - } - - /** - * Load more posts to the end of the feed - */ - loadMore = bundleAsync(async (isRefreshing: boolean = false) => { - if (!isRefreshing && !this.hasMore) { - return - } - if (isRefreshing) { - this.isRefreshing = true // set optimistically for UI - this.feeds = [] - } - this._xLoading(isRefreshing) - const start = this.feeds.length - const newFeeds: PostsFeedModel[] = [] - for ( - let i = start; - i < start + FEEDS_PAGE_SIZE && i < this.feedInfos.length; - i++ - ) { - const feed = new PostsFeedModel(this.rootStore, 'custom', { - feed: this.feedInfos[i].uri, - }) - feed.pageSize = FEED_PAGE_SIZE - await feed.setup() - newFeeds.push(feed) - } - runInAction(() => { - this.feeds = this.feeds.concat(newFeeds) - this.hasMore = this.feeds.length < this.feedInfos.length - }) - this._xIdle() - }) - - /** - * Attempt to load more again after a failure - */ - async retryLoadMore() { - this.hasMore = true - return this.loadMore() - } - - /** - * Removes posts from the feed upon deletion. - */ - onPostDeleted(uri: string) { - for (const f of this.feeds) { - f.onPostDeleted(uri) - } - } - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - } - - _xIdle() { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - } - - // helper functions - // = -} diff --git a/src/state/models/feeds/posts-slice.ts b/src/state/models/feeds/posts-slice.ts index 16e4eef15..2501cef6f 100644 --- a/src/state/models/feeds/posts-slice.ts +++ b/src/state/models/feeds/posts-slice.ts @@ -2,6 +2,7 @@ import {makeAutoObservable} from 'mobx' import {RootStoreModel} from '../root-store' import {FeedViewPostsSlice} from 'lib/api/feed-manip' import {PostsFeedItemModel} from './post' +import {FeedSourceInfo} from 'lib/api/feed/types' export class PostsFeedSliceModel { // ui state @@ -9,9 +10,11 @@ export class PostsFeedSliceModel { // data items: PostsFeedItemModel[] = [] + source: FeedSourceInfo | undefined constructor(public rootStore: RootStoreModel, slice: FeedViewPostsSlice) { this._reactKey = slice._reactKey + this.source = slice.source for (let i = 0; i < slice.items.length; i++) { this.items.push( new PostsFeedItemModel( diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index c88249c8f..d4e62533e 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -14,6 +14,13 @@ import {PostsFeedSliceModel} from './posts-slice' import {track} from 'lib/analytics/analytics' import {FeedViewPostsSlice} from 'lib/api/feed-manip' +import {FeedAPI, FeedAPIResponse} from 'lib/api/feed/types' +import {FollowingFeedAPI} from 'lib/api/feed/following' +import {AuthorFeedAPI} from 'lib/api/feed/author' +import {LikesFeedAPI} from 'lib/api/feed/likes' +import {CustomFeedAPI} from 'lib/api/feed/custom' +import {MergeFeedAPI} from 'lib/api/feed/merge' + const PAGE_SIZE = 30 type Options = { @@ -27,6 +34,7 @@ type Options = { type QueryParams = | GetTimeline.QueryParams | GetAuthorFeed.QueryParams + | GetActorLikes.QueryParams | GetCustomFeed.QueryParams export class PostsFeedModel { @@ -41,8 +49,8 @@ export class PostsFeedModel { loadMoreError = '' params: QueryParams hasMore = true - loadMoreCursor: string | undefined pollCursor: string | undefined + api: FeedAPI tuner = new FeedTuner() pageSize = PAGE_SIZE options: Options = {} @@ -50,7 +58,7 @@ export class PostsFeedModel { // used to linearize async modifications to state lock = new AwaitLock() - // used to track if what's hot is coming up empty + // used to track if a feed is coming up empty emptyFetches = 0 // data @@ -58,7 +66,7 @@ export class PostsFeedModel { constructor( public rootStore: RootStoreModel, - public feedType: 'home' | 'author' | 'custom' | 'likes', + public feedType: 'home' | 'following' | 'author' | 'custom' | 'likes', params: QueryParams, options?: Options, ) { @@ -67,12 +75,33 @@ export class PostsFeedModel { { rootStore: false, params: false, - loadMoreCursor: false, }, {autoBind: true}, ) this.params = params this.options = options || {} + if (feedType === 'home') { + this.api = new MergeFeedAPI(rootStore) + } else if (feedType === 'following') { + this.api = new FollowingFeedAPI(rootStore) + } else if (feedType === 'author') { + this.api = new AuthorFeedAPI( + rootStore, + params as GetAuthorFeed.QueryParams, + ) + } else if (feedType === 'likes') { + this.api = new LikesFeedAPI( + rootStore, + params as GetActorLikes.QueryParams, + ) + } else if (feedType === 'custom') { + this.api = new CustomFeedAPI( + rootStore, + params as GetCustomFeed.QueryParams, + ) + } else { + this.api = new FollowingFeedAPI(rootStore) + } } get hasContent() { @@ -105,7 +134,6 @@ export class PostsFeedModel { this.hasLoaded = false this.error = '' this.hasMore = true - this.loadMoreCursor = undefined this.pollCursor = undefined this.slices = [] this.tuner.reset() @@ -113,6 +141,8 @@ export class PostsFeedModel { get feedTuners() { const areRepliesEnabled = this.rootStore.preferences.homeFeedRepliesEnabled + const areRepliesByFollowedOnlyEnabled = + this.rootStore.preferences.homeFeedRepliesByFollowedOnlyEnabled const repliesThreshold = this.rootStore.preferences.homeFeedRepliesThreshold const areRepostsEnabled = this.rootStore.preferences.homeFeedRepostsEnabled const areQuotePostsEnabled = @@ -126,7 +156,7 @@ export class PostsFeedModel { ), ] } - if (this.feedType === 'home') { + if (this.feedType === 'home' || this.feedType === 'following') { const feedTuners = [] if (areRepostsEnabled) { @@ -136,7 +166,13 @@ export class PostsFeedModel { } if (areRepliesEnabled) { - feedTuners.push(FeedTuner.likedRepliesOnly({repliesThreshold})) + feedTuners.push( + FeedTuner.thresholdRepliesOnly({ + userDid: this.rootStore.session.data?.did || '', + minLikes: repliesThreshold, + followedOnly: areRepliesByFollowedOnlyEnabled, + }), + ) } else { feedTuners.push(FeedTuner.removeReplies) } @@ -161,10 +197,11 @@ export class PostsFeedModel { await this.lock.acquireAsync() try { this.setHasNewLatest(false) + this.api.reset() this.tuner.reset() this._xLoading(isRefreshing) try { - const res = await this._getFeed({limit: this.pageSize}) + const res = await this.api.fetchNext({limit: this.pageSize}) await this._replaceAll(res) this._xIdle() } catch (e: any) { @@ -201,8 +238,7 @@ export class PostsFeedModel { } this._xLoading() try { - const res = await this._getFeed({ - cursor: this.loadMoreCursor, + const res = await this.api.fetchNext({ limit: this.pageSize, }) await this._appendAll(res) @@ -231,53 +267,15 @@ export class PostsFeedModel { } /** - * Update content in-place - */ - update = bundleAsync(async () => { - await this.lock.acquireAsync() - try { - if (!this.slices.length) { - return - } - this._xLoading() - let numToFetch = this.slices.length - let cursor - try { - do { - const res: GetTimeline.Response = await this._getFeed({ - cursor, - limit: Math.min(numToFetch, 100), - }) - if (res.data.feed.length === 0) { - break // sanity check - } - this._updateAll(res) - numToFetch -= res.data.feed.length - cursor = res.data.cursor - } while (cursor && numToFetch > 0) - this._xIdle() - } catch (e: any) { - this._xIdle() // don't bubble the error to the user - this.rootStore.log.error('FeedView: Failed to update', { - params: this.params, - e, - }) - } - } finally { - this.lock.release() - } - }) - - /** * Check if new posts are available */ async checkForLatest() { if (!this.hasLoaded || this.hasNewLatest || this.isLoading) { return } - const res = await this._getFeed({limit: 1}) - if (res.data.feed[0]) { - const slices = this.tuner.tune(res.data.feed, this.feedTuners, { + const post = await this.api.peekLatest() + if (post) { + const slices = this.tuner.tune([post], this.feedTuners, { dryRun: true, }) if (slices[0]) { @@ -345,33 +343,27 @@ export class PostsFeedModel { // helper functions // = - async _replaceAll( - res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, - ) { - this.pollCursor = res.data.feed[0]?.post.uri + async _replaceAll(res: FeedAPIResponse) { + this.pollCursor = res.feed[0]?.post.uri return this._appendAll(res, true) } - async _appendAll( - res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, - replace = false, - ) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor + async _appendAll(res: FeedAPIResponse, replace = false) { + this.hasMore = !!res.cursor if (replace) { this.emptyFetches = 0 } this.rootStore.me.follows.hydrateProfiles( - res.data.feed.map(item => item.post.author), + res.feed.map(item => item.post.author), ) - for (const item of res.data.feed) { + for (const item of res.feed) { this.rootStore.posts.fromFeedItem(item) } const slices = this.options.isSimpleFeed - ? res.data.feed.map(item => new FeedViewPostsSlice([item])) - : this.tuner.tune(res.data.feed, this.feedTuners) + ? res.feed.map(item => new FeedViewPostsSlice([item])) + : this.tuner.tune(res.feed, this.feedTuners) const toAppend: PostsFeedSliceModel[] = [] for (const slice of slices) { @@ -401,54 +393,4 @@ export class PostsFeedModel { } }) } - - _updateAll( - res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, - ) { - for (const item of res.data.feed) { - this.rootStore.posts.fromFeedItem(item) - 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: QueryParams, - ): Promise< - GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response - > { - params = Object.assign({}, this.params, params) - if (this.feedType === 'home') { - return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams) - } else if (this.feedType === 'custom') { - const res = await this.rootStore.agent.app.bsky.feed.getFeed( - params as GetCustomFeed.QueryParams, - ) - // NOTE - // some custom feeds fail to enforce the pagination limit - // so we manually truncate here - // -prf - if (params.limit && res.data.feed.length > params.limit) { - res.data.feed = res.data.feed.slice(0, params.limit) - } - return res - } else if (this.feedType === 'author') { - return this.rootStore.agent.getAuthorFeed( - params as GetAuthorFeed.QueryParams, - ) - } else { - return this.rootStore.agent.getActorLikes( - params as GetActorLikes.QueryParams, - ) - } - } } |