Skip to content

Commit

Permalink
Add "follows you" information and sync follow state between views (#215)
Browse files Browse the repository at this point in the history
* Bump @atproto/api@0.1.2 and update API usage

* Add 'follows you' pill to profile header (close #110)

* Add 'follows you' to followers and follows (close #103)

* Update reposted-by and liked-by views to use the same components as followers and following

* Create a local follows cache MyFollowsModel to keep views in sync (close #205)

* Add incremental hydration to the MyFollows model

* Fix tests

* Update deps

* Fix lint

* Fix to paginated fetches

* Fix reference

* Fix potential state-desync issue
  • Loading branch information
pfrazee authored Feb 16, 2023
1 parent ad4ccad commit 70ec769
Show file tree
Hide file tree
Showing 19 changed files with 370 additions and 515 deletions.
11 changes: 8 additions & 3 deletions __mocks__/state-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {SessionModel} from '../src/state/models/session'
import {NavigationModel} from '../src/state/models/navigation'
import {ShellUiModel} from '../src/state/models/shell-ui'
import {MeModel} from '../src/state/models/me'
import {MyFollowsModel} from '../src/state/models/my-follows'
import {OnboardModel} from '../src/state/models/onboard'
import {ProfilesViewModel} from '../src/state/models/profiles-view'
import {LinkMetasViewModel} from '../src/state/models/link-metas-view'
Expand Down Expand Up @@ -53,9 +54,8 @@ export const mockedProfileStore = {
followsCount: 0,
membersCount: 0,
postsCount: 0,
myState: {
follow: '',
member: '',
viewer: {
following: '',
},
rootStore: {} as RootStoreModel,
hasContent: true,
Expand Down Expand Up @@ -572,6 +572,10 @@ export const mockedShellStore = {
openLightbox: jest.fn(),
} as ShellUiModel

export const mockedFollowsStore = {
isFollowing: jest.fn().mockReturnValue(false),
} as MyFollowsModel

export const mockedMeStore = {
serialize: jest.fn(),
hydrate: jest.fn(),
Expand All @@ -585,6 +589,7 @@ export const mockedMeStore = {
memberships: mockedMembershipsStore,
mainFeed: mockedFeedStore,
notifications: mockedNotificationsStore,
follows: mockedFollowsStore,
clear: jest.fn(),
load: jest.fn(),
clearNotificationCount: jest.fn(),
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"e2e": "detox test --configuration ios.sim.debug --take-screenshots all"
},
"dependencies": {
"@atproto/api": "^0.1.1",
"@atproto/api": "^0.1.2",
"@atproto/lexicon": "^0.0.4",
"@atproto/xrpc": "^0.0.4",
"@bam.tech/react-native-image-resizer": "^3.0.4",
Expand Down Expand Up @@ -80,7 +80,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/pds": "^0.0.2",
"@atproto/pds": "^0.0.3",
"@babel/core": "^7.20.0",
"@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0",
Expand Down
6 changes: 6 additions & 0 deletions src/state/models/me.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import notifee from '@notifee/react-native'
import {RootStoreModel} from './root-store'
import {FeedModel} from './feed-view'
import {NotificationsViewModel} from './notifications-view'
import {MyFollowsModel} from './my-follows'
import {isObj, hasProp} from '../lib/type-guards'
import {displayNotificationFromModel} from '../../view/lib/notifee'

Expand All @@ -15,6 +16,7 @@ export class MeModel {
notificationCount: number = 0
mainFeed: FeedModel
notifications: NotificationsViewModel
follows: MyFollowsModel

constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
Expand All @@ -26,6 +28,7 @@ export class MeModel {
algorithm: 'reverse-chronological',
})
this.notifications = new NotificationsViewModel(this.rootStore, {})
this.follows = new MyFollowsModel(this.rootStore)
}

clear() {
Expand Down Expand Up @@ -104,6 +107,9 @@ export class MeModel {
this.notifications.setup().catch(e => {
this.rootStore.log.error('Failed to setup notifications model', e)
}),
this.follows.fetch().catch(e => {
this.rootStore.log.error('Failed to load my follows', e)
}),
])

// request notifications permission once the user has logged in
Expand Down
109 changes: 109 additions & 0 deletions src/state/models/my-follows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} 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 =
| AppBskyActorProfile.ViewBasic
| AppBskyActorProfile.View
| AppBskyActorRef.WithInfo

/**
* This model is used to maintain a synced local cache of the user's
* follows. It should be periodically refreshed and updated any time
* the user makes a change to their follows.
*/
export class MyFollowsModel {
// data
followDidToRecordMap: Record<string, string> = {}
lastSync = 0

constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}

// public api
// =

fetchIfNeeded = bundleAsync(async () => {
if (
Object.keys(this.followDidToRecordMap).length === 0 ||
Date.now() - this.lastSync > CACHE_TTL
) {
return await this.fetch()
}
})

fetch = bundleAsync(async () => {
this.rootStore.log.debug('MyFollowsModel:fetch running full fetch')
let before
let records: FollowsListResponseRecord[] = []
do {
const res: FollowsListResponse =
await this.rootStore.api.app.bsky.graph.follow.list({
user: this.rootStore.me.did,
before,
})
records = records.concat(res.records)
before = res.cursor
} while (typeof before !== 'undefined')
runInAction(() => {
this.followDidToRecordMap = {}
for (const record of records) {
this.followDidToRecordMap[record.value.subject.did] = record.uri
}
this.lastSync = Date.now()
})
})

isFollowing(did: string) {
return !!this.followDidToRecordMap[did]
}

getFollowUri(did: string): string {
const v = this.followDidToRecordMap[did]
if (!v) {
throw new Error('Not a followed user')
}
return v
}

addFollow(did: string, recordUri: string) {
this.followDidToRecordMap[did] = recordUri
}

removeFollow(did: string) {
delete this.followDidToRecordMap[did]
}

/**
* Use this to incrementally update the cache as views provide information
*/
hydrate(did: string, recordUri: string | undefined) {
if (recordUri) {
this.followDidToRecordMap[did] = recordUri
} else {
delete this.followDidToRecordMap[did]
}
}

/**
* Use this to incrementally update the cache as views provide information
*/
hydrateProfiles(profiles: Profile[]) {
for (const profile of profiles) {
if (profile.viewer) {
this.hydrate(profile.did, profile.viewer.following)
}
}
}
}
38 changes: 27 additions & 11 deletions src/state/models/profile-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ import {cleanError} from '../../lib/strings'

export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'

export class ProfileViewMyStateModel {
follow?: string
export class ProfileViewViewerModel {
muted?: boolean
following?: string
followedBy?: string

constructor() {
makeAutoObservable(this)
Expand Down Expand Up @@ -47,7 +48,7 @@ export class ProfileViewModel {
followersCount: number = 0
followsCount: number = 0
postsCount: number = 0
myState = new ProfileViewMyStateModel()
viewer = new ProfileViewViewerModel()

// added data
descriptionEntities?: Entity[]
Expand Down Expand Up @@ -98,11 +99,24 @@ export class ProfileViewModel {
if (!this.rootStore.me.did) {
throw new Error('Not logged in')
}
if (this.myState.follow) {
await apilib.unfollow(this.rootStore, this.myState.follow)

const follows = this.rootStore.me.follows
const followUri = follows.isFollowing(this.did)
? follows.getFollowUri(this.did)
: undefined

// guard against this view getting out of sync with the follows cache
if (followUri !== this.viewer.following) {
this.viewer.following = followUri
return
}

if (followUri) {
await apilib.unfollow(this.rootStore, followUri)
runInAction(() => {
this.followersCount--
this.myState.follow = undefined
this.viewer.following = undefined
this.rootStore.me.follows.removeFollow(this.did)
})
} else {
const res = await apilib.follow(
Expand All @@ -112,7 +126,8 @@ export class ProfileViewModel {
)
runInAction(() => {
this.followersCount++
this.myState.follow = res.uri
this.viewer.following = res.uri
this.rootStore.me.follows.addFollow(this.did, res.uri)
})
}
}
Expand Down Expand Up @@ -153,13 +168,13 @@ export class ProfileViewModel {

async muteAccount() {
await this.rootStore.api.app.bsky.graph.mute({user: this.did})
this.myState.muted = true
this.viewer.muted = true
await this.refresh()
}

async unmuteAccount() {
await this.rootStore.api.app.bsky.graph.unmute({user: this.did})
this.myState.muted = false
this.viewer.muted = false
await this.refresh()
}

Expand Down Expand Up @@ -211,8 +226,9 @@ export class ProfileViewModel {
this.followersCount = res.data.followersCount
this.followsCount = res.data.followsCount
this.postsCount = res.data.postsCount
if (res.data.myState) {
Object.assign(this.myState, res.data.myState)
if (res.data.viewer) {
Object.assign(this.viewer, res.data.viewer)
this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following)
}
this.descriptionEntities = extractEntities(this.description || '')
}
Expand Down
8 changes: 6 additions & 2 deletions src/state/models/reposted-by-view.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {AtUri} from '../../third-party/uri'
import {AppBskyFeedGetRepostedBy as GetRepostedBy} from '@atproto/api'
import {
AppBskyFeedGetRepostedBy as GetRepostedBy,
AppBskyActorRef as ActorRef,
} from '@atproto/api'
import {RootStoreModel} from './root-store'
import {bundleAsync} from '../../lib/async/bundle'
import {cleanError} from '../../lib/strings'
import * as apilib from '../lib/api'

const PAGE_SIZE = 30

export type RepostedByItem = GetRepostedBy.RepostedBy
export type RepostedByItem = ActorRef.WithInfo

export class RepostedByViewModel {
// state
Expand Down Expand Up @@ -127,5 +130,6 @@ export class RepostedByViewModel {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.repostedBy = this.repostedBy.concat(res.data.repostedBy)
this.rootStore.me.follows.hydrateProfiles(res.data.repostedBy)
}
}
1 change: 1 addition & 0 deletions src/state/models/root-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export class RootStoreModel {
}
try {
await this.me.fetchNotifications()
await this.me.follows.fetchIfNeeded()
} catch (e: any) {
this.log.error('Failed to fetch latest state', e)
}
Expand Down
20 changes: 11 additions & 9 deletions src/state/models/suggested-actors-view.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {
AppBskyActorGetSuggestions as GetSuggestions,
AppBskyActorProfile as Profile,
} from '@atproto/api'
import {AppBskyActorProfile as Profile} from '@atproto/api'
import {RootStoreModel} from './root-store'
import {cleanError} from '../../lib/strings'
import {bundleAsync} from '../../lib/async/bundle'
Expand All @@ -14,7 +11,7 @@ import {

const PAGE_SIZE = 30

export type SuggestedActor = GetSuggestions.Actor | Profile.View
export type SuggestedActor = Profile.ViewBasic | Profile.View

const getSuggestionList = ({serviceUrl}: {serviceUrl: string}) => {
if (serviceUrl.includes('localhost')) {
Expand Down Expand Up @@ -142,10 +139,15 @@ export class SuggestedActorsViewModel {
} while (actors.length)

runInAction(() => {
this.hardCodedSuggestions = profiles.filter(
profile =>
!profile.myState?.follow && profile.did !== this.rootStore.me.did,
)
this.hardCodedSuggestions = profiles.filter(profile => {
if (this.rootStore.me.follows.isFollowing(profile.did)) {
return false
}
if (profile.did === this.rootStore.me.did) {
return false
}
return true
})
})
} catch (e) {
this.rootStore.log.error(
Expand Down
3 changes: 2 additions & 1 deletion src/state/models/user-followers-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {bundleAsync} from '../../lib/async/bundle'

const PAGE_SIZE = 30

export type FollowerItem = GetFollowers.Follower
export type FollowerItem = ActorRef.WithInfo

export class UserFollowersViewModel {
// state
Expand Down Expand Up @@ -116,5 +116,6 @@ export class UserFollowersViewModel {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.followers = this.followers.concat(res.data.followers)
this.rootStore.me.follows.hydrateProfiles(res.data.followers)
}
}
3 changes: 2 additions & 1 deletion src/state/models/user-follows-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {bundleAsync} from '../../lib/async/bundle'

const PAGE_SIZE = 30

export type FollowItem = GetFollows.Follow
export type FollowItem = ActorRef.WithInfo

export class UserFollowsViewModel {
// state
Expand Down Expand Up @@ -116,5 +116,6 @@ export class UserFollowsViewModel {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.follows = this.follows.concat(res.data.follows)
this.rootStore.me.follows.hydrateProfiles(res.data.follows)
}
}
Loading

0 comments on commit 70ec769

Please sign in to comment.