From 87da619aaa92e0ec762e68c13b24e58a25da10a8 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 3 Apr 2025 03:21:15 +0300 Subject: [Explore] Base (#8053) * migrate to #/screens * rm unneeded import * block drawer gesture on recent profiles * rm recommendations (#8056) * [Explore] Disable Trending videos (#8054) * remove giant header * disable * [Explore] Dynamic module ordering (#8066) * Dynamic module ordering * [Explore] New headers, metrics (#8067) * new sticky headers * improve spacing between modules * view metric on modules * update metrics names * [Explore] Suggested accounts module (#8072) * use modern profile card, update load more * add tab bar * tabbed suggested accounts * [Explore] Discover feeds module (#8073) * cap number of feeds to 3 * change feed pin button * Apply suggestions from code review Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * restore statsig to log events * filter out followed profiles, make suer enough are loaded (#8090) * [Explore] Trending topics (#8055) * redesigned trending topics * rm borders on web * get post count / age / ranking from api * spacing tweaks * fetch more topics then slice * use api data for avis/category * rm top border * Integrate new SDK, part out components * Clean up * Use status field * Bump SDK * Send up interests and langs --------- Co-authored-by: Eric Bailey * Clean up module spacing and borders (cherry picked from commit 63d19b6c2d67e226e0e14709b1047a1f88b3ce1c) (cherry picked from commit 62d7d394ab1dc31b40b9c2cf59075adbf94737a1) * Switch back border ordering (cherry picked from commit 34e3789f8b410132c1390df3c2bb8257630ebdd9) * [Explore] Starter Packs (#8095) * Temp WIP (cherry picked from commit 43b5d7b1e64b3adb1ed162262d0310e0bf026c18) * New SP card * Load state * Revert change * Cleanup * Interests and caching * Count total * Format * Caching * [Explore] Feed previews module (#8075) * wip new hook * get fetching working, maybe * get feed previews rendering! * fix header height * working pin button * extract out FeedLink * add loader * only make preview:header sticky * Fix headers * Header tweaks * Fix moderation filter * Fix threading --------- Co-authored-by: Eric Bailey * Space it out * Fix query key * Mock new endpoint, filter saved feeds * Make sure we're pinning, lower cache time * add news category * Remove log * Improve suggested accounts load state * Integrate new app view endpoint * fragment * Update src/screens/Search/modules/ExploreTrendingTopics.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/screens/Search/modules/ExploreTrendingTopics.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * lint * maybe fix this --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Eric Bailey Co-authored-by: Hailey --- src/screens/Search/Shell.tsx | 535 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 535 insertions(+) create mode 100644 src/screens/Search/Shell.tsx (limited to 'src/screens/Search/Shell.tsx') diff --git a/src/screens/Search/Shell.tsx b/src/screens/Search/Shell.tsx new file mode 100644 index 000000000..e930b8289 --- /dev/null +++ b/src/screens/Search/Shell.tsx @@ -0,0 +1,535 @@ +import { + memo, + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { + type StyleProp, + type TextInput, + View, + type ViewStyle, +} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' + +import {HITSLOP_20} from '#/lib/constants' +import {HITSLOP_10} from '#/lib/constants' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {MagnifyingGlassIcon} from '#/lib/icons' +import {type NavigationProp} from '#/lib/routes/types' +import {isWeb} from '#/platform/detection' +import {listenSoftReset} from '#/state/events' +import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' +import { + unstableCacheProfileView, + useProfilesQuery, +} from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {useSetMinimalShellMode} from '#/state/shell' +import { + makeSearchQuery, + type Params, + parseSearchQuery, +} from '#/screens/Search/utils' +import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {SearchInput} from '#/components/forms/SearchInput' +import * as Layout from '#/components/Layout' +import {Text} from '#/components/Typography' +import {account, useStorage} from '#/storage' +import type * as bsky from '#/types/bsky' +import {AutocompleteResults} from './components/AutocompleteResults' +import {SearchHistory} from './components/SearchHistory' +import {SearchLanguageDropdown} from './components/SearchLanguageDropdown' +import {Explore} from './Explore' +import {SearchResults} from './SearchResults' + +export function SearchScreenShell({ + queryParam, + testID, + fixedParams, + navButton = 'menu', + inputPlaceholder, +}: { + queryParam: string + testID: string + fixedParams?: Params + navButton?: 'back' | 'menu' + inputPlaceholder?: string +}) { + const t = useTheme() + const {gtMobile} = useBreakpoints() + const navigation = useNavigation() + const route = useRoute() + const textInput = useRef(null) + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const {currentAccount} = useSession() + const queryClient = useQueryClient() + + // Query terms + const [searchText, setSearchText] = useState(queryParam) + const {data: autocompleteData, isFetching: isAutocompleteFetching} = + useActorAutocompleteQuery(searchText, true) + + const [showAutocomplete, setShowAutocomplete] = useState(false) + + 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) => { + if (!item) return + const newSearchHistory = [ + item, + ...termHistory.filter(search => search !== item), + ].slice(0, 6) + setTermHistory(newSearchHistory) + }, + [termHistory, setTermHistory], + ) + + const updateProfileHistory = useCallback( + async (item: bsky.profile.AnyProfileView) => { + 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: bsky.profile.AnyProfileView) => { + setAccountHistory(accountHistory.filter(p => p !== item.did)) + }, + [accountHistory, setAccountHistory], + ) + + const {params, query, queryWithParams} = useQueryManager({ + initialQuery: queryParam, + fixedParams, + }) + const showFilters = Boolean(queryWithParams && !showAutocomplete) + + // web only - measure header height for sticky positioning + const [headerHeight, setHeaderHeight] = useState(0) + const headerRef = useRef(null) + useLayoutEffect(() => { + if (isWeb) { + if (!headerRef.current) return + const measurement = (headerRef.current as Element).getBoundingClientRect() + setHeaderHeight(measurement.height) + } + }, []) + + useFocusEffect( + useNonReactiveCallback(() => { + if (isWeb) { + setSearchText(queryParam) + } + }), + ) + + const onPressClearQuery = useCallback(() => { + scrollToTopWeb() + setSearchText('') + textInput.current?.focus() + }, []) + + const onChangeText = useCallback(async (text: string) => { + scrollToTopWeb() + setSearchText(text) + }, []) + + const navigateToItem = useCallback( + (item: string) => { + scrollToTopWeb() + setShowAutocomplete(false) + updateSearchHistory(item) + + if (isWeb) { + // @ts-expect-error route is not typesafe + navigation.push(route.name, {...route.params, q: item}) + } else { + textInput.current?.blur() + navigation.setParams({q: item}) + } + }, + [updateSearchHistory, navigation, route], + ) + + const onPressCancelSearch = useCallback(() => { + scrollToTopWeb() + textInput.current?.blur() + setShowAutocomplete(false) + if (isWeb) { + // Empty params resets the URL to be /search rather than /search?q= + + const {q: _q, ...parameters} = (route.params ?? {}) as { + [key: string]: string + } + // @ts-expect-error route is not typesafe + navigation.replace(route.name, parameters) + } else { + setSearchText('') + navigation.setParams({q: ''}) + } + }, [setShowAutocomplete, setSearchText, navigation, route.params, route.name]) + + const onSubmit = useCallback(() => { + navigateToItem(searchText) + }, [navigateToItem, searchText]) + + const onAutocompleteResultPress = useCallback(() => { + if (isWeb) { + setShowAutocomplete(false) + } else { + textInput.current?.blur() + } + }, []) + + const handleHistoryItemClick = useCallback( + (item: string) => { + setSearchText(item) + navigateToItem(item) + }, + [navigateToItem], + ) + + const handleProfileClick = useCallback( + (profile: bsky.profile.AnyProfileView) => { + unstableCacheProfileView(queryClient, profile) + // Slight delay to avoid updating during push nav animation. + setTimeout(() => { + updateProfileHistory(profile) + }, 400) + }, + [updateProfileHistory, queryClient], + ) + + const onSoftReset = useCallback(() => { + if (isWeb) { + // Empty params resets the URL to be /search rather than /search?q= + + const {q: _q, ...parameters} = (route.params ?? {}) as { + [key: string]: string + } + // @ts-expect-error route is not typesafe + navigation.replace(route.name, parameters) + } else { + setSearchText('') + navigation.setParams({q: ''}) + textInput.current?.focus() + } + }, [navigation, route]) + + useFocusEffect( + useCallback(() => { + setMinimalShellMode(false) + return listenSoftReset(onSoftReset) + }, [onSoftReset, setMinimalShellMode]), + ) + + const onSearchInputFocus = useCallback(() => { + if (isWeb) { + // Prevent a jump on iPad by ensuring that + // the initial focused render has no result list. + requestAnimationFrame(() => { + setShowAutocomplete(true) + }) + } else { + setShowAutocomplete(true) + } + }, [setShowAutocomplete]) + + const focusSearchInput = useCallback(() => { + textInput.current?.focus() + }, []) + + const showHeader = !gtMobile || navButton !== 'menu' + + return ( + + { + if (isWeb) setHeaderHeight(evt.nativeEvent.layout.height) + }} + style={[ + a.relative, + a.z_10, + web({ + position: 'sticky', + top: 0, + }), + ]}> + + {showHeader && ( + + )} + + + + + + + {showAutocomplete && ( + + )} + + + {showFilters && !showHeader && ( + + + + )} + + + + + + + {searchText.length > 0 ? ( + + ) : ( + + )} + + + + + + ) +} + +let SearchScreenInner = ({ + query, + queryWithParams, + headerHeight, + focusSearchInput, +}: { + query: string + queryWithParams: string + headerHeight: number + focusSearchInput: () => void +}): React.ReactNode => { + const t = useTheme() + const setMinimalShellMode = useSetMinimalShellMode() + const {hasSession} = useSession() + const {gtTablet} = useBreakpoints() + const [activeTab, setActiveTab] = useState(0) + const {_} = useLingui() + + const onPageSelected = useCallback( + (index: number) => { + setMinimalShellMode(false) + setActiveTab(index) + }, + [setMinimalShellMode], + ) + + return queryWithParams ? ( + + ) : hasSession ? ( + + ) : ( + + + {gtTablet && ( + + + Search + + + )} + + + } + /> + + Find posts, users, and feeds on Bluesky + + + + + ) +} +SearchScreenInner = memo(SearchScreenInner) + +function useQueryManager({ + initialQuery, + fixedParams, +}: { + initialQuery: string + fixedParams?: Params +}) { + const {query, params: initialParams} = useMemo(() => { + return parseSearchQuery(initialQuery || '') + }, [initialQuery]) + const [prevInitialQuery, setPrevInitialQuery] = useState(initialQuery) + const [lang, setLang] = useState(initialParams.lang || '') + + if (initialQuery !== prevInitialQuery) { + // handle new queryParam change (from manual search entry) + setPrevInitialQuery(initialQuery) + setLang(initialParams.lang || '') + } + + const params = useMemo( + () => ({ + // default stuff + ...initialParams, + // managed stuff + lang, + ...fixedParams, + }), + [lang, initialParams, fixedParams], + ) + const handlers = useMemo( + () => ({ + setLang, + }), + [setLang], + ) + + return useMemo(() => { + return { + query, + queryWithParams: makeSearchQuery(query, params), + params: { + ...params, + ...handlers, + }, + } + }, [query, params, handlers]) +} + +function scrollToTopWeb() { + if (isWeb) { + window.scrollTo(0, 0) + } +} -- cgit 1.4.1