Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Rework the me.follows cache to reduce network load (#384)

authored by

Paul Frazee and committed by
GitHub
25cc5b99 50f7f987

+97 -75
+30 -53
src/state/models/cache/my-follows.ts
··· 1 - import {makeAutoObservable, runInAction} from 'mobx' 2 - import {FollowRecord, AppBskyActorDefs} from '@atproto/api' 1 + import {makeAutoObservable} from 'mobx' 2 + import {AppBskyActorDefs} from '@atproto/api' 3 3 import {RootStoreModel} from '../root-store' 4 - import {bundleAsync} from 'lib/async/bundle' 5 4 6 - const CACHE_TTL = 1000 * 60 * 60 // hourly 7 - type FollowsListResponse = Awaited<ReturnType<FollowRecord['list']>> 8 - type FollowsListResponseRecord = FollowsListResponse['records'][0] 9 5 type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView 10 6 7 + export enum FollowState { 8 + Following, 9 + NotFollowing, 10 + Unknown, 11 + } 12 + 11 13 /** 12 14 * This model is used to maintain a synced local cache of the user's 13 15 * follows. It should be periodically refreshed and updated any time ··· 15 17 */ 16 18 export class MyFollowsCache { 17 19 // data 18 - followDidToRecordMap: Record<string, string> = {} 20 + followDidToRecordMap: Record<string, string | boolean> = {} 19 21 lastSync = 0 20 22 myDid?: string 21 23 ··· 38 40 this.myDid = undefined 39 41 } 40 42 41 - fetchIfNeeded = bundleAsync(async () => { 42 - if ( 43 - this.myDid !== this.rootStore.me.did || 44 - Object.keys(this.followDidToRecordMap).length === 0 || 45 - Date.now() - this.lastSync > CACHE_TTL 46 - ) { 47 - return await this.fetch() 43 + getFollowState(did: string): FollowState { 44 + if (typeof this.followDidToRecordMap[did] === 'undefined') { 45 + return FollowState.Unknown 46 + } 47 + if (typeof this.followDidToRecordMap[did] === 'string') { 48 + return FollowState.Following 48 49 } 49 - }) 50 - 51 - fetch = bundleAsync(async () => { 52 - this.rootStore.log.debug('MyFollowsModel:fetch running full fetch') 53 - let rkeyStart 54 - let records: FollowsListResponseRecord[] = [] 55 - do { 56 - const res: FollowsListResponse = 57 - await this.rootStore.agent.app.bsky.graph.follow.list({ 58 - repo: this.rootStore.me.did, 59 - rkeyStart, 60 - reverse: true, 61 - }) 62 - records = records.concat(res.records) 63 - rkeyStart = res.cursor 64 - } while (typeof rkeyStart !== 'undefined') 65 - runInAction(() => { 66 - this.followDidToRecordMap = {} 67 - for (const record of records) { 68 - this.followDidToRecordMap[record.value.subject] = record.uri 69 - } 70 - this.lastSync = Date.now() 71 - this.myDid = this.rootStore.me.did 72 - }) 73 - }) 74 - 75 - isFollowing(did: string) { 76 - return !!this.followDidToRecordMap[did] 50 + return FollowState.NotFollowing 77 51 } 78 52 79 - get numFollows() { 80 - return Object.keys(this.followDidToRecordMap).length 81 - } 82 - 83 - get isEmpty() { 84 - return Object.keys(this.followDidToRecordMap).length === 0 53 + async fetchFollowState(did: string): Promise<FollowState> { 54 + // TODO: can we get a more efficient method for this? getProfile fetches more data than we need -prf 55 + const res = await this.rootStore.agent.getProfile({actor: did}) 56 + if (res.data.viewer?.following) { 57 + this.addFollow(did, res.data.viewer.following) 58 + } else { 59 + this.removeFollow(did) 60 + } 61 + return this.getFollowState(did) 85 62 } 86 63 87 64 getFollowUri(did: string): string { 88 65 const v = this.followDidToRecordMap[did] 89 - if (!v) { 90 - throw new Error('Not a followed user') 66 + if (typeof v === 'string') { 67 + return v 91 68 } 92 - return v 69 + throw new Error('Not a followed user') 93 70 } 94 71 95 72 addFollow(did: string, recordUri: string) { ··· 97 74 } 98 75 99 76 removeFollow(did: string) { 100 - delete this.followDidToRecordMap[did] 77 + this.followDidToRecordMap[did] = false 101 78 } 102 79 103 80 /** ··· 107 84 if (recordUri) { 108 85 this.followDidToRecordMap[did] = recordUri 109 86 } else { 110 - delete this.followDidToRecordMap[did] 87 + this.followDidToRecordMap[did] = false 111 88 } 112 89 } 113 90
+5 -3
src/state/models/content/profile.ts
··· 8 8 import {RootStoreModel} from '../root-store' 9 9 import * as apilib from 'lib/api/index' 10 10 import {cleanError} from 'lib/strings/errors' 11 + import {FollowState} from '../cache/my-follows' 11 12 12 13 export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser' 13 14 ··· 89 90 } 90 91 91 92 const follows = this.rootStore.me.follows 92 - const followUri = follows.isFollowing(this.did) 93 - ? follows.getFollowUri(this.did) 94 - : undefined 93 + const followUri = 94 + (await follows.fetchFollowState(this.did)) === FollowState.Following 95 + ? follows.getFollowUri(this.did) 96 + : undefined 95 97 96 98 // guard against this view getting out of sync with the follows cache 97 99 if (followUri !== this.viewer.following) {
+22 -3
src/state/models/discovery/foafs.ts
··· 38 38 fetch = bundleAsync(async () => { 39 39 try { 40 40 this.isLoading = true 41 - await this.rootStore.me.follows.fetchIfNeeded() 41 + 42 + // fetch & hydrate up to 1000 follows 43 + { 44 + let cursor 45 + for (let i = 0; i < 10; i++) { 46 + const res = await this.rootStore.agent.getFollows({ 47 + actor: this.rootStore.me.did, 48 + cursor, 49 + limit: 100, 50 + }) 51 + this.rootStore.me.follows.hydrateProfiles(res.data.follows) 52 + if (!res.data.cursor) { 53 + break 54 + } 55 + cursor = res.data.cursor 56 + } 57 + } 58 + 42 59 // grab 10 of the users followed by the user 43 60 this.sources = sampleSize( 44 61 Object.keys(this.rootStore.me.follows.followDidToRecordMap), ··· 66 83 const popular: RefWithInfoAndFollowers[] = [] 67 84 for (let i = 0; i < results.length; i++) { 68 85 const res = results[i] 86 + if (res.status === 'fulfilled') { 87 + this.rootStore.me.follows.hydrateProfiles(res.value.data.follows) 88 + } 69 89 const profile = profiles.data.profiles[i] 70 90 const source = this.sources[i] 71 91 if (res.status === 'fulfilled' && profile) { 72 92 // filter out users already followed by the user or that *is* the user 73 93 res.value.data.follows = res.value.data.follows.filter(follow => { 74 94 return ( 75 - follow.did !== this.rootStore.me.did && 76 - !this.rootStore.me.follows.isFollowing(follow.did) 95 + follow.did !== this.rootStore.me.did && !follow.viewer?.following 77 96 ) 78 97 }) 79 98
+3 -2
src/state/models/discovery/suggested-actors.ts
··· 110 110 if (this.hardCodedSuggestions) { 111 111 return 112 112 } 113 - await this.rootStore.me.follows.fetchIfNeeded() 114 113 try { 115 114 // clone the array so we can mutate it 116 115 const actors = [ ··· 128 127 profiles = profiles.concat(res.data.profiles) 129 128 } while (actors.length) 130 129 130 + this.rootStore.me.follows.hydrateProfiles(profiles) 131 + 131 132 runInAction(() => { 132 133 profiles = profiles.filter(profile => { 133 - if (this.rootStore.me.follows.isFollowing(profile.did)) { 134 + if (profile.viewer?.following) { 134 135 return false 135 136 } 136 137 if (profile.did === this.rootStore.me.did) {
+4
src/state/models/feeds/posts.ts
··· 543 543 this.loadMoreCursor = res.data.cursor 544 544 this.hasMore = !!this.loadMoreCursor 545 545 546 + this.rootStore.me.follows.hydrateProfiles( 547 + res.data.feed.map(item => item.post.author), 548 + ) 549 + 546 550 const slices = this.tuner.tune(res.data.feed, this.feedTuners) 547 551 548 552 const toAppend: PostsFeedSliceModel[] = []
+3
src/state/models/lists/likes.ts
··· 126 126 _appendAll(res: GetLikes.Response) { 127 127 this.loadMoreCursor = res.data.cursor 128 128 this.hasMore = !!this.loadMoreCursor 129 + this.rootStore.me.follows.hydrateProfiles( 130 + res.data.likes.map(like => like.actor), 131 + ) 129 132 this.likes = this.likes.concat(res.data.likes) 130 133 } 131 134 }
-3
src/state/models/me.ts
··· 104 104 } 105 105 }) 106 106 this.mainFeed.clear() 107 - await this.follows.fetch().catch(e => { 108 - this.rootStore.log.error('Failed to load my follows', e) 109 - }) 110 107 await Promise.all([ 111 108 this.mainFeed.setup().catch(e => { 112 109 this.rootStore.log.error('Failed to setup main feed model', e)
-1
src/state/models/root-store.ts
··· 142 142 } 143 143 try { 144 144 await this.me.notifications.loadUnreadCount() 145 - await this.me.follows.fetchIfNeeded() 146 145 } catch (e: any) { 147 146 this.log.error('Failed to fetch latest state', e) 148 147 }
+3
src/state/models/ui/search.ts
··· 43 43 profiles = profiles.concat(res.data.profiles) 44 44 } while (profilesSearch.length) 45 45 } 46 + 47 + this.rootStore.me.follows.hydrateProfiles(profiles) 48 + 46 49 runInAction(() => { 47 50 this.profiles = profiles 48 51 this.isProfilesLoading = false
+12 -5
src/view/com/profile/FollowButton.tsx
··· 1 1 import React from 'react' 2 + import {View} from 'react-native' 2 3 import {observer} from 'mobx-react-lite' 3 4 import {Button, ButtonType} from '../util/forms/Button' 4 5 import {useStores} from 'state/index' 5 6 import * as Toast from '../util/Toast' 7 + import {FollowState} from 'state/models/cache/my-follows' 6 8 7 9 const FollowButton = observer( 8 10 ({ ··· 15 17 onToggleFollow?: (v: boolean) => void 16 18 }) => { 17 19 const store = useStores() 18 - const isFollowing = store.me.follows.isFollowing(did) 20 + const followState = store.me.follows.getFollowState(did) 21 + 22 + if (followState === FollowState.Unknown) { 23 + return <View /> 24 + } 19 25 20 26 const onToggleFollowInner = async () => { 21 - if (store.me.follows.isFollowing(did)) { 27 + const updatedFollowState = await store.me.follows.fetchFollowState(did) 28 + if (updatedFollowState === FollowState.Following) { 22 29 try { 23 30 await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) 24 31 store.me.follows.removeFollow(did) ··· 27 34 store.log.error('Failed fo delete follow', e) 28 35 Toast.show('An issue occurred, please try again.') 29 36 } 30 - } else { 37 + } else if (updatedFollowState === FollowState.NotFollowing) { 31 38 try { 32 39 const res = await store.agent.follow(did) 33 40 store.me.follows.addFollow(did, res.uri) ··· 41 48 42 49 return ( 43 50 <Button 44 - type={isFollowing ? 'default' : type} 51 + type={followState === FollowState.Following ? 'default' : type} 45 52 onPress={onToggleFollowInner} 46 - label={isFollowing ? 'Unfollow' : 'Follow'} 53 + label={followState === FollowState.Following ? 'Unfollow' : 'Follow'} 47 54 /> 48 55 ) 49 56 },
+3 -1
src/view/com/profile/ProfileHeader.tsx
··· 30 30 import {useAnalytics} from 'lib/analytics' 31 31 import {NavigationProp} from 'lib/routes/types' 32 32 import {isDesktopWeb} from 'platform/detection' 33 + import {FollowState} from 'state/models/cache/my-follows' 33 34 34 35 const BACK_HITSLOP = {left: 30, top: 30, right: 30, bottom: 30} 35 36 ··· 219 220 </TouchableOpacity> 220 221 ) : ( 221 222 <> 222 - {store.me.follows.isFollowing(view.did) ? ( 223 + {store.me.follows.getFollowState(view.did) === 224 + FollowState.Following ? ( 223 225 <TouchableOpacity 224 226 testID="unfollowBtn" 225 227 onPress={onPressToggleFollow}
+11 -3
src/view/com/util/PostMeta.tsx
··· 8 8 import {UserAvatar} from './UserAvatar' 9 9 import {observer} from 'mobx-react-lite' 10 10 import FollowButton from '../profile/FollowButton' 11 + import {FollowState} from 'state/models/cache/my-follows' 11 12 12 13 interface PostMetaOpts { 13 14 authorAvatar?: string ··· 25 26 const handle = opts.authorHandle 26 27 const store = useStores() 27 28 const isMe = opts.did === store.me.did 28 - const isFollowing = 29 - typeof opts.did === 'string' && store.me.follows.isFollowing(opts.did) 29 + const followState = 30 + typeof opts.did === 'string' 31 + ? store.me.follows.getFollowState(opts.did) 32 + : FollowState.Unknown 30 33 31 34 const [didFollow, setDidFollow] = React.useState(false) 32 35 const onToggleFollow = React.useCallback(() => { 33 36 setDidFollow(true) 34 37 }, [setDidFollow]) 35 38 36 - if (opts.showFollowBtn && !isMe && (!isFollowing || didFollow) && opts.did) { 39 + if ( 40 + opts.showFollowBtn && 41 + !isMe && 42 + (followState === FollowState.NotFollowing || didFollow) && 43 + opts.did 44 + ) { 37 45 // two-liner with follow button 38 46 return ( 39 47 <View style={styles.metaTwoLine}>
+1 -1
src/view/screens/Home.tsx
··· 71 71 return <FollowingEmptyState /> 72 72 }, []) 73 73 74 - const initialPage = store.me.follows.isEmpty ? 1 : 0 74 + const initialPage = store.me.followsCount === 0 ? 1 : 0 75 75 return ( 76 76 <Pager 77 77 testID="homeScreen"