about summary refs log tree commit diff
path: root/src/state/models
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/models')
-rw-r--r--src/state/models/cache/my-follows.ts104
-rw-r--r--src/state/models/content/profile.ts4
-rw-r--r--src/state/models/discovery/foafs.ts36
-rw-r--r--src/state/models/discovery/onboarding.ts1
-rw-r--r--src/state/models/discovery/suggested-actors.ts4
-rw-r--r--src/state/models/discovery/user-autocomplete.ts115
-rw-r--r--src/state/models/feeds/post.ts14
-rw-r--r--src/state/models/feeds/posts.ts8
-rw-r--r--src/state/models/invited-users.ts2
-rw-r--r--src/state/models/lists/likes.ts2
-rw-r--r--src/state/models/lists/reposted-by.ts2
-rw-r--r--src/state/models/lists/user-followers.ts2
-rw-r--r--src/state/models/lists/user-follows.ts2
-rw-r--r--src/state/models/media/image.ts2
-rw-r--r--src/state/models/ui/preferences.ts111
-rw-r--r--src/state/models/ui/reminders.e2e.ts24
-rw-r--r--src/state/models/ui/reminders.ts19
-rw-r--r--src/state/models/ui/search.ts2
18 files changed, 292 insertions, 162 deletions
diff --git a/src/state/models/cache/my-follows.ts b/src/state/models/cache/my-follows.ts
index 10f88c4a9..e1e8af509 100644
--- a/src/state/models/cache/my-follows.ts
+++ b/src/state/models/cache/my-follows.ts
@@ -1,6 +1,14 @@
 import {makeAutoObservable} from 'mobx'
-import {AppBskyActorDefs} from '@atproto/api'
+import {
+  AppBskyActorDefs,
+  AppBskyGraphGetFollows as GetFollows,
+  moderateProfile,
+} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
+import {bundleAsync} from 'lib/async/bundle'
+
+const MAX_SYNC_PAGES = 10
+const SYNC_TTL = 60e3 * 10 // 10 minutes
 
 type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView
 
@@ -10,6 +18,14 @@ export enum FollowState {
   Unknown,
 }
 
