diff options
author | Samuel Newman <mozzius@protonmail.com> | 2025-02-07 11:18:51 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-02-07 11:18:51 +0000 |
commit | 4b706c9519c548d53529876bdf815836ec71bdd4 (patch) | |
tree | 96485dcb1de8c7344287bebb890cd5832f527dfa /src | |
parent | b12d39b4cff0fe1b91fb16496c3fea1ed22de5cf (diff) | |
download | voidsky-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
Diffstat (limited to 'src')
-rw-r--r-- | src/state/queries/profile.ts | 10 | ||||
-rw-r--r-- | src/storage/index.ts | 72 | ||||
-rw-r--r-- | src/storage/schema.ts | 1 | ||||
-rw-r--r-- | src/view/screens/Search/Search.tsx | 172 |
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> |