about summary refs log tree commit diff
path: root/src/state/models
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-10-05 16:44:05 -0700
committerGitHub <noreply@github.com>2023-10-05 16:44:05 -0700
commitbd7db8af26bfbf94a80972671ca714a143bee28e (patch)
treecf022bddc4f6b164bea51aeb3c57479d72b73355 /src/state/models
parent19f8389fc777c7ff41466748f1238f4e0a4b0619 (diff)
downloadvoidsky-bd7db8af26bfbf94a80972671ca714a143bee28e.tar.zst
Improve typeahead search with inclusion of followed users (temporary solution) (#1612)
* Update follows cache to maintain some user info

* Prioritize follows in composer autocomplete

* Clean up logic and add new autocomplete to search

* Update follow hook
Diffstat (limited to 'src/state/models')
-rw-r--r--src/state/models/cache/my-follows.ts99
-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/suggested-actors.ts4
-rw-r--r--src/state/models/discovery/user-autocomplete.ts115
-rw-r--r--src/state/models/feeds/posts.ts2
-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/ui/search.ts2
12 files changed, 159 insertions, 113 deletions
diff --git a/src/state/models/cache/my-follows.ts b/src/state/models/cache/my-follows.ts
index 10f88c4a9..14dc9895d 100644
--- a/src/state/models/cache/my-follows.ts
+++ b/src/state/models/cache/my-follows.ts
@@ -1,7 +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'
 
+const MAX_SYNC_PAGES = 10
+const SYNC_TTL = 60e3 * 10 // 10 minutes
+
 type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView
 
 export enum FollowState {
@@ -10,6 +17,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 +32,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 +49,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
+   */
+  async syncIfNeeded() {
+    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 +96,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]
+    const v = this.byDid[did]
     if (typeof v === 'string') {
       return v
     }
     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/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/posts.ts b/src/state/models/feeds/posts.ts
index 2a7170325..2462689b1 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -316,7 +316,7 @@ export class PostsFeedModel {
       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/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