Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Samuel Newman and committed by
GitHub
4b706c95 b12d39b4

+138 -117
+9 -1
src/state/queries/profile.ts
··· 10 10 ComAtprotoRepoUploadBlob, 11 11 } from '@atproto/api' 12 12 import { 13 + keepPreviousData, 13 14 QueryClient, 14 15 useMutation, 15 16 useQuery, ··· 81 82 }) 82 83 } 83 84 84 - export function useProfilesQuery({handles}: {handles: string[]}) { 85 + export function useProfilesQuery({ 86 + handles, 87 + maintainData, 88 + }: { 89 + handles: string[] 90 + maintainData?: boolean 91 + }) { 85 92 const agent = useAgent() 86 93 return useQuery({ 87 94 staleTime: STALE.MINUTES.FIVE, ··· 90 97 const res = await agent.getProfiles({actors: handles}) 91 98 return res.data 92 99 }, 100 + placeholderData: maintainData ? keepPreviousData : undefined, 93 101 }) 94 102 } 95 103
+69 -3
src/storage/index.ts
··· 1 + import {useCallback, useEffect, useState} from 'react' 1 2 import {MMKV} from 'react-native-mmkv' 2 - import {Did} from '@atproto/api' 3 3 4 4 import {Account, Device} from '#/storage/schema' 5 5 ··· 65 65 removeMany<Key extends keyof Schema>(scopes: [...Scopes], keys: Key[]) { 66 66 keys.forEach(key => this.remove([...scopes, key])) 67 67 } 68 + 69 + /** 70 + * Fires a callback when the storage associated with a given key changes 71 + * 72 + * @returns Listener - call `remove()` to stop listening 73 + */ 74 + addOnValueChangedListener<Key extends keyof Schema>( 75 + scopes: [...Scopes, Key], 76 + callback: () => void, 77 + ) { 78 + return this.store.addOnValueChangedListener(key => { 79 + if (key === scopes.join(this.sep)) { 80 + callback() 81 + } 82 + }) 83 + } 84 + } 85 + 86 + type StorageSchema<T extends Storage<any, any>> = T extends Storage< 87 + any, 88 + infer U 89 + > 90 + ? U 91 + : never 92 + type StorageScopes<T extends Storage<any, any>> = T extends Storage< 93 + infer S, 94 + any 95 + > 96 + ? S 97 + : never 98 + 99 + /** 100 + * Hook to use a storage instance. Acts like a useState hook, but persists the 101 + * value in storage. 102 + */ 103 + export function useStorage< 104 + Store extends Storage<any, any>, 105 + Key extends keyof StorageSchema<Store>, 106 + >( 107 + storage: Store, 108 + scopes: [...StorageScopes<Store>, Key], 109 + ): [ 110 + StorageSchema<Store>[Key] | undefined, 111 + (data: StorageSchema<Store>[Key]) => void, 112 + ] { 113 + type Schema = StorageSchema<Store> 114 + const [value, setValue] = useState<Schema[Key] | undefined>(() => 115 + storage.get(scopes), 116 + ) 117 + 118 + useEffect(() => { 119 + const sub = storage.addOnValueChangedListener(scopes, () => { 120 + setValue(storage.get(scopes)) 121 + }) 122 + return () => sub.remove() 123 + }, [storage, scopes]) 124 + 125 + const setter = useCallback( 126 + (data: Schema[Key]) => { 127 + setValue(data) 128 + storage.set(scopes, data) 129 + }, 130 + [storage, scopes], 131 + ) 132 + 133 + return [value, setter] as const 68 134 } 69 135 70 136 /** ··· 77 143 /** 78 144 * Account data that's specific to the account on this device 79 145 */ 80 - export const account = new Storage<[Did], Account>({id: 'bsky_account'}) 146 + export const account = new Storage<[string], Account>({id: 'bsky_account'}) 81 147 82 148 if (__DEV__ && typeof window !== 'undefined') { 83 - // @ts-ignore 149 + // @ts-expect-error - dev global 84 150 window.bsky_storage = { 85 151 device, 86 152 account,
+1
src/storage/schema.ts
··· 13 13 14 14 export type Account = { 15 15 searchTermHistory?: string[] 16 + searchAccountHistory?: string[] 16 17 }
+59 -113
src/view/screens/Search/Search.tsx
··· 1 - import React from 'react' 1 + import React, {useCallback} from 'react' 2 2 import { 3 3 ActivityIndicator, 4 4 Image, ··· 18 18 } from '@fortawesome/react-native-fontawesome' 19 19 import {msg, Trans} from '@lingui/macro' 20 20 import {useLingui} from '@lingui/react' 21 - import AsyncStorage from '@react-native-async-storage/async-storage' 22 21 import {useFocusEffect, useNavigation} from '@react-navigation/native' 23 22 24 23 import {APP_LANGUAGES, LANGUAGES} from '#/lib/../locale/languages' ··· 37 36 import {sanitizeDisplayName} from '#/lib/strings/display-names' 38 37 import {augmentSearchQuery} from '#/lib/strings/helpers' 39 38 import {languageName} from '#/locale/helpers' 40 - import {logger} from '#/logger' 41 39 import {isNative, isWeb} from '#/platform/detection' 42 40 import {listenSoftReset} from '#/state/events' 43 41 import {useLanguagePrefs} from '#/state/preferences/languages' ··· 45 43 import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 46 44 import {useActorSearch} from '#/state/queries/actor-search' 47 45 import {usePopularFeedsSearch} from '#/state/queries/feed' 46 + import {useProfilesQuery} from '#/state/queries/profile' 48 47 import {useSearchPostsQuery} from '#/state/queries/search-posts' 49 48 import {useSession} from '#/state/session' 50 49 import {useSetDrawerOpen} from '#/state/shell' ··· 72 71 import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron' 73 72 import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' 74 73 import * as Layout from '#/components/Layout' 74 + import {account, useStorage} from '#/storage' 75 75 76 76 function Loader() { 77 77 return ( ··· 604 604 const {_} = useLingui() 605 605 const setDrawerOpen = useSetDrawerOpen() 606 606 const setMinimalShellMode = useSetMinimalShellMode() 607 + const {currentAccount} = useSession() 607 608 608 609 // Query terms 609 610 const queryParam = props.route?.params?.q ?? '' ··· 612 613 useActorAutocompleteQuery(searchText, true) 613 614 614 615 const [showAutocomplete, setShowAutocomplete] = React.useState(false) 615 - const [searchHistory, setSearchHistory] = React.useState<string[]>([]) 616 - const [selectedProfiles, setSelectedProfiles] = React.useState< 617 - AppBskyActorDefs.ProfileViewBasic[] 618 - >([]) 616 + 617 + const [termHistory = [], setTermHistory] = useStorage(account, [ 618 + currentAccount?.did ?? 'pwi', 619 + 'searchTermHistory', 620 + ] as const) 621 + const [accountHistory = [], setAccountHistory] = useStorage(account, [ 622 + currentAccount?.did ?? 'pwi', 623 + 'searchAccountHistory', 624 + ]) 625 + 626 + const {data: accountHistoryProfiles} = useProfilesQuery({ 627 + handles: accountHistory, 628 + maintainData: true, 629 + }) 630 + 631 + const updateSearchHistory = useCallback( 632 + async (item: string) => { 633 + const newSearchHistory = [ 634 + item, 635 + ...termHistory.filter(search => search !== item), 636 + ].slice(0, 6) 637 + setTermHistory(newSearchHistory) 638 + }, 639 + [termHistory, setTermHistory], 640 + ) 641 + 642 + const updateProfileHistory = useCallback( 643 + async (item: AppBskyActorDefs.ProfileViewBasic) => { 644 + const newAccountHistory = [ 645 + item.did, 646 + ...accountHistory.filter(p => p !== item.did), 647 + ].slice(0, 5) 648 + setAccountHistory(newAccountHistory) 649 + }, 650 + [accountHistory, setAccountHistory], 651 + ) 652 + 653 + const deleteSearchHistoryItem = useCallback( 654 + async (item: string) => { 655 + setTermHistory(termHistory.filter(search => search !== item)) 656 + }, 657 + [termHistory, setTermHistory], 658 + ) 659 + const deleteProfileHistoryItem = useCallback( 660 + async (item: AppBskyActorDefs.ProfileViewBasic) => { 661 + setAccountHistory(accountHistory.filter(p => p !== item.did)) 662 + }, 663 + [accountHistory, setAccountHistory], 664 + ) 619 665 620 666 const {params, query, queryWithParams} = useQueryManager({ 621 667 initialQuery: queryParam, ··· 635 681 }), 636 682 ) 637 683 638 - React.useEffect(() => { 639 - const loadSearchHistory = async () => { 640 - try { 641 - const history = await AsyncStorage.getItem('searchHistory') 642 - if (history !== null) { 643 - setSearchHistory(JSON.parse(history)) 644 - } 645 - const profiles = await AsyncStorage.getItem('selectedProfiles') 646 - if (profiles !== null) { 647 - setSelectedProfiles(JSON.parse(profiles)) 648 - } 649 - } catch (e: any) { 650 - logger.error('Failed to load search history', {message: e}) 651 - } 652 - } 653 - 654 - loadSearchHistory() 655 - }, []) 656 - 657 684 const onPressMenu = React.useCallback(() => { 658 685 textInput.current?.blur() 659 686 setDrawerOpen(true) ··· 670 697 setSearchText(text) 671 698 }, []) 672 699 673 - const updateSearchHistory = React.useCallback( 674 - async (newQuery: string) => { 675 - newQuery = newQuery.trim() 676 - if (newQuery) { 677 - let newHistory = [ 678 - newQuery, 679 - ...searchHistory.filter(q => q !== newQuery), 680 - ] 681 - 682 - if (newHistory.length > 5) { 683 - newHistory = newHistory.slice(0, 5) 684 - } 685 - 686 - setSearchHistory(newHistory) 687 - try { 688 - await AsyncStorage.setItem( 689 - 'searchHistory', 690 - JSON.stringify(newHistory), 691 - ) 692 - } catch (e: any) { 693 - logger.error('Failed to save search history', {message: e}) 694 - } 695 - } 696 - }, 697 - [searchHistory, setSearchHistory], 698 - ) 699 - 700 - const updateSelectedProfiles = React.useCallback( 701 - async (profile: AppBskyActorDefs.ProfileViewBasic) => { 702 - let newProfiles = [ 703 - profile, 704 - ...selectedProfiles.filter(p => p.did !== profile.did), 705 - ] 706 - 707 - if (newProfiles.length > 5) { 708 - newProfiles = newProfiles.slice(0, 5) 709 - } 710 - 711 - setSelectedProfiles(newProfiles) 712 - try { 713 - await AsyncStorage.setItem( 714 - 'selectedProfiles', 715 - JSON.stringify(newProfiles), 716 - ) 717 - } catch (e: any) { 718 - logger.error('Failed to save selected profiles', {message: e}) 719 - } 720 - }, 721 - [selectedProfiles, setSelectedProfiles], 722 - ) 723 - 724 700 const navigateToItem = React.useCallback( 725 701 (item: string) => { 726 702 scrollToTopWeb() ··· 774 750 (profile: AppBskyActorDefs.ProfileViewBasic) => { 775 751 // Slight delay to avoid updating during push nav animation. 776 752 setTimeout(() => { 777 - updateSelectedProfiles(profile) 753 + updateProfileHistory(profile) 778 754 }, 400) 779 755 }, 780 - [updateSelectedProfiles], 756 + [updateProfileHistory], 781 757 ) 782 758 783 759 const onSoftReset = React.useCallback(() => { ··· 796 772 setMinimalShellMode(false) 797 773 return listenSoftReset(onSoftReset) 798 774 }, [onSoftReset, setMinimalShellMode]), 799 - ) 800 - 801 - const handleRemoveHistoryItem = React.useCallback( 802 - (itemToRemove: string) => { 803 - const updatedHistory = searchHistory.filter(item => item !== itemToRemove) 804 - setSearchHistory(updatedHistory) 805 - AsyncStorage.setItem( 806 - 'searchHistory', 807 - JSON.stringify(updatedHistory), 808 - ).catch(e => { 809 - logger.error('Failed to update search history', {message: e}) 810 - }) 811 - }, 812 - [searchHistory], 813 - ) 814 - 815 - const handleRemoveProfile = React.useCallback( 816 - (profileToRemove: AppBskyActorDefs.ProfileViewBasic) => { 817 - const updatedProfiles = selectedProfiles.filter( 818 - profile => profile.did !== profileToRemove.did, 819 - ) 820 - setSelectedProfiles(updatedProfiles) 821 - AsyncStorage.setItem( 822 - 'selectedProfiles', 823 - JSON.stringify(updatedProfiles), 824 - ).catch(e => { 825 - logger.error('Failed to update selected profiles', {message: e}) 826 - }) 827 - }, 828 - [selectedProfiles], 829 775 ) 830 776 831 777 const onSearchInputFocus = React.useCallback(() => { ··· 932 878 /> 933 879 ) : ( 934 880 <SearchHistory 935 - searchHistory={searchHistory} 936 - selectedProfiles={selectedProfiles} 881 + searchHistory={termHistory} 882 + selectedProfiles={accountHistoryProfiles?.profiles || []} 937 883 onItemClick={handleHistoryItemClick} 938 884 onProfileClick={handleProfileClick} 939 - onRemoveItemClick={handleRemoveHistoryItem} 940 - onRemoveProfileClick={handleRemoveProfile} 885 + onRemoveItemClick={deleteSearchHistoryItem} 886 + onRemoveProfileClick={deleteProfileHistoryItem} 941 887 /> 942 888 )} 943 889 </View>