diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-04-03 19:50:46 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-04-03 19:50:46 -0500 |
commit | 25cc5b997f07daaeb64ef434992cb95892546ff5 (patch) | |
tree | 09531de05bbce7edddc9b5d48cb4cd56bdabcf90 | |
parent | 50f7f9877ff000fac3a22d4432253f91657b7e61 (diff) | |
download | voidsky-25cc5b997f07daaeb64ef434992cb95892546ff5.tar.zst |
Rework the me.follows cache to reduce network load (#384)
-rw-r--r-- | src/state/models/cache/my-follows.ts | 83 | ||||
-rw-r--r-- | src/state/models/content/profile.ts | 8 | ||||
-rw-r--r-- | src/state/models/discovery/foafs.ts | 25 | ||||
-rw-r--r-- | src/state/models/discovery/suggested-actors.ts | 5 | ||||
-rw-r--r-- | src/state/models/feeds/posts.ts | 4 | ||||
-rw-r--r-- | src/state/models/lists/likes.ts | 3 | ||||
-rw-r--r-- | src/state/models/me.ts | 3 | ||||
-rw-r--r-- | src/state/models/root-store.ts | 1 | ||||
-rw-r--r-- | src/state/models/ui/search.ts | 3 | ||||
-rw-r--r-- | src/view/com/profile/FollowButton.tsx | 17 | ||||
-rw-r--r-- | src/view/com/profile/ProfileHeader.tsx | 4 | ||||
-rw-r--r-- | src/view/com/util/PostMeta.tsx | 14 | ||||
-rw-r--r-- | src/view/screens/Home.tsx | 2 |
13 files changed, 97 insertions, 75 deletions
diff --git a/src/state/models/cache/my-follows.ts b/src/state/models/cache/my-follows.ts index eaab829bc..10f88c4a9 100644 --- a/src/state/models/cache/my-follows.ts +++ b/src/state/models/cache/my-follows.ts @@ -1,13 +1,15 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {FollowRecord, AppBskyActorDefs} from '@atproto/api' +import {makeAutoObservable} from 'mobx' +import {AppBskyActorDefs} from '@atproto/api' import {RootStoreModel} from '../root-store' -import {bundleAsync} from 'lib/async/bundle' -const CACHE_TTL = 1000 * 60 * 60 // hourly -type FollowsListResponse = Awaited<ReturnType<FollowRecord['list']>> -type FollowsListResponseRecord = FollowsListResponse['records'][0] type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView +export enum FollowState { + Following, + NotFollowing, + Unknown, +} + /** * This model is used to maintain a synced local cache of the user's * follows. It should be periodically refreshed and updated any time @@ -15,7 +17,7 @@ type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView */ export class MyFollowsCache { // data - followDidToRecordMap: Record<string, string> = {} + followDidToRecordMap: Record<string, string | boolean> = {} lastSync = 0 myDid?: string @@ -38,58 +40,33 @@ export class MyFollowsCache { this.myDid = undefined } - fetchIfNeeded = bundleAsync(async () => { - if ( - this.myDid !== this.rootStore.me.did || - Object.keys(this.followDidToRecordMap).length === 0 || - Date.now() - this.lastSync > CACHE_TTL - ) { - return await this.fetch() + getFollowState(did: string): FollowState { + if (typeof this.followDidToRecordMap[did] === 'undefined') { + return FollowState.Unknown } - }) - - fetch = bundleAsync(async () => { - this.rootStore.log.debug('MyFollowsModel:fetch running full fetch') - let rkeyStart - let records: FollowsListResponseRecord[] = [] - do { - const res: FollowsListResponse = - await this.rootStore.agent.app.bsky.graph.follow.list({ - repo: this.rootStore.me.did, - rkeyStart, - reverse: true, - }) - records = records.concat(res.records) - rkeyStart = res.cursor - } while (typeof rkeyStart !== 'undefined') - runInAction(() => { - this.followDidToRecordMap = {} - for (const record of records) { - this.followDidToRecordMap[record.value.subject] = record.uri - } - this.lastSync = Date.now() - this.myDid = this.rootStore.me.did - }) - }) - - isFollowing(did: string) { - return !!this.followDidToRecordMap[did] - } - - get numFollows() { - return Object.keys(this.followDidToRecordMap).length + if (typeof this.followDidToRecordMap[did] === 'string') { + return FollowState.Following + } + return FollowState.NotFollowing } - get isEmpty() { - return Object.keys(this.followDidToRecordMap).length === 0 + async fetchFollowState(did: string): Promise<FollowState> { + // TODO: can we get a more efficient method for this? getProfile fetches more data than we need -prf + const res = await this.rootStore.agent.getProfile({actor: did}) + if (res.data.viewer?.following) { + this.addFollow(did, res.data.viewer.following) + } else { + this.removeFollow(did) + } + return this.getFollowState(did) } getFollowUri(did: string): string { const v = this.followDidToRecordMap[did] - if (!v) { - throw new Error('Not a followed user') + if (typeof v === 'string') { + return v } - return v + throw new Error('Not a followed user') } addFollow(did: string, recordUri: string) { @@ -97,7 +74,7 @@ export class MyFollowsCache { } removeFollow(did: string) { - delete this.followDidToRecordMap[did] + this.followDidToRecordMap[did] = false } /** @@ -107,7 +84,7 @@ export class MyFollowsCache { if (recordUri) { this.followDidToRecordMap[did] = recordUri } else { - delete this.followDidToRecordMap[did] + this.followDidToRecordMap[did] = false } } diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index 08616bf18..8d9c71b39 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -8,6 +8,7 @@ import { import {RootStoreModel} from '../root-store' import * as apilib from 'lib/api/index' import {cleanError} from 'lib/strings/errors' +import {FollowState} from '../cache/my-follows' export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser' @@ -89,9 +90,10 @@ export class ProfileModel { } const follows = this.rootStore.me.follows - const followUri = follows.isFollowing(this.did) - ? follows.getFollowUri(this.did) - : undefined + const followUri = + (await follows.fetchFollowState(this.did)) === FollowState.Following + ? follows.getFollowUri(this.did) + : undefined // guard against this view getting out of sync with the follows cache if (followUri !== this.viewer.following) { diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts index 27cee8503..8dac2ec2d 100644 --- a/src/state/models/discovery/foafs.ts +++ b/src/state/models/discovery/foafs.ts @@ -38,7 +38,24 @@ export class FoafsModel { fetch = bundleAsync(async () => { try { this.isLoading = true - await this.rootStore.me.follows.fetchIfNeeded() + + // fetch & hydrate up to 1000 follows + { + let cursor + for (let i = 0; i < 10; i++) { + const res = await this.rootStore.agent.getFollows({ + actor: this.rootStore.me.did, + cursor, + limit: 100, + }) + this.rootStore.me.follows.hydrateProfiles(res.data.follows) + if (!res.data.cursor) { + break + } + cursor = res.data.cursor + } + } + // grab 10 of the users followed by the user this.sources = sampleSize( Object.keys(this.rootStore.me.follows.followDidToRecordMap), @@ -66,14 +83,16 @@ export class FoafsModel { const popular: RefWithInfoAndFollowers[] = [] for (let i = 0; i < results.length; i++) { const res = results[i] + if (res.status === 'fulfilled') { + this.rootStore.me.follows.hydrateProfiles(res.value.data.follows) + } const profile = profiles.data.profiles[i] const source = this.sources[i] if (res.status === 'fulfilled' && profile) { // filter out users already followed by the user or that *is* the user res.value.data.follows = res.value.data.follows.filter(follow => { return ( - follow.did !== this.rootStore.me.did && - !this.rootStore.me.follows.isFollowing(follow.did) + follow.did !== this.rootStore.me.did && !follow.viewer?.following ) }) diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts index 91c5efd02..dca81dc90 100644 --- a/src/state/models/discovery/suggested-actors.ts +++ b/src/state/models/discovery/suggested-actors.ts @@ -110,7 +110,6 @@ export class SuggestedActorsModel { if (this.hardCodedSuggestions) { return } - await this.rootStore.me.follows.fetchIfNeeded() try { // clone the array so we can mutate it const actors = [ @@ -128,9 +127,11 @@ export class SuggestedActorsModel { profiles = profiles.concat(res.data.profiles) } while (actors.length) + this.rootStore.me.follows.hydrateProfiles(profiles) + runInAction(() => { profiles = profiles.filter(profile => { - if (this.rootStore.me.follows.isFollowing(profile.did)) { + if (profile.viewer?.following) { return false } if (profile.did === this.rootStore.me.did) { diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 0046f9781..8a726ca8b 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -543,6 +543,10 @@ export class PostsFeedModel { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor + this.rootStore.me.follows.hydrateProfiles( + res.data.feed.map(item => item.post.author), + ) + const slices = this.tuner.tune(res.data.feed, this.feedTuners) const toAppend: PostsFeedSliceModel[] = [] diff --git a/src/state/models/lists/likes.ts b/src/state/models/lists/likes.ts index e88389c56..684fd5ee9 100644 --- a/src/state/models/lists/likes.ts +++ b/src/state/models/lists/likes.ts @@ -126,6 +126,9 @@ export class LikesModel { _appendAll(res: GetLikes.Response) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor + this.rootStore.me.follows.hydrateProfiles( + res.data.likes.map(like => like.actor), + ) this.likes = this.likes.concat(res.data.likes) } } diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 26f0849c7..3adbc7c6c 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -104,9 +104,6 @@ export class MeModel { } }) this.mainFeed.clear() - await this.follows.fetch().catch(e => { - this.rootStore.log.error('Failed to load my follows', e) - }) await Promise.all([ this.mainFeed.setup().catch(e => { this.rootStore.log.error('Failed to setup main feed model', e) diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index d4fcbf74e..0d893415f 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -142,7 +142,6 @@ export class RootStoreModel { } try { await this.me.notifications.loadUnreadCount() - await this.me.follows.fetchIfNeeded() } catch (e: any) { this.log.error('Failed to fetch latest state', e) } diff --git a/src/state/models/ui/search.ts b/src/state/models/ui/search.ts index 8436b0984..330283a0b 100644 --- a/src/state/models/ui/search.ts +++ b/src/state/models/ui/search.ts @@ -43,6 +43,9 @@ export class SearchUIModel { profiles = profiles.concat(res.data.profiles) } while (profilesSearch.length) } + + this.rootStore.me.follows.hydrateProfiles(profiles) + runInAction(() => { this.profiles = profiles this.isProfilesLoading = false diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index f22eb9b4a..f799e26f2 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -1,8 +1,10 @@ import React from 'react' +import {View} from 'react-native' import {observer} from 'mobx-react-lite' import {Button, ButtonType} from '../util/forms/Button' import {useStores} from 'state/index' import * as Toast from '../util/Toast' +import {FollowState} from 'state/models/cache/my-follows' const FollowButton = observer( ({ @@ -15,10 +17,15 @@ const FollowButton = observer( onToggleFollow?: (v: boolean) => void }) => { const store = useStores() - const isFollowing = store.me.follows.isFollowing(did) + const followState = store.me.follows.getFollowState(did) + + if (followState === FollowState.Unknown) { + return <View /> + } const onToggleFollowInner = async () => { - if (store.me.follows.isFollowing(did)) { + const updatedFollowState = await store.me.follows.fetchFollowState(did) + if (updatedFollowState === FollowState.Following) { try { await store.agent.deleteFollow(store.me.follows.getFollowUri(did)) store.me.follows.removeFollow(did) @@ -27,7 +34,7 @@ const FollowButton = observer( store.log.error('Failed fo delete follow', e) Toast.show('An issue occurred, please try again.') } - } else { + } else if (updatedFollowState === FollowState.NotFollowing) { try { const res = await store.agent.follow(did) store.me.follows.addFollow(did, res.uri) @@ -41,9 +48,9 @@ const FollowButton = observer( return ( <Button - type={isFollowing ? 'default' : type} + type={followState === FollowState.Following ? 'default' : type} onPress={onToggleFollowInner} - label={isFollowing ? 'Unfollow' : 'Follow'} + label={followState === FollowState.Following ? 'Unfollow' : 'Follow'} /> ) }, diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 878d837c9..36aadb9e2 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -30,6 +30,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics' import {NavigationProp} from 'lib/routes/types' import {isDesktopWeb} from 'platform/detection' +import {FollowState} from 'state/models/cache/my-follows' const BACK_HITSLOP = {left: 30, top: 30, right: 30, bottom: 30} @@ -219,7 +220,8 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({ </TouchableOpacity> ) : ( <> - {store.me.follows.isFollowing(view.did) ? ( + {store.me.follows.getFollowState(view.did) === + FollowState.Following ? ( <TouchableOpacity testID="unfollowBtn" onPress={onPressToggleFollow} diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index a675283b8..870f503f2 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -8,6 +8,7 @@ import {useStores} from 'state/index' import {UserAvatar} from './UserAvatar' import {observer} from 'mobx-react-lite' import FollowButton from '../profile/FollowButton' +import {FollowState} from 'state/models/cache/my-follows' interface PostMetaOpts { authorAvatar?: string @@ -25,15 +26,22 @@ export const PostMeta = observer(function (opts: PostMetaOpts) { const handle = opts.authorHandle const store = useStores() const isMe = opts.did === store.me.did - const isFollowing = - typeof opts.did === 'string' && store.me.follows.isFollowing(opts.did) + const followState = + typeof opts.did === 'string' + ? store.me.follows.getFollowState(opts.did) + : FollowState.Unknown const [didFollow, setDidFollow] = React.useState(false) const onToggleFollow = React.useCallback(() => { setDidFollow(true) }, [setDidFollow]) - if (opts.showFollowBtn && !isMe && (!isFollowing || didFollow) && opts.did) { + if ( + opts.showFollowBtn && + !isMe && + (followState === FollowState.NotFollowing || didFollow) && + opts.did + ) { // two-liner with follow button return ( <View style={styles.metaTwoLine}> diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 1f9abdafa..260df0401 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -71,7 +71,7 @@ export const HomeScreen = withAuthRequired((_opts: Props) => { return <FollowingEmptyState /> }, []) - const initialPage = store.me.follows.isEmpty ? 1 : 0 + const initialPage = store.me.followsCount === 0 ? 1 : 0 return ( <Pager testID="homeScreen" |