diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 911cc630..02ef5f38 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -436,9 +436,6 @@ export class PostsFeedModel { } else if (this.feedType === 'home') { return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams) } else if (this.feedType === 'custom') { - this.checkIfCustomFeedIsOnlineAndValid( - params as GetCustomFeed.QueryParams, - ) return this.rootStore.agent.app.bsky.feed.getFeed( params as GetCustomFeed.QueryParams, ) @@ -448,18 +445,4 @@ export class PostsFeedModel { ) } } - - private async checkIfCustomFeedIsOnlineAndValid( - params: GetCustomFeed.QueryParams, - ) { - const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({ - feed: params.feed, - }) - if (!res.data.isOnline || !res.data.isValid) { - runInAction(() => { - this.error = - 'This custom feed is not online or may be experiencing issues.' - }) - } - } } diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 81504485..59d79f05 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -52,7 +52,6 @@ export class MeModel { this.mainFeed.clear() this.notifications.clear() this.follows.clear() - this.savedFeeds.clear() this.did = '' this.handle = '' this.displayName = '' @@ -114,7 +113,6 @@ export class MeModel { /* dont await */ this.notifications.setup().catch(e => { this.rootStore.log.error('Failed to setup notifications model', e) }) - /* dont await */ this.savedFeeds.refresh(true) this.rootStore.emitSessionLoaded() await this.fetchInviteCodes() await this.fetchAppPasswords() @@ -124,7 +122,6 @@ export class MeModel { } async updateIfNeeded() { - /* dont await */ this.savedFeeds.refresh(true) if (Date.now() - this.lastProfileStateUpdate > PROFILE_UPDATE_INTERVAL) { this.rootStore.log.debug('Updating me profile information') this.lastProfileStateUpdate = Date.now() diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index dcf6b9a7..a42f0a83 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -1,5 +1,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import {getLocales} from 'expo-localization' +import AwaitLock from 'await-lock' +import isEqual from 'lodash.isequal' import {isObj, hasProp} from 'lib/type-guards' import {RootStoreModel} from '../root-store' import {ComAtprotoLabelDefs, AppBskyActorDefs} from '@atproto/api' @@ -50,8 +52,11 @@ export class PreferencesModel { savedFeeds: string[] = [] pinnedFeeds: string[] = [] + // used to linearize async modifications to state + lock = new AwaitLock() + constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this, {}, {autoBind: true}) + makeAutoObservable(this, {lock: false}, {autoBind: true}) } serialize() { @@ -103,62 +108,72 @@ export class PreferencesModel { /** * This function fetches preferences and sets defaults for missing items. */ - async sync() { - // fetch preferences - let hasSavedFeedsPref = false - const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) - runInAction(() => { - for (const pref of res.data.preferences) { - if ( - AppBskyActorDefs.isAdultContentPref(pref) && - AppBskyActorDefs.validateAdultContentPref(pref).success - ) { - this.adultContentEnabled = pref.enabled - } else if ( - AppBskyActorDefs.isContentLabelPref(pref) && - AppBskyActorDefs.validateAdultContentPref(pref).success - ) { - if ( - LABEL_GROUPS.includes(pref.label) && - VISIBILITY_VALUES.includes(pref.visibility) - ) { - this.contentLabels[pref.label as keyof LabelPreferencesModel] = - pref.visibility as LabelPreference - } - } else if ( - AppBskyActorDefs.isSavedFeedsPref(pref) && - AppBskyActorDefs.validateSavedFeedsPref(pref).success - ) { - this.savedFeeds = pref.saved - this.pinnedFeeds = pref.pinned - hasSavedFeedsPref = true - } - } - }) - - // set defaults on missing items - if (!hasSavedFeedsPref) { - const {saved, pinned} = await DEFAULT_FEEDS( - this.rootStore.agent.service.toString(), - (handle: string) => - this.rootStore.agent - .resolveHandle({handle}) - .then(({data}) => data.did), - ) + async sync({clearCache}: {clearCache?: boolean} = {}) { + await this.lock.acquireAsync() + try { + // fetch preferences + let hasSavedFeedsPref = false + const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) runInAction(() => { - this.savedFeeds = saved - this.pinnedFeeds = pinned + for (const pref of res.data.preferences) { + if ( + AppBskyActorDefs.isAdultContentPref(pref) && + AppBskyActorDefs.validateAdultContentPref(pref).success + ) { + this.adultContentEnabled = pref.enabled + } else if ( + AppBskyActorDefs.isContentLabelPref(pref) && + AppBskyActorDefs.validateAdultContentPref(pref).success + ) { + if ( + LABEL_GROUPS.includes(pref.label) && + VISIBILITY_VALUES.includes(pref.visibility) + ) { + this.contentLabels[pref.label as keyof LabelPreferencesModel] = + pref.visibility as LabelPreference + } + } else if ( + AppBskyActorDefs.isSavedFeedsPref(pref) && + AppBskyActorDefs.validateSavedFeedsPref(pref).success + ) { + if (!isEqual(this.savedFeeds, pref.saved)) { + this.savedFeeds = pref.saved + } + if (!isEqual(this.pinnedFeeds, pref.pinned)) { + this.pinnedFeeds = pref.pinned + } + hasSavedFeedsPref = true + } + } }) - res.data.preferences.push({ - $type: 'app.bsky.actor.defs#savedFeedsPref', - saved, - pinned, - }) - await this.rootStore.agent.app.bsky.actor.putPreferences({ - preferences: res.data.preferences, - }) - /* dont await */ this.rootStore.me.savedFeeds.refresh() + + // set defaults on missing items + if (!hasSavedFeedsPref) { + const {saved, pinned} = await DEFAULT_FEEDS( + this.rootStore.agent.service.toString(), + (handle: string) => + this.rootStore.agent + .resolveHandle({handle}) + .then(({data}) => data.did), + ) + runInAction(() => { + this.savedFeeds = saved + this.pinnedFeeds = pinned + }) + res.data.preferences.push({ + $type: 'app.bsky.actor.defs#savedFeedsPref', + saved, + pinned, + }) + await this.rootStore.agent.app.bsky.actor.putPreferences({ + preferences: res.data.preferences, + }) + } + } finally { + this.lock.release() } + + await this.rootStore.me.savedFeeds.updateCache(clearCache) } /** @@ -170,29 +185,44 @@ export class PreferencesModel { * argument and if the callback returns false, the preferences are not updated. * @returns void */ - async update(cb: (prefs: AppBskyActorDefs.Preferences) => boolean | void) { - const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) - if (cb(res.data.preferences) === false) { - return + async update( + cb: ( + prefs: AppBskyActorDefs.Preferences, + ) => AppBskyActorDefs.Preferences | false, + ) { + await this.lock.acquireAsync() + try { + const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) + const newPrefs = cb(res.data.preferences) + if (newPrefs === false) { + return + } + await this.rootStore.agent.app.bsky.actor.putPreferences({ + preferences: newPrefs, + }) + } finally { + this.lock.release() } - await this.rootStore.agent.app.bsky.actor.putPreferences({ - preferences: res.data.preferences, - }) } /** * This function resets the preferences to an empty array of no preferences. */ async reset() { - runInAction(() => { - this.contentLabels = new LabelPreferencesModel() - this.contentLanguages = deviceLocales.map(locale => locale.languageCode) - this.savedFeeds = [] - this.pinnedFeeds = [] - }) - await this.rootStore.agent.app.bsky.actor.putPreferences({ - preferences: [], - }) + await this.lock.acquireAsync() + try { + runInAction(() => { + this.contentLabels = new LabelPreferencesModel() + this.contentLanguages = deviceLocales.map(locale => locale.languageCode) + this.savedFeeds = [] + this.pinnedFeeds = [] + }) + await this.rootStore.agent.app.bsky.actor.putPreferences({ + preferences: [], + }) + } finally { + this.lock.release() + } } hasContentLanguage(code2: string) { @@ -231,6 +261,7 @@ export class PreferencesModel { visibility: value, }) } + return prefs }) } @@ -250,6 +281,7 @@ export class PreferencesModel { enabled: v, }) } + return prefs }) } @@ -292,32 +324,31 @@ export class PreferencesModel { return res } - setFeeds(saved: string[], pinned: string[]) { - this.savedFeeds = saved - this.pinnedFeeds = pinned - } - async setSavedFeeds(saved: string[], pinned: string[]) { const oldSaved = this.savedFeeds const oldPinned = this.pinnedFeeds - this.setFeeds(saved, pinned) + this.savedFeeds = saved + this.pinnedFeeds = pinned try { await this.update((prefs: AppBskyActorDefs.Preferences) => { - const existing = prefs.find( + let feedsPref = prefs.find( pref => AppBskyActorDefs.isSavedFeedsPref(pref) && AppBskyActorDefs.validateSavedFeedsPref(pref).success, ) - if (existing) { - existing.saved = saved - existing.pinned = pinned + if (feedsPref) { + feedsPref.saved = saved + feedsPref.pinned = pinned } else { - prefs.push({ + feedsPref = { $type: 'app.bsky.actor.defs#savedFeedsPref', saved, pinned, - }) + } } + return prefs + .filter(pref => !AppBskyActorDefs.isSavedFeedsPref(pref)) + .concat([feedsPref]) }) } catch (e) { runInAction(() => { diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts index 979fddf4..f8266651 100644 --- a/src/state/models/ui/saved-feeds.ts +++ b/src/state/models/ui/saved-feeds.ts @@ -1,5 +1,4 @@ import {makeAutoObservable, runInAction} from 'mobx' -import {AppBskyFeedDefs} from '@atproto/api' import {RootStoreModel} from '../root-store' import {bundleAsync} from 'lib/async/bundle' import {cleanError} from 'lib/strings/errors' @@ -13,7 +12,7 @@ export class SavedFeedsModel { error = '' // data - feeds: CustomFeedModel[] = [] + _feedModelCache: Record = {} constructor(public rootStore: RootStoreModel) { makeAutoObservable( @@ -26,7 +25,7 @@ export class SavedFeedsModel { } get hasContent() { - return this.feeds.length > 0 + return this.all.length > 0 } get hasError() { @@ -39,16 +38,19 @@ export class SavedFeedsModel { get pinned() { return this.rootStore.preferences.pinnedFeeds - .map(uri => this.feeds.find(f => f.uri === uri) as CustomFeedModel) + .map(uri => this._feedModelCache[uri] as CustomFeedModel) .filter(Boolean) } get unpinned() { - return this.feeds.filter(f => !this.isPinned(f)) + return this.rootStore.preferences.savedFeeds + .filter(uri => !this.isPinned(uri)) + .map(uri => this._feedModelCache[uri] as CustomFeedModel) + .filter(Boolean) } get all() { - return this.pinned.concat(this.unpinned) + return [...this.pinned, ...this.unpinned] } get pinnedFeedNames() { @@ -58,31 +60,50 @@ export class SavedFeedsModel { // public api // = - clear() { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = false - this.error = '' - this.feeds = [] - } + /** + * Syncs the cached models against the current state + * - Should only be called by the preferences model after syncing state + */ + updateCache = bundleAsync(async (clearCache?: boolean) => { + let newFeedModels: Record = {} + if (!clearCache) { + newFeedModels = {...this._feedModelCache} + } - refresh = bundleAsync(async (quietRefresh = false) => { - this._xLoading(!quietRefresh) - try { - let feeds: AppBskyFeedDefs.GeneratorView[] = [] - for ( - let i = 0; - i < this.rootStore.preferences.savedFeeds.length; - i += 25 - ) { - const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({ - feeds: this.rootStore.preferences.savedFeeds.slice(i, 25), - }) - feeds = feeds.concat(res.data.feeds) + // collect the feed URIs that havent been synced yet + const neededFeedUris = [] + for (const feedUri of this.rootStore.preferences.savedFeeds) { + if (!(feedUri in newFeedModels)) { + neededFeedUris.push(feedUri) } - runInAction(() => { - this.feeds = feeds.map(f => new CustomFeedModel(this.rootStore, f)) + } + + // fetch the missing models + for (let i = 0; i < neededFeedUris.length; i += 25) { + const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({ + feeds: neededFeedUris.slice(i, 25), }) + for (const feedInfo of res.data.feeds) { + newFeedModels[feedInfo.uri] = new CustomFeedModel( + this.rootStore, + feedInfo, + ) + } + } + + // merge into the cache + runInAction(() => { + this._feedModelCache = newFeedModels + }) + }) + + /** + * Refresh the preferences then reload all feed infos + */ + refresh = bundleAsync(async () => { + this._xLoading(true) + try { + await this.rootStore.preferences.sync({clearCache: true}) this._xIdle() } catch (e: any) { this._xIdle(e) @@ -92,12 +113,7 @@ export class SavedFeedsModel { async save(feed: CustomFeedModel) { try { await feed.save() - runInAction(() => { - this.feeds = [ - ...this.feeds, - new CustomFeedModel(this.rootStore, feed.data), - ] - }) + await this.updateCache() } catch (e: any) { this.rootStore.log.error('Failed to save feed', e) } @@ -110,9 +126,6 @@ export class SavedFeedsModel { await this.rootStore.preferences.removePinnedFeed(uri) } await feed.unsave() - runInAction(() => { - this.feeds = this.feeds.filter(f => f.data.uri !== uri) - }) } catch (e: any) { this.rootStore.log.error('Failed to unsave feed', e) } diff --git a/src/view/screens/DiscoverFeeds.tsx b/src/view/screens/DiscoverFeeds.tsx index cd32ec65..98f164a6 100644 --- a/src/view/screens/DiscoverFeeds.tsx +++ b/src/view/screens/DiscoverFeeds.tsx @@ -29,8 +29,8 @@ export const DiscoverFeedsScreen = withAuthRequired( ) const onRefresh = React.useCallback(() => { - store.me.savedFeeds.refresh() - }, [store]) + feeds.refresh() + }, [feeds]) const renderListEmptyComponent = React.useCallback(() => { return (