about summary refs log tree commit diff
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-02-07 11:18:51 +0000
committerGitHub <noreply@github.com>2025-02-07 11:18:51 +0000
commit4b706c9519c548d53529876bdf815836ec71bdd4 (patch)
tree96485dcb1de8c7344287bebb890cd5832f527dfa
parentb12d39b4cff0fe1b91fb16496c3fea1ed22de5cf (diff)
downloadvoidsky-4b706c9519c548d53529876bdf815836ec71bdd4.tar.zst
Per-user search history (#7588)
* per-user search history

* move to mmkv with cool new hook

* revert accidental changes

This reverts commit 27c89fa645eff0acb7a8fd852203ff1ea3725c69.

* restore limits
-rw-r--r--src/state/queries/profile.ts10
-rw-r--r--src/storage/index.ts72
-rw-r--r--src/storage/schema.ts1
-rw-r--r--src/view/screens/Search/Search.tsx172
4 files changed, 138 insertions, 117 deletions
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
index fa5675e87..291999ae1 100644
--- a/src/state/queries/profile.ts
+++ b/src/state/queries/profile.ts
@@ -10,6 +10,7 @@ import {
   ComAtprotoRepoUploadBlob,
 } from '@atproto/api'
 import {
+  keepPreviousData,
   QueryClient,
   useMutation,
   useQuery,
@@ -81,7 +82,13 @@ export function useProfileQuery({
   })
 }
 
-export function useProfilesQuery({handles}: {handles: string[]}) {
+export function useProfilesQuery({
+  handles,
+  maintainData,
+}: {
+  handles: string[]
+  maintainData?: boolean
+}) {
   const agent = useAgent()
   return useQuery({
     staleTime: STALE.MINUTES.FIVE,
@@ -90,6 +97,7 @@ export function useProfilesQuery({handles}: {handles: string[]}) {
       const res = await agent.getProfiles({actors: handles})
       return res.data
     },
+    placeholderData: maintainData ? keepPreviousData : undefined,
   })
 }
 
diff --git a/src/storage/index.ts b/src/storage/index.ts
index 8ec09eefa..9b39e1c1a 100644
--- a/src/storage/index.ts
+++ b/src/storage/index.ts
@@ -1,5 +1,5 @@
+import {useCallback, useEffect, useState} from 'react'
 import {MMKV} from 'react-native-mmkv'
-import {Did} from '@atproto/api'
 
 import {Account, Device} from '#/storage/schema'
 
@@ -65,6 +65,72 @@ export class Storage<Scopes extends unknown[], Schema> {
   removeMany<Key extends keyof Schema>(scopes: [...Scopes], keys: Key[]) {
     keys.forEach(key => this.remove([...scopes, key]))
   }
+
+  /**
+   * Fires a callback when the storage associated with a given key changes
+   *
+   * @returns Listener - call `remove()` to stop listening
+   */
+  addOnValueChangedListener<Key extends keyof Schema>(
+    scopes: [...Scopes, Key],
+    callback: () => void,
+  ) {
+    return this.store.addOnValueChangedListener(key => {
+      if (key === scopes.join(this.sep)) {
+        callback()
+      }
+    })
+  }
+}
+
+type StorageSchema<T extends Storage<any, any>> = T extends Storage<
+  any,
+  infer U
+>
+  ? U
+  : never
+type StorageScopes<T extends Storage<any, any>> = T extends Storage<
+  infer S,
+  any
+>
+  ? S
+  : never
+
+/**
+ * Hook to use a storage instance. Acts like a useState hook, but persists the
+ * value in storage.
+ */
+export function useStorage<
+  Store extends Storage<any, any>,
+  Key extends keyof StorageSchema<Store>,
+>(
+  storage: Store,
+  scopes: [...StorageScopes<Store>, Key],
+): [
+  StorageSchema<Store>[Key] | undefined,
+  (data: StorageSchema<Store>[Key]) => void,
+] {
+  type Schema = StorageSchema<Store>
+  const [value, setValue] = useState<Schema[Key] | undefined>(() =>
+    storage.get(scopes),
+  )
+
+  useEffect(() => {
+    const sub = storage.addOnValueChangedListener(scopes, () => {
+      setValue(storage.get(scopes))
+    })
+    return () => sub.remove()
+  }, [storage, scopes])
+
+  const setter = useCallback(
+    (data: Schema[Key]) => {
+      setValue(data)
+      storage.set(scopes, data)
+    },
+    [storage, scopes],
+  )
+
+  return [value, setter] as const
 }
 
 /**
@@ -77,10 +143,10 @@ export const device = new Storage<[], Device>({id: 'bsky_device'})
 /**
  * Account data that's specific to the account on this device
  */
-export const account = new Storage<[Did], Account>({id: 'bsky_account'})
+export const account = new Storage<[string], Account>({id: 'bsky_account'})
 
 if (__DEV__ && typeof window !== 'undefined') {
-  // @ts-ignore
+  // @ts-expect-error - dev global
   window.bsky_storage = {
     device,
     account,
diff --git a/src/storage/schema.ts b/src/storage/schema.ts
index d8b9874e4..667b43208 100644
--- a/src/storage/schema.ts
+++ b/src/storage/schema.ts
@@ -13,4 +13,5 @@ export type Device = {
 
 export type Account = {
   searchTermHistory?: string[]
+  searchAccountHistory?: string[]
 }
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index 801381a03..f626d5628 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {useCallback} from 'react'
 import {
   ActivityIndicator,
   Image,
@@ -18,7 +18,6 @@ import {
 } from '@fortawesome/react-native-fontawesome'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import AsyncStorage from '@react-native-async-storage/async-storage'
 import {useFocusEffect, useNavigation} from '@react-navigation/native'
 
 import {APP_LANGUAGES, LANGUAGES} from '#/lib/../locale/languages'
@@ -37,7 +36,6 @@ import {
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {augmentSearchQuery} from '#/lib/strings/helpers'
 import {languageName} from '#/locale/helpers'
-import {logger} from '#/logger'
 import {isNative, isWeb} from '#/platform/detection'
 import {listenSoftReset} from '#/state/events'
 import {useLanguagePrefs} from '#/state/preferences/languages'
@@ -45,6 +43,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
 import {useActorSearch} from '#/state/queries/actor-search'
 import {usePopularFeedsSearch} from '#/state/queries/feed'
+import {useProfilesQuery} from '#/state/queries/profile'
 import {useSearchPostsQuery} from '#/state/queries/search-posts'
 import {useSession} from '#/state/session'
 import {useSetDrawerOpen} from '#/state/shell'
@@ -72,6 +71,7 @@ import {SearchInput} from '#/components/forms/SearchInput'
 import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
 import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
 import * as Layout from '#/components/Layout'
+import {account, useStorage} from '#/storage'
 
 function Loader() {
   return (
@@ -604,6 +604,7 @@ export function SearchScreen(
   const {_} = useLingui()
   const setDrawerOpen = useSetDrawerOpen()
   const setMinimalShellMode = useSetMinimalShellMode()
+  const {currentAccount} = useSession()
 
   // Query terms
   const queryParam = props.route?.params?.q ?? ''
@@ -612,10 +613,55 @@ export function SearchScreen(
     useActorAutocompleteQuery(searchText, true)
 
   const [showAutocomplete, setShowAutocomplete] = React.useState(false)
-  const [searchHistory, setSearchHistory] = React.useState<string[]>([])
-  const [selectedProfiles, setSelectedProfiles] = React.useState<
-    AppBskyActorDefs.ProfileViewBasic[]
-  >([])
+
+  const [termHistory = [], setTermHistory] = useStorage(account, [
+    currentAccount?.did ?? 'pwi',
+    'searchTermHistory',
+  ] as const)
+  const [accountHistory = [], setAccountHistory] = useStorage(account, [
+    currentAccount?.did ?? 'pwi',
+    'searchAccountHistory',
+  ])
+
+  const {data: accountHistoryProfiles} = useProfilesQuery({
+    handles: accountHistory,
+    maintainData: true,
+  })
+
+  const updateSearchHistory = useCallback(
+    async (item: string) => {
+      const newSearchHistory = [
+        item,
+        ...termHistory.filter(search => search !== item),
+      ].slice(0, 6)
+      setTermHistory(newSearchHistory)
+    },
+    [termHistory, setTermHistory],
+  )
+
+  const updateProfileHistory = useCallback(
+    async (item: AppBskyActorDefs.ProfileViewBasic) => {
+      const newAccountHistory = [
+        item.did,
+        ...accountHistory.filter(p => p !== item.did),
+      ].slice(0, 5)
+      setAccountHistory(newAccountHistory)
+    },
+    [accountHistory, setAccountHistory],
+  )
+
+  const deleteSearchHistoryItem = useCallback(
+    async (item: string) => {
+      setTermHistory(termHistory.filter(search => search !== item))
+    },
+    [termHistory, setTermHistory],
+  )
+  const deleteProfileHistoryItem = useCallback(
+    async (item: AppBskyActorDefs.ProfileViewBasic) => {
+      setAccountHistory(accountHistory.filter(p => p !== item.did))
+    },
+    [accountHistory, setAccountHistory],
+  )
 
   const {params, query, queryWithParams} = useQueryManager({
     initialQuery: queryParam,
@@ -635,25 +681,6 @@ export function SearchScreen(
     }),
   )
 
-  React.useEffect(() => {
-    const loadSearchHistory = async () => {
-      try {
-        const history = await AsyncStorage.getItem('searchHistory')
-        if (history !== null) {
-          setSearchHistory(JSON.parse(history))
-        }
-        const profiles = await AsyncStorage.getItem('selectedProfiles')
-        if (profiles !== null) {
-          setSelectedProfiles(JSON.parse(profiles))
-        }
-      } catch (e: any) {
-        logger.error('Failed to load search history', {message: e})
-      }
-    }
-
-    loadSearchHistory()
-  }, [])
-
   const onPressMenu = React.useCallback(() => {
     textInput.current?.blur()
     setDrawerOpen(true)
@@ -670,57 +697,6 @@ export function SearchScreen(
     setSearchText(text)
   }, [])
 
-  const updateSearchHistory = React.useCallback(
-    async (newQuery: string) => {
-      newQuery = newQuery.trim()
-      if (newQuery) {
-        let newHistory = [
-          newQuery,
-          ...searchHistory.filter(q => q !== newQuery),
-        ]
-
-        if (newHistory.length > 5) {
-          newHistory = newHistory.slice(0, 5)
-        }
-
-        setSearchHistory(newHistory)
-        try {
-          await AsyncStorage.setItem(
-            'searchHistory',
-            JSON.stringify(newHistory),
-          )
-        } catch (e: any) {
-          logger.error('Failed to save search history', {message: e})
-        }
-      }
-    },
-    [searchHistory, setSearchHistory],
-  )
-
-  const updateSelectedProfiles = React.useCallback(
-    async (profile: AppBskyActorDefs.ProfileViewBasic) => {
-      let newProfiles = [
-        profile,
-        ...selectedProfiles.filter(p => p.did !== profile.did),
-      ]
-
-      if (newProfiles.length > 5) {
-        newProfiles = newProfiles.slice(0, 5)
-      }
-
-      setSelectedProfiles(newProfiles)
-      try {
-        await AsyncStorage.setItem(
-          'selectedProfiles',
-          JSON.stringify(newProfiles),
-        )
-      } catch (e: any) {
-        logger.error('Failed to save selected profiles', {message: e})
-      }
-    },
-    [selectedProfiles, setSelectedProfiles],
-  )
-
   const navigateToItem = React.useCallback(
     (item: string) => {
       scrollToTopWeb()
@@ -774,10 +750,10 @@ export function SearchScreen(
     (profile: AppBskyActorDefs.ProfileViewBasic) => {
       // Slight delay to avoid updating during push nav animation.
       setTimeout(() => {
-        updateSelectedProfiles(profile)
+        updateProfileHistory(profile)
       }, 400)
     },
-    [updateSelectedProfiles],
+    [updateProfileHistory],
   )
 
   const onSoftReset = React.useCallback(() => {
@@ -798,36 +774,6 @@ export function SearchScreen(
     }, [onSoftReset, setMinimalShellMode]),
   )
 
-  const handleRemoveHistoryItem = React.useCallback(
-    (itemToRemove: string) => {
-      const updatedHistory = searchHistory.filter(item => item !== itemToRemove)
-      setSearchHistory(updatedHistory)
-      AsyncStorage.setItem(
-        'searchHistory',
-        JSON.stringify(updatedHistory),
-      ).catch(e => {
-        logger.error('Failed to update search history', {message: e})
-      })
-    },
-    [searchHistory],
-  )
-
-  const handleRemoveProfile = React.useCallback(
-    (profileToRemove: AppBskyActorDefs.ProfileViewBasic) => {
-      const updatedProfiles = selectedProfiles.filter(
-        profile => profile.did !== profileToRemove.did,
-      )
-      setSelectedProfiles(updatedProfiles)
-      AsyncStorage.setItem(
-        'selectedProfiles',
-        JSON.stringify(updatedProfiles),
-      ).catch(e => {
-        logger.error('Failed to update selected profiles', {message: e})
-      })
-    },
-    [selectedProfiles],
-  )
-
   const onSearchInputFocus = React.useCallback(() => {
     if (isWeb) {
       // Prevent a jump on iPad by ensuring that
@@ -932,12 +878,12 @@ export function SearchScreen(
           />
         ) : (
           <SearchHistory
-            searchHistory={searchHistory}
-            selectedProfiles={selectedProfiles}
+            searchHistory={termHistory}
+            selectedProfiles={accountHistoryProfiles?.profiles || []}
             onItemClick={handleHistoryItemClick}
             onProfileClick={handleProfileClick}
-            onRemoveItemClick={handleRemoveHistoryItem}
-            onRemoveProfileClick={handleRemoveProfile}
+            onRemoveItemClick={deleteSearchHistoryItem}
+            onRemoveProfileClick={deleteProfileHistoryItem}
           />
         )}
       </View>