Bluesky app fork with some witchin' additions 💫

Fixes to feed preference and state sync [APP-678] (#829)

* Remove extraneous custom-feed health check

* Fixes to custom feed preference sync

* Fix lint

* Fix to how preferences are synced to enable membership modifications

authored by

Paul Frazee and committed by
GitHub
e9c84a19 f416798c

+162 -138
-17
src/state/models/feeds/posts.ts
··· 436 436 } else if (this.feedType === 'home') { 437 437 return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams) 438 438 } else if (this.feedType === 'custom') { 439 - this.checkIfCustomFeedIsOnlineAndValid( 440 - params as GetCustomFeed.QueryParams, 441 - ) 442 439 return this.rootStore.agent.app.bsky.feed.getFeed( 443 440 params as GetCustomFeed.QueryParams, 444 441 ) ··· 446 443 return this.rootStore.agent.getAuthorFeed( 447 444 params as GetAuthorFeed.QueryParams, 448 445 ) 449 - } 450 - } 451 - 452 - private async checkIfCustomFeedIsOnlineAndValid( 453 - params: GetCustomFeed.QueryParams, 454 - ) { 455 - const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({ 456 - feed: params.feed, 457 - }) 458 - if (!res.data.isOnline || !res.data.isValid) { 459 - runInAction(() => { 460 - this.error = 461 - 'This custom feed is not online or may be experiencing issues.' 462 - }) 463 446 } 464 447 } 465 448 }
-3
src/state/models/me.ts
··· 52 52 this.mainFeed.clear() 53 53 this.notifications.clear() 54 54 this.follows.clear() 55 - this.savedFeeds.clear() 56 55 this.did = '' 57 56 this.handle = '' 58 57 this.displayName = '' ··· 114 113 /* dont await */ this.notifications.setup().catch(e => { 115 114 this.rootStore.log.error('Failed to setup notifications model', e) 116 115 }) 117 - /* dont await */ this.savedFeeds.refresh(true) 118 116 this.rootStore.emitSessionLoaded() 119 117 await this.fetchInviteCodes() 120 118 await this.fetchAppPasswords() ··· 124 122 } 125 123 126 124 async updateIfNeeded() { 127 - /* dont await */ this.savedFeeds.refresh(true) 128 125 if (Date.now() - this.lastProfileStateUpdate > PROFILE_UPDATE_INTERVAL) { 129 126 this.rootStore.log.debug('Updating me profile information') 130 127 this.lastProfileStateUpdate = Date.now()
+110 -79
src/state/models/ui/preferences.ts
··· 1 1 import {makeAutoObservable, runInAction} from 'mobx' 2 2 import {getLocales} from 'expo-localization' 3 + import AwaitLock from 'await-lock' 4 + import isEqual from 'lodash.isequal' 3 5 import {isObj, hasProp} from 'lib/type-guards' 4 6 import {RootStoreModel} from '../root-store' 5 7 import {ComAtprotoLabelDefs, AppBskyActorDefs} from '@atproto/api' ··· 50 52 savedFeeds: string[] = [] 51 53 pinnedFeeds: string[] = [] 52 54 55 + // used to linearize async modifications to state 56 + lock = new AwaitLock() 57 + 53 58 constructor(public rootStore: RootStoreModel) { 54 - makeAutoObservable(this, {}, {autoBind: true}) 59 + makeAutoObservable(this, {lock: false}, {autoBind: true}) 55 60 } 56 61 57 62 serialize() { ··· 103 108 /** 104 109 * This function fetches preferences and sets defaults for missing items. 105 110 */ 106 - async sync() { 107 - // fetch preferences 108 - let hasSavedFeedsPref = false 109 - const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) 110 - runInAction(() => { 111 - for (const pref of res.data.preferences) { 112 - if ( 113 - AppBskyActorDefs.isAdultContentPref(pref) && 114 - AppBskyActorDefs.validateAdultContentPref(pref).success 115 - ) { 116 - this.adultContentEnabled = pref.enabled 117 - } else if ( 118 - AppBskyActorDefs.isContentLabelPref(pref) && 119 - AppBskyActorDefs.validateAdultContentPref(pref).success 120 - ) { 111 + async sync({clearCache}: {clearCache?: boolean} = {}) { 112 + await this.lock.acquireAsync() 113 + try { 114 + // fetch preferences 115 + let hasSavedFeedsPref = false 116 + const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) 117 + runInAction(() => { 118 + for (const pref of res.data.preferences) { 121 119 if ( 122 - LABEL_GROUPS.includes(pref.label) && 123 - VISIBILITY_VALUES.includes(pref.visibility) 120 + AppBskyActorDefs.isAdultContentPref(pref) && 121 + AppBskyActorDefs.validateAdultContentPref(pref).success 122 + ) { 123 + this.adultContentEnabled = pref.enabled 124 + } else if ( 125 + AppBskyActorDefs.isContentLabelPref(pref) && 126 + AppBskyActorDefs.validateAdultContentPref(pref).success 127 + ) { 128 + if ( 129 + LABEL_GROUPS.includes(pref.label) && 130 + VISIBILITY_VALUES.includes(pref.visibility) 131 + ) { 132 + this.contentLabels[pref.label as keyof LabelPreferencesModel] = 133 + pref.visibility as LabelPreference 134 + } 135 + } else if ( 136 + AppBskyActorDefs.isSavedFeedsPref(pref) && 137 + AppBskyActorDefs.validateSavedFeedsPref(pref).success 124 138 ) { 125 - this.contentLabels[pref.label as keyof LabelPreferencesModel] = 126 - pref.visibility as LabelPreference 139 + if (!isEqual(this.savedFeeds, pref.saved)) { 140 + this.savedFeeds = pref.saved 141 + } 142 + if (!isEqual(this.pinnedFeeds, pref.pinned)) { 143 + this.pinnedFeeds = pref.pinned 144 + } 145 + hasSavedFeedsPref = true 127 146 } 128 - } else if ( 129 - AppBskyActorDefs.isSavedFeedsPref(pref) && 130 - AppBskyActorDefs.validateSavedFeedsPref(pref).success 131 - ) { 132 - this.savedFeeds = pref.saved 133 - this.pinnedFeeds = pref.pinned 134 - hasSavedFeedsPref = true 135 147 } 148 + }) 149 + 150 + // set defaults on missing items 151 + if (!hasSavedFeedsPref) { 152 + const {saved, pinned} = await DEFAULT_FEEDS( 153 + this.rootStore.agent.service.toString(), 154 + (handle: string) => 155 + this.rootStore.agent 156 + .resolveHandle({handle}) 157 + .then(({data}) => data.did), 158 + ) 159 + runInAction(() => { 160 + this.savedFeeds = saved 161 + this.pinnedFeeds = pinned 162 + }) 163 + res.data.preferences.push({ 164 + $type: 'app.bsky.actor.defs#savedFeedsPref', 165 + saved, 166 + pinned, 167 + }) 168 + await this.rootStore.agent.app.bsky.actor.putPreferences({ 169 + preferences: res.data.preferences, 170 + }) 136 171 } 137 - }) 172 + } finally { 173 + this.lock.release() 174 + } 138 175 139 - // set defaults on missing items 140 - if (!hasSavedFeedsPref) { 141 - const {saved, pinned} = await DEFAULT_FEEDS( 142 - this.rootStore.agent.service.toString(), 143 - (handle: string) => 144 - this.rootStore.agent 145 - .resolveHandle({handle}) 146 - .then(({data}) => data.did), 147 - ) 148 - runInAction(() => { 149 - this.savedFeeds = saved 150 - this.pinnedFeeds = pinned 151 - }) 152 - res.data.preferences.push({ 153 - $type: 'app.bsky.actor.defs#savedFeedsPref', 154 - saved, 155 - pinned, 156 - }) 157 - await this.rootStore.agent.app.bsky.actor.putPreferences({ 158 - preferences: res.data.preferences, 159 - }) 160 - /* dont await */ this.rootStore.me.savedFeeds.refresh() 161 - } 176 + await this.rootStore.me.savedFeeds.updateCache(clearCache) 162 177 } 163 178 164 179 /** ··· 170 185 * argument and if the callback returns false, the preferences are not updated. 171 186 * @returns void 172 187 */ 173 - async update(cb: (prefs: AppBskyActorDefs.Preferences) => boolean | void) { 174 - const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) 175 - if (cb(res.data.preferences) === false) { 176 - return 188 + async update( 189 + cb: ( 190 + prefs: AppBskyActorDefs.Preferences, 191 + ) => AppBskyActorDefs.Preferences | false, 192 + ) { 193 + await this.lock.acquireAsync() 194 + try { 195 + const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) 196 + const newPrefs = cb(res.data.preferences) 197 + if (newPrefs === false) { 198 + return 199 + } 200 + await this.rootStore.agent.app.bsky.actor.putPreferences({ 201 + preferences: newPrefs, 202 + }) 203 + } finally { 204 + this.lock.release() 177 205 } 178 - await this.rootStore.agent.app.bsky.actor.putPreferences({ 179 - preferences: res.data.preferences, 180 - }) 181 206 } 182 207 183 208 /** 184 209 * This function resets the preferences to an empty array of no preferences. 185 210 */ 186 211 async reset() { 187 - runInAction(() => { 188 - this.contentLabels = new LabelPreferencesModel() 189 - this.contentLanguages = deviceLocales.map(locale => locale.languageCode) 190 - this.savedFeeds = [] 191 - this.pinnedFeeds = [] 192 - }) 193 - await this.rootStore.agent.app.bsky.actor.putPreferences({ 194 - preferences: [], 195 - }) 212 + await this.lock.acquireAsync() 213 + try { 214 + runInAction(() => { 215 + this.contentLabels = new LabelPreferencesModel() 216 + this.contentLanguages = deviceLocales.map(locale => locale.languageCode) 217 + this.savedFeeds = [] 218 + this.pinnedFeeds = [] 219 + }) 220 + await this.rootStore.agent.app.bsky.actor.putPreferences({ 221 + preferences: [], 222 + }) 223 + } finally { 224 + this.lock.release() 225 + } 196 226 } 197 227 198 228 hasContentLanguage(code2: string) { ··· 231 261 visibility: value, 232 262 }) 233 263 } 264 + return prefs 234 265 }) 235 266 } 236 267 ··· 250 281 enabled: v, 251 282 }) 252 283 } 284 + return prefs 253 285 }) 254 286 } 255 287 ··· 292 324 return res 293 325 } 294 326 295 - setFeeds(saved: string[], pinned: string[]) { 296 - this.savedFeeds = saved 297 - this.pinnedFeeds = pinned 298 - } 299 - 300 327 async setSavedFeeds(saved: string[], pinned: string[]) { 301 328 const oldSaved = this.savedFeeds 302 329 const oldPinned = this.pinnedFeeds 303 - this.setFeeds(saved, pinned) 330 + this.savedFeeds = saved 331 + this.pinnedFeeds = pinned 304 332 try { 305 333 await this.update((prefs: AppBskyActorDefs.Preferences) => { 306 - const existing = prefs.find( 334 + let feedsPref = prefs.find( 307 335 pref => 308 336 AppBskyActorDefs.isSavedFeedsPref(pref) && 309 337 AppBskyActorDefs.validateSavedFeedsPref(pref).success, 310 338 ) 311 - if (existing) { 312 - existing.saved = saved 313 - existing.pinned = pinned 339 + if (feedsPref) { 340 + feedsPref.saved = saved 341 + feedsPref.pinned = pinned 314 342 } else { 315 - prefs.push({ 343 + feedsPref = { 316 344 $type: 'app.bsky.actor.defs#savedFeedsPref', 317 345 saved, 318 346 pinned, 319 - }) 347 + } 320 348 } 349 + return prefs 350 + .filter(pref => !AppBskyActorDefs.isSavedFeedsPref(pref)) 351 + .concat([feedsPref]) 321 352 }) 322 353 } catch (e) { 323 354 runInAction(() => {
+50 -37
src/state/models/ui/saved-feeds.ts
··· 1 1 import {makeAutoObservable, runInAction} from 'mobx' 2 - import {AppBskyFeedDefs} from '@atproto/api' 3 2 import {RootStoreModel} from '../root-store' 4 3 import {bundleAsync} from 'lib/async/bundle' 5 4 import {cleanError} from 'lib/strings/errors' ··· 13 12 error = '' 14 13 15 14 // data 16 - feeds: CustomFeedModel[] = [] 15 + _feedModelCache: Record<string, CustomFeedModel> = {} 17 16 18 17 constructor(public rootStore: RootStoreModel) { 19 18 makeAutoObservable( ··· 26 25 } 27 26 28 27 get hasContent() { 29 - return this.feeds.length > 0 28 + return this.all.length > 0 30 29 } 31 30 32 31 get hasError() { ··· 39 38 40 39 get pinned() { 41 40 return this.rootStore.preferences.pinnedFeeds 42 - .map(uri => this.feeds.find(f => f.uri === uri) as CustomFeedModel) 41 + .map(uri => this._feedModelCache[uri] as CustomFeedModel) 43 42 .filter(Boolean) 44 43 } 45 44 46 45 get unpinned() { 47 - return this.feeds.filter(f => !this.isPinned(f)) 46 + return this.rootStore.preferences.savedFeeds 47 + .filter(uri => !this.isPinned(uri)) 48 + .map(uri => this._feedModelCache[uri] as CustomFeedModel) 49 + .filter(Boolean) 48 50 } 49 51 50 52 get all() { 51 - return this.pinned.concat(this.unpinned) 53 + return [...this.pinned, ...this.unpinned] 52 54 } 53 55 54 56 get pinnedFeedNames() { ··· 58 60 // public api 59 61 // = 60 62 61 - clear() { 62 - this.isLoading = false 63 - this.isRefreshing = false 64 - this.hasLoaded = false 65 - this.error = '' 66 - this.feeds = [] 67 - } 63 + /** 64 + * Syncs the cached models against the current state 65 + * - Should only be called by the preferences model after syncing state 66 + */ 67 + updateCache = bundleAsync(async (clearCache?: boolean) => { 68 + let newFeedModels: Record<string, CustomFeedModel> = {} 69 + if (!clearCache) { 70 + newFeedModels = {...this._feedModelCache} 71 + } 68 72 69 - refresh = bundleAsync(async (quietRefresh = false) => { 70 - this._xLoading(!quietRefresh) 71 - try { 72 - let feeds: AppBskyFeedDefs.GeneratorView[] = [] 73 - for ( 74 - let i = 0; 75 - i < this.rootStore.preferences.savedFeeds.length; 76 - i += 25 77 - ) { 78 - const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({ 79 - feeds: this.rootStore.preferences.savedFeeds.slice(i, 25), 80 - }) 81 - feeds = feeds.concat(res.data.feeds) 73 + // collect the feed URIs that havent been synced yet 74 + const neededFeedUris = [] 75 + for (const feedUri of this.rootStore.preferences.savedFeeds) { 76 + if (!(feedUri in newFeedModels)) { 77 + neededFeedUris.push(feedUri) 82 78 } 83 - runInAction(() => { 84 - this.feeds = feeds.map(f => new CustomFeedModel(this.rootStore, f)) 79 + } 80 + 81 + // fetch the missing models 82 + for (let i = 0; i < neededFeedUris.length; i += 25) { 83 + const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({ 84 + feeds: neededFeedUris.slice(i, 25), 85 85 }) 86 + for (const feedInfo of res.data.feeds) { 87 + newFeedModels[feedInfo.uri] = new CustomFeedModel( 88 + this.rootStore, 89 + feedInfo, 90 + ) 91 + } 92 + } 93 + 94 + // merge into the cache 95 + runInAction(() => { 96 + this._feedModelCache = newFeedModels 97 + }) 98 + }) 99 + 100 + /** 101 + * Refresh the preferences then reload all feed infos 102 + */ 103 + refresh = bundleAsync(async () => { 104 + this._xLoading(true) 105 + try { 106 + await this.rootStore.preferences.sync({clearCache: true}) 86 107 this._xIdle() 87 108 } catch (e: any) { 88 109 this._xIdle(e) ··· 92 113 async save(feed: CustomFeedModel) { 93 114 try { 94 115 await feed.save() 95 - runInAction(() => { 96 - this.feeds = [ 97 - ...this.feeds, 98 - new CustomFeedModel(this.rootStore, feed.data), 99 - ] 100 - }) 116 + await this.updateCache() 101 117 } catch (e: any) { 102 118 this.rootStore.log.error('Failed to save feed', e) 103 119 } ··· 110 126 await this.rootStore.preferences.removePinnedFeed(uri) 111 127 } 112 128 await feed.unsave() 113 - runInAction(() => { 114 - this.feeds = this.feeds.filter(f => f.data.uri !== uri) 115 - }) 116 129 } catch (e: any) { 117 130 this.rootStore.log.error('Failed to unsave feed', e) 118 131 }
+2 -2
src/view/screens/DiscoverFeeds.tsx
··· 29 29 ) 30 30 31 31 const onRefresh = React.useCallback(() => { 32 - store.me.savedFeeds.refresh() 33 - }, [store]) 32 + feeds.refresh() 33 + }, [feeds]) 34 34 35 35 const renderListEmptyComponent = React.useCallback(() => { 36 36 return (