diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 807fd92e5b..ce85b55c8c 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -1,9 +1,9 @@ import * as React from 'react' -import {JSX} from 'react/jsx-runtime' -import {i18n, MessageDescriptor} from '@lingui/core' +import {type JSX} from 'react/jsx-runtime' +import {i18n, type MessageDescriptor} from '@lingui/core' import {msg} from '@lingui/macro' import { - BottomTabBarProps, + type BottomTabBarProps, createBottomTabNavigator, } from '@react-navigation/bottom-tabs' import { @@ -20,16 +20,16 @@ import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration' import {buildStateObject} from '#/lib/routes/helpers' import { - AllNavigatorParams, - BottomTabNavigatorParams, - FlatNavigatorParams, - HomeTabNavigatorParams, - MessagesTabNavigatorParams, - MyProfileTabNavigatorParams, - NotificationsTabNavigatorParams, - SearchTabNavigatorParams, + type AllNavigatorParams, + type BottomTabNavigatorParams, + type FlatNavigatorParams, + type HomeTabNavigatorParams, + type MessagesTabNavigatorParams, + type MyProfileTabNavigatorParams, + type NotificationsTabNavigatorParams, + type SearchTabNavigatorParams, } from '#/lib/routes/types' -import {RouteParams, State} from '#/lib/routes/types' +import {type RouteParams, type State} from '#/lib/routes/types' import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig' import {bskyTitle} from '#/lib/strings/headings' import {logger} from '#/logger' @@ -59,7 +59,6 @@ import {ProfileScreen} from '#/view/screens/Profile' import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy' import {ProfileListScreen} from '#/view/screens/ProfileList' import {SavedFeeds} from '#/view/screens/SavedFeeds' -import {SearchScreen} from '#/view/screens/Search' import {Storybook} from '#/view/screens/Storybook' import {SupportScreen} from '#/view/screens/Support' import {TermsOfServiceScreen} from '#/view/screens/TermsOfService' @@ -81,6 +80,7 @@ import {ProfileFeedScreen} from '#/screens/Profile/ProfileFeed' import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers' import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows' import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' +import {SearchScreen} from '#/screens/Search' import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings' import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings' diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index 3690783d32..6e0be9d0a0 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -1,4 +1,4 @@ -import {StyleProp, TextStyle, ViewStyle} from 'react-native' +import {type StyleProp, type TextStyle, type ViewStyle} from 'react-native' import Svg, {Ellipse, Line, Path, Rect} from 'react-native-svg' // Copyright (c) 2020 Refactoring UI Inc. diff --git a/src/screens/Profile/ProfileSearch.tsx b/src/screens/Profile/ProfileSearch.tsx index d91dc973ee..6247e39792 100644 --- a/src/screens/Profile/ProfileSearch.tsx +++ b/src/screens/Profile/ProfileSearch.tsx @@ -2,11 +2,14 @@ import {useMemo} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import { + type CommonNavigatorParams, + type NativeStackScreenProps, +} from '#/lib/routes/types' import {useProfileQuery} from '#/state/queries/profile' import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useSession} from '#/state/session' -import {SearchScreenShell} from '#/view/screens/Search/Search' +import {SearchScreenShell} from '#/screens/Search/Shell' type Props = NativeStackScreenProps export const ProfileSearchScreen = ({route}: Props) => { diff --git a/src/view/screens/Search/Explore.tsx b/src/screens/Search/Explore.tsx similarity index 94% rename from src/view/screens/Search/Explore.tsx rename to src/screens/Search/Explore.tsx index 520e103a4f..8be0d7c2b5 100644 --- a/src/view/screens/Search/Explore.tsx +++ b/src/screens/Search/Explore.tsx @@ -1,16 +1,15 @@ -import React from 'react' +import {useCallback, useMemo} from 'react' import {View} from 'react-native' import { - AppBskyActorDefs, - AppBskyFeedDefs, + type AppBskyActorDefs, + type AppBskyFeedDefs, moderateProfile, - ModerationDecision, - ModerationOpts, + type ModerationDecision, + type ModerationOpts, } from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {logEvent} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' import {isNative, isWeb} from '#/platform/detection' @@ -25,15 +24,15 @@ import { ProfileCardFeedLoadingPlaceholder, } from '#/view/com/util/LoadingPlaceholder' import {UserAvatar} from '#/view/com/util/UserAvatar' -import {ExploreRecommendations} from '#/screens/Search/components/ExploreRecommendations' -import {ExploreTrendingTopics} from '#/screens/Search/components/ExploreTrendingTopics' -import {ExploreTrendingVideos} from '#/screens/Search/components/ExploreTrendingVideos' -import {atoms as a, useTheme, ViewStyleProp} from '#/alf' +import {ExploreRecommendations} from '#/screens/Search/modules/ExploreRecommendations' +import {ExploreTrendingTopics} from '#/screens/Search/modules/ExploreTrendingTopics' +import {ExploreTrendingVideos} from '#/screens/Search/modules/ExploreTrendingVideos' +import {atoms as a, useTheme, type ViewStyleProp} from '#/alf' import {Button} from '#/components/Button' import * as FeedCard from '#/components/FeedCard' import {ArrowBottom_Stroke2_Corner0_Rounded as ArrowBottom} from '#/components/icons/Arrow' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' -import {Props as SVGIconProps} from '#/components/icons/common' +import {type Props as SVGIconProps} from '#/components/icons/common' import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' import {Loader} from '#/components/Loader' @@ -101,7 +100,7 @@ function LoadMore({ }) { const t = useTheme() const {_} = useLingui() - const items: LoadMoreItem[] = React.useMemo(() => { + const items: LoadMoreItem[] = useMemo(() => { return item.items .map(_item => { let loadMoreItem: LoadMoreItem | undefined @@ -312,7 +311,7 @@ export function Explore() { } = useGetPopularFeedsQuery({limit: 10}) const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles - const onLoadMoreProfiles = React.useCallback(async () => { + const onLoadMoreProfiles = useCallback(async () => { if (isFetchingNextProfilesPage || !hasNextProfilesPage || profilesError) return try { @@ -328,7 +327,7 @@ export function Explore() { ]) const isLoadingMoreFeeds = isFetchingNextFeedsPage && !isLoadingFeeds - const onLoadMoreFeeds = React.useCallback(async () => { + const onLoadMoreFeeds = useCallback(async () => { if (isFetchingNextFeedsPage || !hasNextFeedsPage || feedsError) return try { await fetchNextFeedsPage() @@ -342,7 +341,7 @@ export function Explore() { fetchNextFeedsPage, ]) - const items = React.useMemo(() => { + const items = useMemo(() => { const i: ExploreScreenItems[] = [] i.push({ @@ -513,7 +512,7 @@ export function Explore() { hasNextFeedsPage, ]) - const renderItem = React.useCallback( + const renderItem = useCallback( ({item, index}: {item: ExploreScreenItems; index: number}) => { switch (item.type) { case 'header': { @@ -544,14 +543,14 @@ export function Explore() { noBorder showKnownFollowers onPress={() => { - logEvent('suggestedUser:press', { + logger.metric('suggestedUser:press', { logContext: 'Explore', recId: item.recId, position: index, }) }} onFollow={() => { - logEvent('suggestedUser:follow', { + logger.metric('suggestedUser:follow', { logContext: 'Explore', location: 'Card', recId: item.recId, @@ -631,7 +630,6 @@ export function Explore() { data={items} renderItem={renderItem} keyExtractor={item => item.key} - // @ts-ignore web only -prf desktopFixedHeight contentContainerStyle={{paddingBottom: 100}} keyboardShouldPersistTaps="handled" diff --git a/src/screens/Search/SearchResults.tsx b/src/screens/Search/SearchResults.tsx new file mode 100644 index 0000000000..ed9679817b --- /dev/null +++ b/src/screens/Search/SearchResults.tsx @@ -0,0 +1,338 @@ +import {memo, useCallback, useMemo, useState} from 'react' +import {ActivityIndicator, View} from 'react-native' +import {type AppBskyFeedDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {augmentSearchQuery} from '#/lib/strings/helpers' +import {useActorSearch} from '#/state/queries/actor-search' +import {usePopularFeedsSearch} from '#/state/queries/feed' +import {useSearchPostsQuery} from '#/state/queries/search-posts' +import {useSession} from '#/state/session' +import {Pager} from '#/view/com/pager/Pager' +import {TabBar} from '#/view/com/pager/TabBar' +import {Post} from '#/view/com/post/Post' +import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' +import {List} from '#/view/com/util/List' +import {atoms as a, useTheme, web} from '#/alf' +import * as FeedCard from '#/components/FeedCard' +import * as Layout from '#/components/Layout' +import {Text} from '#/components/Typography' + +let SearchResults = ({ + query, + queryWithParams, + activeTab, + onPageSelected, + headerHeight, +}: { + query: string + queryWithParams: string + activeTab: number + onPageSelected: (page: number) => void + headerHeight: number +}): React.ReactNode => { + const {_} = useLingui() + + const sections = useMemo(() => { + if (!queryWithParams) return [] + const noParams = queryWithParams === query + return [ + { + title: _(msg`Top`), + component: ( + + ), + }, + { + title: _(msg`Latest`), + component: ( + + ), + }, + noParams && { + title: _(msg`People`), + component: ( + + ), + }, + noParams && { + title: _(msg`Feeds`), + component: ( + + ), + }, + ].filter(Boolean) as { + title: string + component: React.ReactNode + }[] + }, [_, query, queryWithParams, activeTab]) + + return ( + ( + + section.title)} {...props} /> + + )} + initialPage={0}> + {sections.map((section, i) => ( + {section.component} + ))} + + ) +} +SearchResults = memo(SearchResults) +export {SearchResults} + +function Loader() { + return ( + + + + + + ) +} + +function EmptyState({message, error}: {message: string; error?: string}) { + const t = useTheme() + + return ( + + + + {message} + + {error && ( + <> + + + + Error: {error} + + + )} + + + + ) +} + +type SearchResultSlice = + | { + type: 'post' + key: string + post: AppBskyFeedDefs.PostView + } + | { + type: 'loadingMore' + key: string + } + +let SearchScreenPostResults = ({ + query, + sort, + active, +}: { + query: string + sort?: 'top' | 'latest' + active: boolean +}): React.ReactNode => { + const {_} = useLingui() + const {currentAccount} = useSession() + const [isPTR, setIsPTR] = useState(false) + + const augmentedQuery = useMemo(() => { + return augmentSearchQuery(query || '', {did: currentAccount?.did}) + }, [query, currentAccount]) + + const { + isFetched, + data: results, + isFetching, + error, + refetch, + fetchNextPage, + isFetchingNextPage, + hasNextPage, + } = useSearchPostsQuery({query: augmentedQuery, sort, enabled: active}) + + const onPullToRefresh = useCallback(async () => { + setIsPTR(true) + await refetch() + setIsPTR(false) + }, [setIsPTR, refetch]) + const onEndReached = useCallback(() => { + if (isFetching || !hasNextPage || error) return + fetchNextPage() + }, [isFetching, error, hasNextPage, fetchNextPage]) + + const posts = useMemo(() => { + return results?.pages.flatMap(page => page.posts) || [] + }, [results]) + const items = useMemo(() => { + let temp: SearchResultSlice[] = [] + + const seenUris = new Set() + for (const post of posts) { + if (seenUris.has(post.uri)) { + continue + } + temp.push({ + type: 'post', + key: post.uri, + post, + }) + seenUris.add(post.uri) + } + + if (isFetchingNextPage) { + temp.push({ + type: 'loadingMore', + key: 'loadingMore', + }) + } + + return temp + }, [posts, isFetchingNextPage]) + + return error ? ( + + ) : ( + <> + {isFetched ? ( + <> + {posts.length ? ( + { + if (item.type === 'post') { + return + } else { + return null + } + }} + keyExtractor={item => item.key} + refreshing={isPTR} + onRefresh={onPullToRefresh} + onEndReached={onEndReached} + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + + )} + + ) : ( + + )} + + ) +} +SearchScreenPostResults = memo(SearchScreenPostResults) + +let SearchScreenUserResults = ({ + query, + active, +}: { + query: string + active: boolean +}): React.ReactNode => { + const {_} = useLingui() + + const {data: results, isFetched} = useActorSearch({ + query, + enabled: active, + }) + + return isFetched && results ? ( + <> + {results.length ? ( + ( + + )} + keyExtractor={item => item.did} + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + + )} + + ) : ( + + ) +} +SearchScreenUserResults = memo(SearchScreenUserResults) + +let SearchScreenFeedsResults = ({ + query, + active, +}: { + query: string + active: boolean +}): React.ReactNode => { + const t = useTheme() + const {_} = useLingui() + + const {data: results, isFetched} = usePopularFeedsSearch({ + query, + enabled: active, + }) + + return isFetched && results ? ( + <> + {results.length ? ( + ( + + + + )} + keyExtractor={item => item.uri} + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + + )} + + ) : ( + + ) +} +SearchScreenFeedsResults = memo(SearchScreenFeedsResults) diff --git a/src/screens/Search/Shell.tsx b/src/screens/Search/Shell.tsx new file mode 100644 index 0000000000..477380e711 --- /dev/null +++ b/src/screens/Search/Shell.tsx @@ -0,0 +1,528 @@ +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 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, +}: { + query: string + queryWithParams: string + headerHeight: number +}): 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) + } +} diff --git a/src/screens/Search/components/AutocompleteResults.tsx b/src/screens/Search/components/AutocompleteResults.tsx new file mode 100644 index 0000000000..58a0dec779 --- /dev/null +++ b/src/screens/Search/components/AutocompleteResults.tsx @@ -0,0 +1,71 @@ +import {memo} from 'react' +import {ActivityIndicator, View} from 'react-native' +import {type AppBskyActorDefs, moderateProfile} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isNative} from '#/platform/detection' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search' +import {atoms as a, native} from '#/alf' +import * as Layout from '#/components/Layout' + +let AutocompleteResults = ({ + isAutocompleteFetching, + autocompleteData, + searchText, + onSubmit, + onResultPress, + onProfileClick, +}: { + isAutocompleteFetching: boolean + autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined + searchText: string + onSubmit: () => void + onResultPress: () => void + onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void +}): React.ReactNode => { + const moderationOpts = useModerationOpts() + const {_} = useLingui() + return ( + <> + {(isAutocompleteFetching && !autocompleteData?.length) || + !moderationOpts ? ( + + + + + + ) : ( + + + {autocompleteData?.map(item => ( + { + onProfileClick(item) + onResultPress() + }} + /> + ))} + + + )} + + ) +} +AutocompleteResults = memo(AutocompleteResults) +export {AutocompleteResults} diff --git a/src/screens/Search/components/SearchHistory.tsx b/src/screens/Search/components/SearchHistory.tsx new file mode 100644 index 0000000000..7e65a0817c --- /dev/null +++ b/src/screens/Search/components/SearchHistory.tsx @@ -0,0 +1,167 @@ +import {Pressable, StyleSheet, View} from 'react-native' +import {ScrollView as RNGHScrollView} from 'react-native-gesture-handler' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {createHitslop, HITSLOP_10} from '#/lib/constants' +import {makeProfileLink} from '#/lib/routes/links' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {Link} from '#/view/com/util/Link' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import * as Layout from '#/components/Layout' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' + +export function SearchHistory({ + searchHistory, + selectedProfiles, + onItemClick, + onProfileClick, + onRemoveItemClick, + onRemoveProfileClick, +}: { + searchHistory: string[] + selectedProfiles: bsky.profile.AnyProfileView[] + onItemClick: (item: string) => void + onProfileClick: (profile: bsky.profile.AnyProfileView) => void + onRemoveItemClick: (item: string) => void + onRemoveProfileClick: (profile: bsky.profile.AnyProfileView) => void +}) { + const {gtMobile} = useBreakpoints() + const t = useTheme() + const {_} = useLingui() + + return ( + + + {(searchHistory.length > 0 || selectedProfiles.length > 0) && ( + + Recent Searches + + )} + {selectedProfiles.length > 0 && ( + + + {selectedProfiles.slice(0, 5).map((profile, index) => ( + + onProfileClick(profile)} + style={[a.align_center, a.w_full]}> + + + {sanitizeDisplayName( + profile.displayName || profile.handle, + )} + + + onRemoveProfileClick(profile)} + hitSlop={createHitslop(6)} + style={styles.profileRemoveBtn}> + + + + ))} + + + )} + {searchHistory.length > 0 && ( + + {searchHistory.slice(0, 5).map((historyItem, index) => ( + + onItemClick(historyItem)} + hitSlop={HITSLOP_10} + style={[a.flex_1, a.py_md]}> + {historyItem} + + + + ))} + + )} + + + ) +} + +const styles = StyleSheet.create({ + selectedProfilesContainer: { + marginTop: 10, + paddingHorizontal: 12, + height: 80, + }, + selectedProfilesContainerMobile: { + height: 100, + }, + profileItem: { + alignItems: 'center', + marginRight: 15, + width: 78, + }, + profileItemMobile: { + width: 70, + }, + profileName: { + width: 78, + marginTop: 6, + }, + profileRemoveBtn: { + position: 'absolute', + top: 0, + right: 5, + backgroundColor: 'white', + borderRadius: 10, + width: 18, + height: 18, + alignItems: 'center', + justifyContent: 'center', + }, +}) diff --git a/src/screens/Search/components/SearchLanguageDropdown.tsx b/src/screens/Search/components/SearchLanguageDropdown.tsx new file mode 100644 index 0000000000..5c5a4b74fe --- /dev/null +++ b/src/screens/Search/components/SearchLanguageDropdown.tsx @@ -0,0 +1,120 @@ +import {useMemo} from 'react' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {languageName} from '#/locale/helpers' +import {APP_LANGUAGES, LANGUAGES} from '#/locale/languages' +import {useLanguagePrefs} from '#/state/preferences' +import {atoms as a, native, platform, tokens} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import { + ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, + ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon, +} from '#/components/icons/Chevron' +import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe' +import * as Menu from '#/components/Menu' + +export function SearchLanguageDropdown({ + value, + onChange, +}: { + value: string + onChange(value: string): void +}) { + const {_} = useLingui() + const {appLanguage, contentLanguages} = useLanguagePrefs() + + const languages = useMemo(() => { + return LANGUAGES.filter( + (lang, index, self) => + Boolean(lang.code2) && // reduce to the code2 varieties + index === self.findIndex(t => t.code2 === lang.code2), // remove dupes (which will happen) + ) + .map(l => ({ + label: languageName(l, appLanguage), + value: l.code2, + key: l.code2 + l.code3, + })) + .sort((a, b) => { + // prioritize user's languages + const aIsUser = contentLanguages.includes(a.value) + const bIsUser = contentLanguages.includes(b.value) + if (aIsUser && !bIsUser) return -1 + if (bIsUser && !aIsUser) return 1 + // prioritize "common" langs in the network + const aIsCommon = !!APP_LANGUAGES.find( + al => + // skip `ast`, because it uses a 3-letter code which conflicts with `as` + // it begins with `a` anyway so still is top of the list + al.code2 !== 'ast' && al.code2.startsWith(a.value), + ) + const bIsCommon = !!APP_LANGUAGES.find( + al => + // ditto + al.code2 !== 'ast' && al.code2.startsWith(b.value), + ) + if (aIsCommon && !bIsCommon) return -1 + if (bIsCommon && !aIsCommon) return 1 + // fall back to alphabetical + return a.label.localeCompare(b.label) + }) + }, [appLanguage, contentLanguages]) + + const currentLanguageLabel = + languages.find(lang => lang.value === value)?.label ?? _(msg`All languages`) + + return ( + + + {({props}) => ( + + )} + + + + Filter search by language + + onChange('')}> + + All languages + + + + + + {languages.map(lang => ( + onChange(lang.value)}> + {lang.label} + + + ))} + + + + ) +} diff --git a/src/screens/Search/index.tsx b/src/screens/Search/index.tsx new file mode 100644 index 0000000000..429f1e5c7a --- /dev/null +++ b/src/screens/Search/index.tsx @@ -0,0 +1,13 @@ +import { + type NativeStackScreenProps, + type SearchTabNavigatorParams, +} from '#/lib/routes/types' +import {SearchScreenShell} from './Shell' + +export function SearchScreen( + props: NativeStackScreenProps, +) { + const queryParam = props.route?.params?.q ?? '' + + return +} diff --git a/src/screens/Search/components/ExploreRecommendations.tsx b/src/screens/Search/modules/ExploreRecommendations.tsx similarity index 94% rename from src/screens/Search/components/ExploreRecommendations.tsx rename to src/screens/Search/modules/ExploreRecommendations.tsx index 602bab87d2..628c6673c5 100644 --- a/src/screens/Search/components/ExploreRecommendations.tsx +++ b/src/screens/Search/modules/ExploreRecommendations.tsx @@ -1,8 +1,8 @@ import {View} from 'react-native' -import {AppBskyUnspeccedDefs} from '@atproto/api' +import {type AppBskyUnspeccedDefs} from '@atproto/api' import {Trans} from '@lingui/macro' -import {logEvent} from '#/lib/statsig/statsig' +import {logger} from '#/logger' import {isWeb} from '#/platform/detection' import { DEFAULT_LIMIT as RECOMMENDATIONS_COUNT, @@ -86,7 +86,9 @@ function Inner() { key={topic.link} topic={topic} onPress={() => { - logEvent('recommendedTopic:click', {context: 'explore'}) + logger.metric('recommendedTopic:click', { + context: 'explore', + }) }}> {({hovered}) => ( { - logEvent('trendingTopics:hide', {context: 'explore:trending'}) + logger.metric('trendingTopics:hide', {context: 'explore:trending'}) setTrendingDisabled(true) }, [setTrendingDisabled]) @@ -102,7 +102,7 @@ function Inner() { {isLoading ? ( Array(TRENDING_TOPICS_COUNT) .fill(0) - .map((_, i) => ) + .map((__, i) => ) ) : !trending?.topics ? null : ( <> {trending.topics.map(topic => ( @@ -110,7 +110,7 @@ function Inner() { key={topic.link} topic={topic} onPress={() => { - logEvent('trendingTopic:click', {context: 'explore'}) + logger.metric('trendingTopic:click', {context: 'explore'}) }}> {({hovered}) => ( - - - - - ) -} - -function EmptyState({message, error}: {message: string; error?: string}) { - const t = useTheme() - - return ( - - - - {message} - - {error && ( - <> - - - - Error: {error} - - - )} - - - - ) -} - -type SearchResultSlice = - | { - type: 'post' - key: string - post: AppBskyFeedDefs.PostView - } - | { - type: 'loadingMore' - key: string - } - -let SearchScreenPostResults = ({ - query, - sort, - active, -}: { - query: string - sort?: 'top' | 'latest' - active: boolean -}): React.ReactNode => { - const {_} = useLingui() - const {currentAccount} = useSession() - const [isPTR, setIsPTR] = React.useState(false) - - const augmentedQuery = React.useMemo(() => { - return augmentSearchQuery(query || '', {did: currentAccount?.did}) - }, [query, currentAccount]) - - const { - isFetched, - data: results, - isFetching, - error, - refetch, - fetchNextPage, - isFetchingNextPage, - hasNextPage, - } = useSearchPostsQuery({query: augmentedQuery, sort, enabled: active}) - - const onPullToRefresh = React.useCallback(async () => { - setIsPTR(true) - await refetch() - setIsPTR(false) - }, [setIsPTR, refetch]) - const onEndReached = React.useCallback(() => { - if (isFetching || !hasNextPage || error) return - fetchNextPage() - }, [isFetching, error, hasNextPage, fetchNextPage]) - - const posts = React.useMemo(() => { - return results?.pages.flatMap(page => page.posts) || [] - }, [results]) - const items = React.useMemo(() => { - let temp: SearchResultSlice[] = [] - - const seenUris = new Set() - for (const post of posts) { - if (seenUris.has(post.uri)) { - continue - } - temp.push({ - type: 'post', - key: post.uri, - post, - }) - seenUris.add(post.uri) - } - - if (isFetchingNextPage) { - temp.push({ - type: 'loadingMore', - key: 'loadingMore', - }) - } - - return temp - }, [posts, isFetchingNextPage]) - - return error ? ( - - ) : ( - <> - {isFetched ? ( - <> - {posts.length ? ( - { - if (item.type === 'post') { - return - } else { - return null - } - }} - keyExtractor={item => item.key} - refreshing={isPTR} - onRefresh={onPullToRefresh} - onEndReached={onEndReached} - desktopFixedHeight - contentContainerStyle={{paddingBottom: 100}} - /> - ) : ( - - )} - - ) : ( - - )} - - ) -} -SearchScreenPostResults = React.memo(SearchScreenPostResults) - -let SearchScreenUserResults = ({ - query, - active, -}: { - query: string - active: boolean -}): React.ReactNode => { - const {_} = useLingui() - - const {data: results, isFetched} = useActorSearch({ - query, - enabled: active, - }) - - return isFetched && results ? ( - <> - {results.length ? ( - ( - - )} - keyExtractor={item => item.did} - desktopFixedHeight - contentContainerStyle={{paddingBottom: 100}} - /> - ) : ( - - )} - - ) : ( - - ) -} -SearchScreenUserResults = React.memo(SearchScreenUserResults) - -let SearchScreenFeedsResults = ({ - query, - active, -}: { - query: string - active: boolean -}): React.ReactNode => { - const t = useTheme() - const {_} = useLingui() - - const {data: results, isFetched} = usePopularFeedsSearch({ - query, - enabled: active, - }) - - return isFetched && results ? ( - <> - {results.length ? ( - ( - - - - )} - keyExtractor={item => item.uri} - desktopFixedHeight - contentContainerStyle={{paddingBottom: 100}} - /> - ) : ( - - )} - - ) : ( - - ) -} -SearchScreenFeedsResults = React.memo(SearchScreenFeedsResults) - -function SearchLanguageDropdown({ - value, - onChange, -}: { - value: string - onChange(value: string): void -}) { - const {_} = useLingui() - const {appLanguage, contentLanguages} = useLanguagePrefs() - - const languages = useMemo(() => { - return LANGUAGES.filter( - (lang, index, self) => - Boolean(lang.code2) && // reduce to the code2 varieties - index === self.findIndex(t => t.code2 === lang.code2), // remove dupes (which will happen) - ) - .map(l => ({ - label: languageName(l, appLanguage), - value: l.code2, - key: l.code2 + l.code3, - })) - .sort((a, b) => { - // prioritize user's languages - const aIsUser = contentLanguages.includes(a.value) - const bIsUser = contentLanguages.includes(b.value) - if (aIsUser && !bIsUser) return -1 - if (bIsUser && !aIsUser) return 1 - // prioritize "common" langs in the network - const aIsCommon = !!APP_LANGUAGES.find( - al => - // skip `ast`, because it uses a 3-letter code which conflicts with `as` - // it begins with `a` anyway so still is top of the list - al.code2 !== 'ast' && al.code2.startsWith(a.value), - ) - const bIsCommon = !!APP_LANGUAGES.find( - al => - // ditto - al.code2 !== 'ast' && al.code2.startsWith(b.value), - ) - if (aIsCommon && !bIsCommon) return -1 - if (bIsCommon && !aIsCommon) return 1 - // fall back to alphabetical - return a.label.localeCompare(b.label) - }) - }, [appLanguage, contentLanguages]) - - const currentLanguageLabel = - languages.find(lang => lang.value === value)?.label ?? _(msg`All languages`) - - return ( - - - {({props}) => ( - - )} - - - - Filter search by language - - onChange('')}> - - All languages - - - - - - {languages.map(lang => ( - onChange(lang.value)}> - {lang.label} - - - ))} - - - - ) -} - -function useQueryManager({ - initialQuery, - fixedParams, -}: { - initialQuery: string - fixedParams?: Params -}) { - const {query, params: initialParams} = React.useMemo(() => { - return parseSearchQuery(initialQuery || '') - }, [initialQuery]) - const [prevInitialQuery, setPrevInitialQuery] = React.useState(initialQuery) - const [lang, setLang] = React.useState(initialParams.lang || '') - - if (initialQuery !== prevInitialQuery) { - // handle new queryParam change (from manual search entry) - setPrevInitialQuery(initialQuery) - setLang(initialParams.lang || '') - } - - const params = React.useMemo( - () => ({ - // default stuff - ...initialParams, - // managed stuff - lang, - ...fixedParams, - }), - [lang, initialParams, fixedParams], - ) - const handlers = React.useMemo( - () => ({ - setLang, - }), - [setLang], - ) - - return React.useMemo(() => { - return { - query, - queryWithParams: makeSearchQuery(query, params), - params: { - ...params, - ...handlers, - }, - } - }, [query, params, handlers]) -} - -let SearchScreenInner = ({ - query, - queryWithParams, - headerHeight, -}: { - query: string - queryWithParams: string - headerHeight: number -}): React.ReactNode => { - const t = useTheme() - const setMinimalShellMode = useSetMinimalShellMode() - const {hasSession} = useSession() - const {gtTablet} = useBreakpoints() - const [activeTab, setActiveTab] = React.useState(0) - const {_} = useLingui() - - const onPageSelected = React.useCallback( - (index: number) => { - setMinimalShellMode(false) - setActiveTab(index) - }, - [setMinimalShellMode], - ) - - const sections = React.useMemo(() => { - if (!queryWithParams) return [] - const noParams = queryWithParams === query - return [ - { - title: _(msg`Top`), - component: ( - - ), - }, - { - title: _(msg`Latest`), - component: ( - - ), - }, - noParams && { - title: _(msg`People`), - component: ( - - ), - }, - noParams && { - title: _(msg`Feeds`), - component: ( - - ), - }, - ].filter(Boolean) as { - title: string - component: React.ReactNode - }[] - }, [_, query, queryWithParams, activeTab]) - - return queryWithParams ? ( - ( - - section.title)} {...props} /> - - )} - initialPage={0}> - {sections.map((section, i) => ( - {section.component} - ))} - - ) : hasSession ? ( - - ) : ( - - - {gtTablet && ( - - - Search - - - )} - - - } - /> - - Find posts, users, and feeds on Bluesky - - - - - ) -} -SearchScreenInner = React.memo(SearchScreenInner) - -export function SearchScreen( - props: NativeStackScreenProps, -) { - const queryParam = props.route?.params?.q ?? '' - - return -} - -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 = React.useRef(null) - const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() - const {currentAccount} = useSession() - const queryClient = useQueryClient() - - // Query terms - const [searchText, setSearchText] = React.useState(queryParam) - const {data: autocompleteData, isFetching: isAutocompleteFetching} = - useActorAutocompleteQuery(searchText, true) - - const [showAutocomplete, setShowAutocomplete] = React.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: AppBskyActorDefs.ProfileViewDetailed) => { - 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] = React.useState(0) - const headerRef = React.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 = React.useCallback(() => { - scrollToTopWeb() - setSearchText('') - textInput.current?.focus() - }, []) - - const onChangeText = React.useCallback(async (text: string) => { - scrollToTopWeb() - setSearchText(text) - }, []) - - const navigateToItem = React.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 = React.useCallback(() => { - scrollToTopWeb() - textInput.current?.blur() - setShowAutocomplete(false) - if (isWeb) { - // Empty params resets the URL to be /search rather than /search?q= - // eslint-disable-next-line @typescript-eslint/no-unused-vars - 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 = React.useCallback(() => { - navigateToItem(searchText) - }, [navigateToItem, searchText]) - - const onAutocompleteResultPress = React.useCallback(() => { - if (isWeb) { - setShowAutocomplete(false) - } else { - textInput.current?.blur() - } - }, []) - - const handleHistoryItemClick = React.useCallback( - (item: string) => { - setSearchText(item) - navigateToItem(item) - }, - [navigateToItem], - ) - - const handleProfileClick = React.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 = React.useCallback(() => { - if (isWeb) { - // Empty params resets the URL to be /search rather than /search?q= - // eslint-disable-next-line @typescript-eslint/no-unused-vars - 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( - React.useCallback(() => { - setMinimalShellMode(false) - return listenSoftReset(onSoftReset) - }, [onSoftReset, setMinimalShellMode]), - ) - - const onSearchInputFocus = React.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 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 AutocompleteResults = ({ - isAutocompleteFetching, - autocompleteData, - searchText, - onSubmit, - onResultPress, - onProfileClick, -}: { - isAutocompleteFetching: boolean - autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined - searchText: string - onSubmit: () => void - onResultPress: () => void - onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void -}): React.ReactNode => { - const moderationOpts = useModerationOpts() - const {_} = useLingui() - return ( - <> - {(isAutocompleteFetching && !autocompleteData?.length) || - !moderationOpts ? ( - - ) : ( - - - {autocompleteData?.map(item => ( - { - onProfileClick(item) - onResultPress() - }} - /> - ))} - - - )} - - ) -} -AutocompleteResults = React.memo(AutocompleteResults) - -function SearchHistory({ - searchHistory, - selectedProfiles, - onItemClick, - onProfileClick, - onRemoveItemClick, - onRemoveProfileClick, -}: { - searchHistory: string[] - selectedProfiles: AppBskyActorDefs.ProfileViewDetailed[] - onItemClick: (item: string) => void - onProfileClick: (profile: AppBskyActorDefs.ProfileViewDetailed) => void - onRemoveItemClick: (item: string) => void - onRemoveProfileClick: (profile: AppBskyActorDefs.ProfileViewDetailed) => void -}) { - const {gtMobile} = useBreakpoints() - const t = useTheme() - const {_} = useLingui() - - return ( - - - {(searchHistory.length > 0 || selectedProfiles.length > 0) && ( - - Recent Searches - - )} - {selectedProfiles.length > 0 && ( - - - {selectedProfiles.slice(0, 5).map((profile, index) => ( - - onProfileClick(profile)} - style={[a.align_center, a.w_full]}> - - - {sanitizeDisplayName( - profile.displayName || profile.handle, - )} - - - onRemoveProfileClick(profile)} - hitSlop={createHitslop(6)} - style={styles.profileRemoveBtn}> - - - - ))} - - - )} - {searchHistory.length > 0 && ( - - {searchHistory.slice(0, 5).map((historyItem, index) => ( - - onItemClick(historyItem)} - hitSlop={HITSLOP_10} - style={[a.flex_1, a.py_md]}> - {historyItem} - - - - ))} - - )} - - - ) -} - -function scrollToTopWeb() { - if (isWeb) { - window.scrollTo(0, 0) - } -} - -const styles = StyleSheet.create({ - selectedProfilesContainer: { - marginTop: 10, - paddingHorizontal: 12, - height: 80, - }, - selectedProfilesContainerMobile: { - height: 100, - }, - profileItem: { - alignItems: 'center', - marginRight: 15, - width: 78, - }, - profileItemMobile: { - width: 70, - }, - profileName: { - width: 78, - marginTop: 6, - }, - profileRemoveBtn: { - position: 'absolute', - top: 0, - right: 5, - backgroundColor: 'white', - borderRadius: 10, - width: 18, - height: 18, - alignItems: 'center', - justifyContent: 'center', - }, -}) diff --git a/src/view/screens/Search/index.tsx b/src/view/screens/Search/index.tsx deleted file mode 100644 index f6c0eca262..0000000000 --- a/src/view/screens/Search/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export {SearchScreen} from '#/view/screens/Search/Search'