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
This commit is contained in:
Samuel Newman
2025-02-07 11:18:51 +00:00
committed by GitHub
parent b12d39b4cf
commit 4b706c9519
4 changed files with 138 additions and 117 deletions
+9 -1
View File
@@ -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,
})
}
+69 -3
View File
@@ -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,
+1
View File
@@ -13,4 +13,5 @@ export type Device = {
export type Account = {
searchTermHistory?: string[]
searchAccountHistory?: string[]
}
+59 -113
View File
@@ -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>