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:
@@ -10,6 +10,7 @@ import {
|
|||||||
ComAtprotoRepoUploadBlob,
|
ComAtprotoRepoUploadBlob,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {
|
import {
|
||||||
|
keepPreviousData,
|
||||||
QueryClient,
|
QueryClient,
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
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()
|
const agent = useAgent()
|
||||||
return useQuery({
|
return useQuery({
|
||||||
staleTime: STALE.MINUTES.FIVE,
|
staleTime: STALE.MINUTES.FIVE,
|
||||||
@@ -90,6 +97,7 @@ export function useProfilesQuery({handles}: {handles: string[]}) {
|
|||||||
const res = await agent.getProfiles({actors: handles})
|
const res = await agent.getProfiles({actors: handles})
|
||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
|
placeholderData: maintainData ? keepPreviousData : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+69
-3
@@ -1,5 +1,5 @@
|
|||||||
|
import {useCallback, useEffect, useState} from 'react'
|
||||||
import {MMKV} from 'react-native-mmkv'
|
import {MMKV} from 'react-native-mmkv'
|
||||||
import {Did} from '@atproto/api'
|
|
||||||
|
|
||||||
import {Account, Device} from '#/storage/schema'
|
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[]) {
|
removeMany<Key extends keyof Schema>(scopes: [...Scopes], keys: Key[]) {
|
||||||
keys.forEach(key => this.remove([...scopes, 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
|
* 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') {
|
if (__DEV__ && typeof window !== 'undefined') {
|
||||||
// @ts-ignore
|
// @ts-expect-error - dev global
|
||||||
window.bsky_storage = {
|
window.bsky_storage = {
|
||||||
device,
|
device,
|
||||||
account,
|
account,
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ export type Device = {
|
|||||||
|
|
||||||
export type Account = {
|
export type Account = {
|
||||||
searchTermHistory?: string[]
|
searchTermHistory?: string[]
|
||||||
|
searchAccountHistory?: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import React, {useCallback} from 'react'
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Image,
|
Image,
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
} from '@fortawesome/react-native-fontawesome'
|
} from '@fortawesome/react-native-fontawesome'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
||||||
import {useFocusEffect, useNavigation} from '@react-navigation/native'
|
import {useFocusEffect, useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {APP_LANGUAGES, LANGUAGES} from '#/lib/../locale/languages'
|
import {APP_LANGUAGES, LANGUAGES} from '#/lib/../locale/languages'
|
||||||
@@ -37,7 +36,6 @@ import {
|
|||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {augmentSearchQuery} from '#/lib/strings/helpers'
|
import {augmentSearchQuery} from '#/lib/strings/helpers'
|
||||||
import {languageName} from '#/locale/helpers'
|
import {languageName} from '#/locale/helpers'
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {isNative, isWeb} from '#/platform/detection'
|
import {isNative, isWeb} from '#/platform/detection'
|
||||||
import {listenSoftReset} from '#/state/events'
|
import {listenSoftReset} from '#/state/events'
|
||||||
import {useLanguagePrefs} from '#/state/preferences/languages'
|
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 {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||||
import {useActorSearch} from '#/state/queries/actor-search'
|
import {useActorSearch} from '#/state/queries/actor-search'
|
||||||
import {usePopularFeedsSearch} from '#/state/queries/feed'
|
import {usePopularFeedsSearch} from '#/state/queries/feed'
|
||||||
|
import {useProfilesQuery} from '#/state/queries/profile'
|
||||||
import {useSearchPostsQuery} from '#/state/queries/search-posts'
|
import {useSearchPostsQuery} from '#/state/queries/search-posts'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {useSetDrawerOpen} from '#/state/shell'
|
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 {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
|
||||||
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
|
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
|
import {account, useStorage} from '#/storage'
|
||||||
|
|
||||||
function Loader() {
|
function Loader() {
|
||||||
return (
|
return (
|
||||||
@@ -604,6 +604,7 @@ export function SearchScreen(
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const setDrawerOpen = useSetDrawerOpen()
|
const setDrawerOpen = useSetDrawerOpen()
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
// Query terms
|
// Query terms
|
||||||
const queryParam = props.route?.params?.q ?? ''
|
const queryParam = props.route?.params?.q ?? ''
|
||||||
@@ -612,10 +613,55 @@ export function SearchScreen(
|
|||||||
useActorAutocompleteQuery(searchText, true)
|
useActorAutocompleteQuery(searchText, true)
|
||||||
|
|
||||||
const [showAutocomplete, setShowAutocomplete] = React.useState(false)
|
const [showAutocomplete, setShowAutocomplete] = React.useState(false)
|
||||||
const [searchHistory, setSearchHistory] = React.useState<string[]>([])
|
|
||||||
const [selectedProfiles, setSelectedProfiles] = React.useState<
|
const [termHistory = [], setTermHistory] = useStorage(account, [
|
||||||
AppBskyActorDefs.ProfileViewBasic[]
|
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({
|
const {params, query, queryWithParams} = useQueryManager({
|
||||||
initialQuery: queryParam,
|
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(() => {
|
const onPressMenu = React.useCallback(() => {
|
||||||
textInput.current?.blur()
|
textInput.current?.blur()
|
||||||
setDrawerOpen(true)
|
setDrawerOpen(true)
|
||||||
@@ -670,57 +697,6 @@ export function SearchScreen(
|
|||||||
setSearchText(text)
|
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(
|
const navigateToItem = React.useCallback(
|
||||||
(item: string) => {
|
(item: string) => {
|
||||||
scrollToTopWeb()
|
scrollToTopWeb()
|
||||||
@@ -774,10 +750,10 @@ export function SearchScreen(
|
|||||||
(profile: AppBskyActorDefs.ProfileViewBasic) => {
|
(profile: AppBskyActorDefs.ProfileViewBasic) => {
|
||||||
// Slight delay to avoid updating during push nav animation.
|
// Slight delay to avoid updating during push nav animation.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
updateSelectedProfiles(profile)
|
updateProfileHistory(profile)
|
||||||
}, 400)
|
}, 400)
|
||||||
},
|
},
|
||||||
[updateSelectedProfiles],
|
[updateProfileHistory],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onSoftReset = React.useCallback(() => {
|
const onSoftReset = React.useCallback(() => {
|
||||||
@@ -798,36 +774,6 @@ export function SearchScreen(
|
|||||||
}, [onSoftReset, setMinimalShellMode]),
|
}, [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(() => {
|
const onSearchInputFocus = React.useCallback(() => {
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
// Prevent a jump on iPad by ensuring that
|
// Prevent a jump on iPad by ensuring that
|
||||||
@@ -932,12 +878,12 @@ export function SearchScreen(
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SearchHistory
|
<SearchHistory
|
||||||
searchHistory={searchHistory}
|
searchHistory={termHistory}
|
||||||
selectedProfiles={selectedProfiles}
|
selectedProfiles={accountHistoryProfiles?.profiles || []}
|
||||||
onItemClick={handleHistoryItemClick}
|
onItemClick={handleHistoryItemClick}
|
||||||
onProfileClick={handleProfileClick}
|
onProfileClick={handleProfileClick}
|
||||||
onRemoveItemClick={handleRemoveHistoryItem}
|
onRemoveItemClick={deleteSearchHistoryItem}
|
||||||
onRemoveProfileClick={handleRemoveProfile}
|
onRemoveProfileClick={deleteProfileHistoryItem}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
Reference in New Issue
Block a user