diff options
Diffstat (limited to 'src/state/models')
-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 | ||||
-rw-r--r-- | src/state/models/root-store.ts | 2 | ||||
-rw-r--r-- | src/state/models/ui/my-feeds.ts | 157 | ||||
-rw-r--r-- | src/state/models/ui/preferences.ts | 31 | ||||
-rw-r--r-- | src/state/models/ui/profile.ts | 7 |
7 files changed, 248 insertions, 351 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, - ) - } - } } diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 6204e0d10..1a81072a2 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -139,7 +139,7 @@ export class RootStoreModel { this.agent = agent applyDebugHeader(this.agent) this.me.clear() - /* dont await */ this.preferences.sync() + await this.preferences.sync() await this.me.load() if (!hadSession) { await resetNavigation() diff --git a/src/state/models/ui/my-feeds.ts b/src/state/models/ui/my-feeds.ts new file mode 100644 index 000000000..f9ad06f77 --- /dev/null +++ b/src/state/models/ui/my-feeds.ts @@ -0,0 +1,157 @@ +import {makeAutoObservable} from 'mobx' +import {FeedsDiscoveryModel} from '../discovery/feeds' +import {CustomFeedModel} from '../feeds/custom-feed' +import {RootStoreModel} from '../root-store' + +export type MyFeedsItem = + | { + _reactKey: string + type: 'spinner' + } + | { + _reactKey: string + type: 'discover-feeds-loading' + } + | { + _reactKey: string + type: 'error' + error: string + } + | { + _reactKey: string + type: 'saved-feeds-header' + } + | { + _reactKey: string + type: 'saved-feed' + feed: CustomFeedModel + } + | { + _reactKey: string + type: 'saved-feeds-load-more' + } + | { + _reactKey: string + type: 'discover-feeds-header' + } + | { + _reactKey: string + type: 'discover-feeds-no-results' + } + | { + _reactKey: string + type: 'discover-feed' + feed: CustomFeedModel + } + +export class MyFeedsUIModel { + discovery: FeedsDiscoveryModel + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable(this) + this.discovery = new FeedsDiscoveryModel(this.rootStore) + } + + get saved() { + return this.rootStore.me.savedFeeds + } + + get isRefreshing() { + return !this.saved.isLoading && this.saved.isRefreshing + } + + get isLoading() { + return this.saved.isLoading || this.discovery.isLoading + } + + async setup() { + if (!this.saved.hasLoaded) { + await this.saved.refresh() + } + if (!this.discovery.hasLoaded) { + await this.discovery.refresh() + } + } + + async refresh() { + return Promise.all([this.saved.refresh(), this.discovery.refresh()]) + } + + async loadMore() { + return this.discovery.loadMore() + } + + get items() { + let items: MyFeedsItem[] = [] + + items.push({ + _reactKey: '__saved_feeds_header__', + type: 'saved-feeds-header', + }) + if (this.saved.isLoading) { + items.push({ + _reactKey: '__saved_feeds_loading__', + type: 'spinner', + }) + } else if (this.saved.hasError) { + items.push({ + _reactKey: '__saved_feeds_error__', + type: 'error', + error: this.saved.error, + }) + } else { + const savedSorted = this.saved.all + .slice() + .sort((a, b) => a.displayName.localeCompare(b.displayName)) + items = items.concat( + savedSorted.map(feed => ({ + _reactKey: `saved-${feed.uri}`, + type: 'saved-feed', + feed, + })), + ) + items.push({ + _reactKey: '__saved_feeds_load_more__', + type: 'saved-feeds-load-more', + }) + } + + items.push({ + _reactKey: '__discover_feeds_header__', + type: 'discover-feeds-header', + }) + if (this.discovery.isLoading && !this.discovery.hasContent) { + items.push({ + _reactKey: '__discover_feeds_loading__', + type: 'discover-feeds-loading', + }) + } else if (this.discovery.hasError) { + items.push({ + _reactKey: '__discover_feeds_error__', + type: 'error', + error: this.discovery.error, + }) + } else if (this.discovery.isEmpty) { + items.push({ + _reactKey: '__discover_feeds_no_results__', + type: 'discover-feeds-no-results', + }) + } else { + items = items.concat( + this.discovery.feeds.map(feed => ({ + _reactKey: `discover-${feed.uri}`, + type: 'discover-feed', + feed, + })), + ) + if (this.discovery.isLoading) { + items.push({ + _reactKey: '__discover_feeds_loading_more__', + type: 'spinner', + }) + } + } + + return items + } +} diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index 64ab4ecba..7232a7b74 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -50,9 +50,11 @@ export class PreferencesModel { pinnedFeeds: string[] = [] birthDate: Date | undefined = undefined homeFeedRepliesEnabled: boolean = true - homeFeedRepliesThreshold: number = 2 + homeFeedRepliesByFollowedOnlyEnabled: boolean = true + homeFeedRepliesThreshold: number = 0 homeFeedRepostsEnabled: boolean = true homeFeedQuotePostsEnabled: boolean = true + homeFeedMergeFeedEnabled: boolean = false requireAltTextEnabled: boolean = false // used to linearize async modifications to state @@ -78,9 +80,12 @@ export class PreferencesModel { savedFeeds: this.savedFeeds, pinnedFeeds: this.pinnedFeeds, homeFeedRepliesEnabled: this.homeFeedRepliesEnabled, + homeFeedRepliesByFollowedOnlyEnabled: + this.homeFeedRepliesByFollowedOnlyEnabled, homeFeedRepliesThreshold: this.homeFeedRepliesThreshold, homeFeedRepostsEnabled: this.homeFeedRepostsEnabled, homeFeedQuotePostsEnabled: this.homeFeedQuotePostsEnabled, + homeFeedMergeFeedEnabled: this.homeFeedMergeFeedEnabled, requireAltTextEnabled: this.requireAltTextEnabled, } } @@ -148,6 +153,14 @@ export class PreferencesModel { ) { this.homeFeedRepliesEnabled = v.homeFeedRepliesEnabled } + // check if home feed replies "followed only" are enabled in preferences, then hydrate + if ( + hasProp(v, 'homeFeedRepliesByFollowedOnlyEnabled') && + typeof v.homeFeedRepliesByFollowedOnlyEnabled === 'boolean' + ) { + this.homeFeedRepliesByFollowedOnlyEnabled = + v.homeFeedRepliesByFollowedOnlyEnabled + } // check if home feed replies threshold is enabled in preferences, then hydrate if ( hasProp(v, 'homeFeedRepliesThreshold') && @@ -169,6 +182,13 @@ export class PreferencesModel { ) { this.homeFeedQuotePostsEnabled = v.homeFeedQuotePostsEnabled } + // check if home feed mergefeed is enabled in preferences, then hydrate + if ( + hasProp(v, 'homeFeedMergeFeedEnabled') && + typeof v.homeFeedMergeFeedEnabled === 'boolean' + ) { + this.homeFeedMergeFeedEnabled = v.homeFeedMergeFeedEnabled + } // check if requiring alt text is enabled in preferences, then hydrate if ( hasProp(v, 'requireAltTextEnabled') && @@ -449,6 +469,11 @@ export class PreferencesModel { this.homeFeedRepliesEnabled = !this.homeFeedRepliesEnabled } + toggleHomeFeedRepliesByFollowedOnlyEnabled() { + this.homeFeedRepliesByFollowedOnlyEnabled = + !this.homeFeedRepliesByFollowedOnlyEnabled + } + setHomeFeedRepliesThreshold(threshold: number) { this.homeFeedRepliesThreshold = threshold } @@ -461,6 +486,10 @@ export class PreferencesModel { this.homeFeedQuotePostsEnabled = !this.homeFeedQuotePostsEnabled } + toggleHomeFeedMergeFeedEnabled() { + this.homeFeedMergeFeedEnabled = !this.homeFeedMergeFeedEnabled + } + toggleRequireAltTextEnabled() { this.requireAltTextEnabled = !this.requireAltTextEnabled } diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index 11951b0ee..8525426bf 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -240,13 +240,6 @@ export class ProfileUiModel { .catch(err => this.rootStore.log.error('Failed to fetch lists', err)) } - async update() { - const view = this.currentView - if (view instanceof PostsFeedModel) { - await view.update() - } - } - async refresh() { await Promise.all([this.profile.refresh(), this.currentView.refresh()]) } |