+export interface FollowInfo {
+  did: string
+  followRecordUri: string | undefined
+  handle: string
+  displayName: string | undefined
+  avatar: string | undefined
+}
+
 /**
  * This model is used to maintain a synced local cache of the user's
  * follows. It should be periodically refreshed and updated any time
@@ -17,9 +33,8 @@ export enum FollowState {
  */
 export class MyFollowsCache {
   // data
-  followDidToRecordMap: Record<string, string | boolean> = {}
+  byDid: Record<string, FollowInfo> = {}
   lastSync = 0
-  myDid?: string
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(
@@ -35,16 +50,45 @@ export class MyFollowsCache {
   // =
 
   clear() {
-    this.followDidToRecordMap = {}
-    this.lastSync = 0
-    this.myDid = undefined
+    this.byDid = {}
   }
 
+  /**
+   * Syncs a subset of the user's follows
+   * for performance reasons, caps out at 1000 follows
+   */
+  syncIfNeeded = bundleAsync(async () => {
+    if (this.lastSync > Date.now() - SYNC_TTL) {
+      return
+    }
+
+    let cursor
+    for (let i = 0; i < MAX_SYNC_PAGES; i++) {
+      const res: GetFollows.Response = await this.rootStore.agent.getFollows({
+        actor: this.rootStore.me.did,
+        cursor,
+        limit: 100,
+      })
+      res.data.follows = res.data.follows.filter(
+        profile =>
+          !moderateProfile(profile, this.rootStore.preferences.moderationOpts)
+            .account.filter,
+      )
+      this.hydrateMany(res.data.follows)
+      if (!res.data.cursor) {
+        break
+      }
+      cursor = res.data.cursor
+    }
+
+    this.lastSync = Date.now()
+  })
+
   getFollowState(did: string): FollowState {
-    if (typeof this.followDidToRecordMap[did] === 'undefined') {
+    if (typeof this.byDid[did] === 'undefined') {
       return FollowState.Unknown
     }
-    if (typeof this.followDidToRecordMap[did] === 'string') {
+    if (typeof this.byDid[did].followRecordUri === 'string') {
       return FollowState.Following
     }
     return FollowState.NotFollowing
@@ -53,49 +97,41 @@ export class MyFollowsCache {
   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)
-    }
+    this.hydrate(did, res.data)
     return this.getFollowState(did)
   }
 
   getFollowUri(did: string): string {
-    const v = this.followDidToRecordMap[did]
-    if (typeof v === 'string') {
-      return v
+    const v = this.byDid[did]
+    if (v && typeof v.followRecordUri === 'string') {
+      return v.followRecordUri
     }
     throw new Error('Not a followed user')
   }
 
-  addFollow(did: string, recordUri: string) {
-    this.followDidToRecordMap[did] = recordUri
+  addFollow(did: string, info: FollowInfo) {
+    this.byDid[did] = info
   }
 
   removeFollow(did: string) {
-    this.followDidToRecordMap[did] = false
+    if (this.byDid[did]) {
+      this.byDid[did].followRecordUri = undefined
+    }
   }
 
-  /**
-   * Use this to incrementally update the cache as views provide information
-   */
-  hydrate(did: string, recordUri: string | undefined) {
-    if (recordUri) {
-      this.followDidToRecordMap[did] = recordUri
-    } else {
-      this.followDidToRecordMap[did] = false
+  hydrate(did: string, profile: Profile) {
+    this.byDid[did] = {
+      did,
+      followRecordUri: profile.viewer?.following,
+      handle: profile.handle,
+      displayName: profile.displayName,
+      avatar: profile.avatar,
     }
   }
 
-  /**
-   * Use this to incrementally update the cache as views provide information
-   */
-  hydrateProfiles(profiles: Profile[]) {
+  hydrateMany(profiles: Profile[]) {
     for (const profile of profiles) {
-      if (profile.viewer) {
-        this.hydrate(profile.did, profile.viewer.following)
-      }
+      this.hydrate(profile.did, profile)
     }
   }
 }
diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
index 26fa6008c..906f84c28 100644
--- a/src/state/models/content/profile.ts
+++ b/src/state/models/content/profile.ts
@@ -137,7 +137,7 @@ export class ProfileModel {
       runInAction(() => {
         this.followersCount++
         this.viewer.following = res.uri
-        this.rootStore.me.follows.addFollow(this.did, res.uri)
+        this.rootStore.me.follows.hydrate(this.did, this)
       })
       track('Profile:Follow', {
         username: this.handle,
@@ -290,8 +290,8 @@ export class ProfileModel {
     this.labels = res.data.labels
     if (res.data.viewer) {
       Object.assign(this.viewer, res.data.viewer)
-      this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following)
     }
+    this.rootStore.me.follows.hydrate(this.did, res.data)
   }
 
   async _createRichText() {
diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts
index 580145f65..4a647dcfe 100644
--- a/src/state/models/discovery/foafs.ts
+++ b/src/state/models/discovery/foafs.ts
@@ -1,8 +1,4 @@
-import {
-  AppBskyActorDefs,
-  AppBskyGraphGetFollows as GetFollows,
-  moderateProfile,
-} from '@atproto/api'
+import {AppBskyActorDefs} from '@atproto/api'
 import {makeAutoObservable, runInAction} from 'mobx'
 import sampleSize from 'lodash.samplesize'
 import {bundleAsync} from 'lib/async/bundle'
@@ -43,35 +39,13 @@ export class FoafsModel {
     try {
       this.isLoading = true
 
-      // fetch & hydrate up to 1000 follows
-      {
-        let cursor
-        for (let i = 0; i < 10; i++) {
-          const res: GetFollows.Response =
-            await this.rootStore.agent.getFollows({
-              actor: this.rootStore.me.did,
-              cursor,
-              limit: 100,
-            })
-          res.data.follows = res.data.follows.filter(
-            profile =>
-              !moderateProfile(
-                profile,
-                this.rootStore.preferences.moderationOpts,
-              ).account.filter,
-          )
-          this.rootStore.me.follows.hydrateProfiles(res.data.follows)
-          if (!res.data.cursor) {
-            break
-          }
-          cursor = res.data.cursor
-        }
-      }
+      // fetch some of the user's follows
+      await this.rootStore.me.follows.syncIfNeeded()
 
       // grab 10 of the users followed by the user
       runInAction(() => {
         this.sources = sampleSize(
-          Object.keys(this.rootStore.me.follows.followDidToRecordMap),
+          Object.keys(this.rootStore.me.follows.byDid),
           10,
         )
       })
@@ -100,7 +74,7 @@ export class FoafsModel {
       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)
+          this.rootStore.me.follows.hydrateMany(res.value.data.follows)
         }
         const profile = profiles.data.profiles[i]
         const source = this.sources[i]
diff --git a/src/state/models/discovery/onboarding.ts b/src/state/models/discovery/onboarding.ts
index 8ad321ed9..3638e7f0d 100644
--- a/src/state/models/discovery/onboarding.ts
+++ b/src/state/models/discovery/onboarding.ts
@@ -81,6 +81,7 @@ export class OnboardingModel {
   }
 
   finish() {
+    this.rootStore.me.mainFeed.refresh() // load the selected content
     this.step = 'Home'
     track('Onboarding:Complete')
   }
diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts
index afa5e74e3..d270267ee 100644
--- a/src/state/models/discovery/suggested-actors.ts
+++ b/src/state/models/discovery/suggested-actors.ts
@@ -76,7 +76,7 @@ export class SuggestedActorsModel {
           !moderateProfile(actor, this.rootStore.preferences.moderationOpts)
             .account.filter,
       )
-      this.rootStore.me.follows.hydrateProfiles(actors)
+      this.rootStore.me.follows.hydrateMany(actors)
 
       runInAction(() => {
         if (replace) {
@@ -118,7 +118,7 @@ export class SuggestedActorsModel {
         actor: actor,
       })
     const {suggestions: moreSuggestions} = res.data
-    this.rootStore.me.follows.hydrateProfiles(moreSuggestions)
+    this.rootStore.me.follows.hydrateMany(moreSuggestions)
     // dedupe
     const toInsert = moreSuggestions.filter(
       s => !this.suggestions.find(s2 => s2.did === s.did),
diff --git a/src/state/models/discovery/user-autocomplete.ts b/src/state/models/discovery/user-autocomplete.ts
index 461073e45..25ce859d2 100644
--- a/src/state/models/discovery/user-autocomplete.ts
+++ b/src/state/models/discovery/user-autocomplete.ts
@@ -4,6 +4,8 @@ import AwaitLock from 'await-lock'
 import {RootStoreModel} from '../root-store'
 import {isInvalidHandle} from 'lib/strings/handles'
 
+type ProfileViewBasic = AppBskyActorDefs.ProfileViewBasic
+
 export class UserAutocompleteModel {
   // state
   isLoading = false
@@ -12,9 +14,8 @@ export class UserAutocompleteModel {
   lock = new AwaitLock()
 
   // data
-  follows: AppBskyActorDefs.ProfileViewBasic[] = []
-  searchRes: AppBskyActorDefs.ProfileViewBasic[] = []
   knownHandles: Set<string> = new Set()
+  _suggestions: ProfileViewBasic[] = []
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(
@@ -27,29 +28,35 @@ export class UserAutocompleteModel {
     )
   }
 
-  get suggestions() {
+  get follows(): ProfileViewBasic[] {
+    return Object.values(this.rootStore.me.follows.byDid).map(item => ({
+      did: item.did,
+      handle: item.handle,
+      displayName: item.displayName,
+      avatar: item.avatar,
+    }))
+  }
+
+  get suggestions(): ProfileViewBasic[] {
     if (!this.isActive) {
       return []
     }
-    if (this.prefix) {
-      return this.searchRes.map(user => ({
-        handle: user.handle,
-        displayName: user.displayName,
-        avatar: user.avatar,
-      }))
-    }
-    return this.follows.map(follow => ({
-      handle: follow.handle,
-      displayName: follow.displayName,
-      avatar: follow.avatar,
-    }))
+    return this._suggestions
   }
 
   // public api
   // =
 
   async setup() {
-    await this._getFollows()
+    await this.rootStore.me.follows.syncIfNeeded()
+    runInAction(() => {
+      for (const did in this.rootStore.me.follows.byDid) {
+        const info = this.rootStore.me.follows.byDid[did]
+        if (!isInvalidHandle(info.handle)) {
+          this.knownHandles.add(info.handle)
+        }
+      }
+    })
   }
 
   setActive(v: boolean) {
@@ -57,7 +64,7 @@ export class UserAutocompleteModel {
   }
 
   async setPrefix(prefix: string) {
-    const origPrefix = prefix.trim()
+    const origPrefix = prefix.trim().toLocaleLowerCase()
     this.prefix = origPrefix
     await this.lock.acquireAsync()
     try {
@@ -65,9 +72,27 @@ export class UserAutocompleteModel {
         if (this.prefix !== origPrefix) {
           return // another prefix was set before we got our chance
         }
-        await this._search()
+
+        // reset to follow results
+        this._computeSuggestions([])
+
+        // ask backend
+        const res = await this.rootStore.agent.searchActorsTypeahead({
+          term: this.prefix,
+          limit: 8,
+        })
+        this._computeSuggestions(res.data.actors)
+
+        // update known handles
+        runInAction(() => {
+          for (const u of res.data.actors) {
+            this.knownHandles.add(u.handle)
+          }
+        })
       } else {
-        this.searchRes = []
+        runInAction(() => {
+          this._computeSuggestions([])
+        })
       }
     } finally {
       this.lock.release()
@@ -77,28 +102,40 @@ export class UserAutocompleteModel {
   // internal
   // =
 
-  async _getFollows() {
-    const res = await this.rootStore.agent.getFollows({
-      actor: this.rootStore.me.did || '',
-    })
-    runInAction(() => {
-      this.follows = res.data.follows.filter(f => !isInvalidHandle(f.handle))
-      for (const f of this.follows) {
-        this.knownHandles.add(f.handle)
+  _computeSuggestions(searchRes: AppBskyActorDefs.ProfileViewBasic[] = []) {
+    if (this.prefix) {
+      const items: ProfileViewBasic[] = []
+      for (const item of this.follows) {
+        if (prefixMatch(this.prefix, item)) {
+          items.push(item)
+        }
+        if (items.length >= 8) {
+          break
+        }
       }
-    })
+      for (const item of searchRes) {
+        if (!items.find(item2 => item2.handle === item.handle)) {
+          items.push({
+            did: item.did,
+            handle: item.handle,
+            displayName: item.displayName,
+            avatar: item.avatar,
+          })
+        }
+      }
+      this._suggestions = items
+    } else {
+      this._suggestions = this.follows
+    }
   }
+}
 
-  async _search() {
-    const res = await this.rootStore.agent.searchActorsTypeahead({
-      term: this.prefix,
-      limit: 8,
-    })
-    runInAction(() => {
-      this.searchRes = res.data.actors
-      for (const u of this.searchRes) {
-        this.knownHandles.add(u.handle)
-      }
-    })
+function prefixMatch(prefix: string, info: ProfileViewBasic): boolean {
+  if (info.handle.includes(prefix)) {
+    return true
+  }
+  if (info.displayName?.toLocaleLowerCase().includes(prefix)) {
+    return true
   }
+  return false
 }
diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts
index ae4f29105..d46cced75 100644
--- a/src/state/models/feeds/post.ts
+++ b/src/state/models/feeds/post.ts
@@ -116,6 +116,7 @@ export class PostsFeedItemModel {
           },
           () => this.rootStore.agent.deleteLike(url),
         )
+        track('Post:Unlike')
       } else {
         // like
         await updateDataOptimistically(
@@ -129,11 +130,10 @@ export class PostsFeedItemModel {
             this.post.viewer!.like = res.uri
           },
         )
+        track('Post:Like')
       }
     } catch (error) {
       this.rootStore.log.error('Failed to toggle like', error)
-    } finally {
-      track(this.post.viewer.like ? 'Post:Unlike' : 'Post:Like')
     }
   }
 
@@ -141,6 +141,7 @@ export class PostsFeedItemModel {
     this.post.viewer = this.post.viewer || {}
     try {
       if (this.post.viewer?.repost) {
+        // unrepost
         const url = this.post.viewer.repost
         await updateDataOptimistically(
           this.post,
@@ -150,7 +151,9 @@ export class PostsFeedItemModel {
           },
           () => this.rootStore.agent.deleteRepost(url),
         )
+        track('Post:Unrepost')
       } else {
+        // repost
         await updateDataOptimistically(
           this.post,
           () => {
@@ -162,11 +165,10 @@ export class PostsFeedItemModel {
             this.post.viewer!.repost = res.uri
           },
         )
+        track('Post:Repost')
       }
     } catch (error) {
       this.rootStore.log.error('Failed to toggle repost', error)
-    } finally {
-      track(this.post.viewer.repost ? 'Post:Unrepost' : 'Post:Repost')
     }
   }
 
@@ -174,13 +176,13 @@ export class PostsFeedItemModel {
     try {
       if (this.isThreadMuted) {
         this.rootStore.mutedThreads.uris.delete(this.rootUri)
+        track('Post:ThreadUnmute')
       } else {
         this.rootStore.mutedThreads.uris.add(this.rootUri)
+        track('Post:ThreadMute')
       }
     } catch (error) {
       this.rootStore.log.error('Failed to toggle thread mute', error)
-    } finally {
-      track(this.isThreadMuted ? 'Post:ThreadUnmute' : 'Post:ThreadMute')
     }
   }
 
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index bb619147f..2462689b1 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -116,6 +116,10 @@ export class PostsFeedModel {
     return this.hasLoaded && !this.hasContent
   }
 
+  get isLoadingMore() {
+    return this.isLoading && !this.isRefreshing
+  }
+
   setHasNewLatest(v: boolean) {
     this.hasNewLatest = v
   }
@@ -307,12 +311,12 @@ export class PostsFeedModel {
   }
 
   async _appendAll(res: FeedAPIResponse, replace = false) {
-    this.hasMore = !!res.cursor
+    this.hasMore = !!res.cursor && res.feed.length > 0
     if (replace) {
       this.emptyFetches = 0
     }
 
-    this.rootStore.me.follows.hydrateProfiles(
+    this.rootStore.me.follows.hydrateMany(
       res.feed.map(item => item.post.author),
     )
     for (const item of res.feed) {
diff --git a/src/state/models/invited-users.ts b/src/state/models/invited-users.ts
index a28e0309a..cd3667062 100644
--- a/src/state/models/invited-users.ts
+++ b/src/state/models/invited-users.ts
@@ -61,7 +61,7 @@ export class InvitedUsers {
             profile => !profile.viewer?.following,
           )
         })
-        this.rootStore.me.follows.hydrateProfiles(this.profiles)
+        this.rootStore.me.follows.hydrateMany(this.profiles)
       } catch (e) {
         this.rootStore.log.error(
           'Failed to fetch profiles for invited users',
diff --git a/src/state/models/lists/likes.ts b/src/state/models/lists/likes.ts
index 39882d73a..dd3cf18a3 100644
--- a/src/state/models/lists/likes.ts
+++ b/src/state/models/lists/likes.ts
@@ -126,7 +126,7 @@ export class LikesModel {
   _appendAll(res: GetLikes.Response) {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
-    this.rootStore.me.follows.hydrateProfiles(
+    this.rootStore.me.follows.hydrateMany(
       res.data.likes.map(like => like.actor),
     )
     this.likes = this.likes.concat(res.data.likes)
diff --git a/src/state/models/lists/reposted-by.ts b/src/state/models/lists/reposted-by.ts
index a70375bdc..5d4fc107d 100644
--- a/src/state/models/lists/reposted-by.ts
+++ b/src/state/models/lists/reposted-by.ts
@@ -130,6 +130,6 @@ export class RepostedByModel {
     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)
+    this.rootStore.me.follows.hydrateMany(res.data.repostedBy)
   }
 }
diff --git a/src/state/models/lists/user-followers.ts b/src/state/models/lists/user-followers.ts
index 2962d6242..1f817c33c 100644
--- a/src/state/models/lists/user-followers.ts
+++ b/src/state/models/lists/user-followers.ts
@@ -115,6 +115,6 @@ export class UserFollowersModel {
     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)
+    this.rootStore.me.follows.hydrateMany(res.data.followers)
   }
 }
diff --git a/src/state/models/lists/user-follows.ts b/src/state/models/lists/user-follows.ts
index 56432a796..c9630eba8 100644
--- a/src/state/models/lists/user-follows.ts
+++ b/src/state/models/lists/user-follows.ts
@@ -115,6 +115,6 @@ export class UserFollowsModel {
     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)
+    this.rootStore.me.follows.hydrateMany(res.data.follows)
   }
 }
diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts
index 10aef0ff4..c26f9b87c 100644
--- a/src/state/models/media/image.ts
+++ b/src/state/models/media/image.ts
@@ -166,7 +166,7 @@ export class ImageModel implements Omit<RNImage, 'size'> {
   async crop() {
     try {
       // NOTE
-      // on ios, react-native-image-cropper gives really bad quality
+      // on ios, react-native-image-crop-picker gives really bad quality
       // without specifying width and height. on android, however, the
       // crop stretches incorrectly if you do specify it. these are
       // both separate bugs in the library. we deal with that by
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index b3365bd7c..6ca19b4b7 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -418,6 +418,7 @@ export class PreferencesModel {
     const oldPinned = this.pinnedFeeds
     this.savedFeeds = saved
     this.pinnedFeeds = pinned
+    await this.lock.acquireAsync()
     try {
       const res = await cb()
       runInAction(() => {
@@ -430,6 +431,8 @@ export class PreferencesModel {
         this.pinnedFeeds = oldPinned
       })
       throw e
+    } finally {
+      this.lock.release()
     }
   }
 
@@ -441,7 +444,7 @@ export class PreferencesModel {
 
   async addSavedFeed(v: string) {
     return this._optimisticUpdateSavedFeeds(
-      [...this.savedFeeds, v],
+      [...this.savedFeeds.filter(uri => uri !== v), v],
       this.pinnedFeeds,
       () => this.rootStore.agent.addSavedFeed(v),
     )
@@ -457,8 +460,8 @@ export class PreferencesModel {
 
   async addPinnedFeed(v: string) {
     return this._optimisticUpdateSavedFeeds(
-      this.savedFeeds,
-      [...this.pinnedFeeds, v],
+      [...this.savedFeeds.filter(uri => uri !== v), v],
+      [...this.pinnedFeeds.filter(uri => uri !== v), v],
       () => this.rootStore.agent.addPinnedFeed(v),
     )
   }
@@ -473,71 +476,121 @@ export class PreferencesModel {
 
   async setBirthDate(birthDate: Date) {
     this.birthDate = birthDate
-    await this.rootStore.agent.setPersonalDetails({birthDate})
+    await this.lock.acquireAsync()
+    try {
+      await this.rootStore.agent.setPersonalDetails({birthDate})
+    } finally {
+      this.lock.release()
+    }
   }
 
   async toggleHomeFeedHideReplies() {
     this.homeFeed.hideReplies = !this.homeFeed.hideReplies
-    await this.rootStore.agent.setFeedViewPrefs('home', {
-      hideReplies: this.homeFeed.hideReplies,
-    })
+    await this.lock.acquireAsync()
+    try {
+      await this.rootStore.agent.setFeedViewPrefs('home', {
+        hideReplies: this.homeFeed.hideReplies,
+      })
+    } finally {
+      this.lock.release()
+    }
   }
 
   async toggleHomeFeedHideRepliesByUnfollowed() {
     this.homeFeed.hideRepliesByUnfollowed =
       !this.homeFeed.hideRepliesByUnfollowed
-    await this.rootStore.agent.setFeedViewPrefs('home', {
-      hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed,
-    })
+    await this.lock.acquireAsync()
+    try {
+      await this.rootStore.agent.setFeedViewPrefs('home', {
+        hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed,
+      })
+    } finally {
+      this.lock.release()
+    }
   }
 
   async setHomeFeedHideRepliesByLikeCount(threshold: number) {
     this.homeFeed.hideRepliesByLikeCount = threshold
-    await this.rootStore.agent.setFeedViewPrefs('home', {
-      hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount,
-    })
+    await this.lock.acquireAsync()
+    try {
+      await this.rootStore.agent.setFeedViewPrefs('home', {
+        hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount,
+      })
+    } finally {
+      this.lock.release()
+    }
   }
 
   async toggleHomeFeedHideReposts() {
     this.homeFeed.hideReposts = !this.homeFeed.hideReposts
-    await this.rootStore.agent.setFeedViewPrefs('home', {
-      hideReposts: this.homeFeed.hideReposts,
-    })
+    await this.lock.acquireAsync()
+    try {
+      await this.rootStore.agent.setFeedViewPrefs('home', {
+        hideReposts: this.homeFeed.hideReposts,
+      })
+    } finally {
+      this.lock.release()
+    }
   }
 
   async toggleHomeFeedHideQuotePosts() {
     this.homeFeed.hideQuotePosts = !this.homeFeed.hideQuotePosts
-    await this.rootStore.agent.setFeedViewPrefs('home', {
-      hideQuotePosts: this.homeFeed.hideQuotePosts,
-    })
+    await this.lock.acquireAsync()
+    try {
+      await this.rootStore.agent.setFeedViewPrefs('home', {
+        hideQuotePosts: this.homeFeed.hideQuotePosts,
+      })
+    } finally {
+      this.lock.release()
+    }
   }
 
   async toggleHomeFeedMergeFeedEnabled() {
     this.homeFeed.lab_mergeFeedEnabled = !this.homeFeed.lab_mergeFeedEnabled
-    await this.rootStore.agent.setFeedViewPrefs('home', {
-      lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled,
-    })
+    await this.lock.acquireAsync()
+    try {
+      await this.rootStore.agent.setFeedViewPrefs('home', {
+        lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled,
+      })
+    } finally {
+      this.lock.release()
+    }
   }
 
   async setThreadSort(v: string) {
     if (THREAD_SORT_VALUES.includes(v)) {
       this.thread.sort = v
-      await this.rootStore.agent.setThreadViewPrefs({sort: v})
+      await this.lock.acquireAsync()
+      try {
+        await this.rootStore.agent.setThreadViewPrefs({sort: v})
+      } finally {
+        this.lock.release()
+      }
     }
   }
 
   async togglePrioritizedFollowedUsers() {
     this.thread.prioritizeFollowedUsers = !this.thread.prioritizeFollowedUsers
-    await this.rootStore.agent.setThreadViewPrefs({
-      prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers,
-    })
+    await this.lock.acquireAsync()
+    try {
+      await this.rootStore.agent.setThreadViewPrefs({
+        prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers,
+      })
+    } finally {
+      this.lock.release()
+    }
   }
 
   async toggleThreadTreeViewEnabled() {
     this.thread.lab_treeViewEnabled = !this.thread.lab_treeViewEnabled
-    await this.rootStore.agent.setThreadViewPrefs({
-      lab_treeViewEnabled: this.thread.lab_treeViewEnabled,
-    })
+    await this.lock.acquireAsync()
+    try {
+      await this.rootStore.agent.setThreadViewPrefs({
+        lab_treeViewEnabled: this.thread.lab_treeViewEnabled,
+      })
+    } finally {
+      this.lock.release()
+    }
   }
 
   toggleRequireAltTextEnabled() {
diff --git a/src/state/models/ui/reminders.e2e.ts b/src/state/models/ui/reminders.e2e.ts
new file mode 100644
index 000000000..ec0eca40d
--- /dev/null
+++ b/src/state/models/ui/reminders.e2e.ts
@@ -0,0 +1,24 @@
+import {makeAutoObservable} from 'mobx'
+import {RootStoreModel} from '../root-store'
+
+export class Reminders {
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(
+      this,
+      {serialize: false, hydrate: false},
+      {autoBind: true},
+    )
+  }
+
+  serialize() {
+    return {}
+  }
+
+  hydrate(_v: unknown) {}
+
+  get shouldRequestEmailConfirmation() {
+    return false
+  }
+
+  setEmailConfirmationRequested() {}
+}
diff --git a/src/state/models/ui/reminders.ts b/src/state/models/ui/reminders.ts
index f8becdec3..c650de004 100644
--- a/src/state/models/ui/reminders.ts
+++ b/src/state/models/ui/reminders.ts
@@ -3,14 +3,8 @@ import {isObj, hasProp} from 'lib/type-guards'
 import {RootStoreModel} from '../root-store'
 import {toHashCode} from 'lib/strings/helpers'
 
-const DAY = 60e3 * 24 * 1 // 1 day (ms)
-
 export class Reminders {
-  // NOTE
-  // by defaulting to the current date, we ensure that the user won't be nagged
-  // on first run (aka right after creating an account)
-  // -prf
-  lastEmailConfirm: Date = new Date()
+  lastEmailConfirm: Date | null = null
 
   constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(
@@ -46,6 +40,13 @@ export class Reminders {
     if (sess.emailConfirmed) {
       return false
     }
+    if (this.rootStore.onboarding.isActive) {
+      return false
+    }
+    // only prompt once
+    if (this.lastEmailConfirm) {
+      return false
+    }
     const today = new Date()
     // shard the users into 2 day of the week buckets
     // (this is to avoid a sudden influx of email updates when
@@ -54,9 +55,7 @@ export class Reminders {
     if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) {
       return false
     }
-    // only ask once a day at most, but because of the bucketing
-    // this will be more like weekly
-    return Number(today) - Number(this.lastEmailConfirm) > DAY
+    return true
   }
 
   setEmailConfirmationRequested() {
diff --git a/src/state/models/ui/search.ts b/src/state/models/ui/search.ts
index 4ab9db513..2b2036751 100644
--- a/src/state/models/ui/search.ts
+++ b/src/state/models/ui/search.ts
@@ -59,7 +59,7 @@ export class SearchUIModel {
       } while (profilesSearch.length)
     }
 
-    this.rootStore.me.follows.hydrateProfiles(profiles)
+    this.rootStore.me.follows.hydrateMany(profiles)
 
     runInAction(() => {
       this.profiles = profiles