migrate to #/screens

This commit is contained in:
Samuel Newman
2025-03-25 11:10:55 +02:00
committed by Eric Bailey
parent 0874d9602a
commit a0de9f929f
15 changed files with 1282 additions and 1208 deletions
+13 -13
View File
@@ -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'
+1 -1
View File
@@ -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.
+5 -2
View File
@@ -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<CommonNavigatorParams, 'ProfileSearch'>
export const ProfileSearchScreen = ({route}: Props) => {
@@ -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<ExploreScreenItems[]>(() => {
const items = useMemo<ExploreScreenItems[]>(() => {
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"
+338
View File
@@ -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: (
<SearchScreenPostResults
query={queryWithParams}
sort="top"
active={activeTab === 0}
/>
),
},
{
title: _(msg`Latest`),
component: (
<SearchScreenPostResults
query={queryWithParams}
sort="latest"
active={activeTab === 1}
/>
),
},
noParams && {
title: _(msg`People`),
component: (
<SearchScreenUserResults query={query} active={activeTab === 2} />
),
},
noParams && {
title: _(msg`Feeds`),
component: (
<SearchScreenFeedsResults query={query} active={activeTab === 3} />
),
},
].filter(Boolean) as {
title: string
component: React.ReactNode
}[]
}, [_, query, queryWithParams, activeTab])
return (
<Pager
onPageSelected={onPageSelected}
renderTabBar={props => (
<Layout.Center style={[a.z_10, web([a.sticky, {top: headerHeight}])]}>
<TabBar items={sections.map(section => section.title)} {...props} />
</Layout.Center>
)}
initialPage={0}>
{sections.map((section, i) => (
<View key={i}>{section.component}</View>
))}
</Pager>
)
}
SearchResults = memo(SearchResults)
export {SearchResults}
function Loader() {
return (
<Layout.Content>
<View style={[a.py_xl]}>
<ActivityIndicator />
</View>
</Layout.Content>
)
}
function EmptyState({message, error}: {message: string; error?: string}) {
const t = useTheme()
return (
<Layout.Content>
<View style={[a.p_xl]}>
<View style={[t.atoms.bg_contrast_25, a.rounded_sm, a.p_lg]}>
<Text style={[a.text_md]}>{message}</Text>
{error && (
<>
<View
style={[
{
marginVertical: 12,
height: 1,
width: '100%',
backgroundColor: t.atoms.text.color,
opacity: 0.2,
},
]}
/>
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>Error:</Trans> {error}
</Text>
</>
)}
</View>
</View>
</Layout.Content>
)
}
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 ? (
<EmptyState
message={_(
msg`We're sorry, but your search could not be completed. Please try again in a few minutes.`,
)}
error={error.toString()}
/>
) : (
<>
{isFetched ? (
<>
{posts.length ? (
<List
data={items}
renderItem={({item}) => {
if (item.type === 'post') {
return <Post post={item.post} />
} else {
return null
}
}}
keyExtractor={item => item.key}
refreshing={isPTR}
onRefresh={onPullToRefresh}
onEndReached={onEndReached}
desktopFixedHeight
contentContainerStyle={{paddingBottom: 100}}
/>
) : (
<EmptyState message={_(msg`No results found for ${query}`)} />
)}
</>
) : (
<Loader />
)}
</>
)
}
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 ? (
<List
data={results}
renderItem={({item}) => (
<ProfileCardWithFollowBtn profile={item} noBg />
)}
keyExtractor={item => item.did}
desktopFixedHeight
contentContainerStyle={{paddingBottom: 100}}
/>
) : (
<EmptyState message={_(msg`No results found for ${query}`)} />
)}
</>
) : (
<Loader />
)
}
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 ? (
<List
data={results}
renderItem={({item}) => (
<View
style={[
a.border_b,
t.atoms.border_contrast_low,
a.px_lg,
a.py_lg,
]}>
<FeedCard.Default view={item} />
</View>
)}
keyExtractor={item => item.uri}
desktopFixedHeight
contentContainerStyle={{paddingBottom: 100}}
/>
) : (
<EmptyState message={_(msg`No results found for ${query}`)} />
)}
</>
) : (
<Loader />
)
}
SearchScreenFeedsResults = memo(SearchScreenFeedsResults)
+528
View File
@@ -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<NavigationProp>()
const route = useRoute()
const textInput = useRef<TextInput>(null)
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
const {currentAccount} = useSession()
const queryClient = useQueryClient()
// Query terms
const [searchText, setSearchText] = useState<string>(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 (
<Layout.Screen testID={testID}>
<View
ref={headerRef}
onLayout={evt => {
if (isWeb) setHeaderHeight(evt.nativeEvent.layout.height)
}}
style={[
a.relative,
a.z_10,
web({
position: 'sticky',
top: 0,
}),
]}>
<Layout.Center style={t.atoms.bg}>
{showHeader && (
<View
// HACK: shift up search input. we can't remove the top padding
// on the search input because it messes up the layout animation
// if we add it only when the header is hidden
style={{marginBottom: tokens.space.xs * -1}}>
<Layout.Header.Outer noBottomBorder>
{navButton === 'menu' ? (
<Layout.Header.MenuButton />
) : (
<Layout.Header.BackButton />
)}
<Layout.Header.Content align="left">
<Layout.Header.TitleText>
<Trans>Search</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
{showFilters ? (
<SearchLanguageDropdown
value={params.lang}
onChange={params.setLang}
/>
) : (
<Layout.Header.Slot />
)}
</Layout.Header.Outer>
</View>
)}
<View style={[a.px_md, a.pt_sm, a.pb_sm, a.overflow_hidden]}>
<View style={[a.gap_sm]}>
<View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs]}>
<View style={[a.flex_1]}>
<SearchInput
ref={textInput}
value={searchText}
onFocus={onSearchInputFocus}
onChangeText={onChangeText}
onClearText={onPressClearQuery}
onSubmitEditing={onSubmit}
placeholder={
inputPlaceholder ??
_(msg`Search for posts, users, or feeds`)
}
hitSlop={{...HITSLOP_20, top: 0}}
/>
</View>
{showAutocomplete && (
<Button
label={_(msg`Cancel search`)}
size="large"
variant="ghost"
color="secondary"
style={[a.px_sm]}
onPress={onPressCancelSearch}
hitSlop={HITSLOP_10}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
)}
</View>
{showFilters && !showHeader && (
<View
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.gap_sm,
]}>
<SearchLanguageDropdown
value={params.lang}
onChange={params.setLang}
/>
</View>
)}
</View>
</View>
</Layout.Center>
</View>
<View
style={{
display: showAutocomplete && !fixedParams ? 'flex' : 'none',
flex: 1,
}}>
{searchText.length > 0 ? (
<AutocompleteResults
isAutocompleteFetching={isAutocompleteFetching}
autocompleteData={autocompleteData}
searchText={searchText}
onSubmit={onSubmit}
onResultPress={onAutocompleteResultPress}
onProfileClick={handleProfileClick}
/>
) : (
<SearchHistory
searchHistory={termHistory}
selectedProfiles={accountHistoryProfiles?.profiles || []}
onItemClick={handleHistoryItemClick}
onProfileClick={handleProfileClick}
onRemoveItemClick={deleteSearchHistoryItem}
onRemoveProfileClick={deleteProfileHistoryItem}
/>
)}
</View>
<View
style={{
display: showAutocomplete ? 'none' : 'flex',
flex: 1,
}}>
<SearchScreenInner
query={query}
queryWithParams={queryWithParams}
headerHeight={headerHeight}
/>
</View>
</Layout.Screen>
)
}
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 ? (
<SearchResults
query={query}
queryWithParams={queryWithParams}
activeTab={activeTab}
headerHeight={headerHeight}
onPageSelected={onPageSelected}
/>
) : hasSession ? (
<Explore />
) : (
<Layout.Center>
<View style={a.flex_1}>
{gtTablet && (
<View
style={[
a.border_b,
t.atoms.border_contrast_low,
a.px_lg,
a.pt_sm,
a.pb_lg,
]}>
<Text style={[a.text_2xl, a.font_heavy]}>
<Trans>Search</Trans>
</Text>
</View>
)}
<View style={[a.align_center, a.justify_center, a.py_4xl, a.gap_lg]}>
<MagnifyingGlassIcon
strokeWidth={3}
size={60}
style={t.atoms.text_contrast_medium as StyleProp<ViewStyle>}
/>
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
<Trans>Find posts, users, and feeds on Bluesky</Trans>
</Text>
</View>
</View>
</Layout.Center>
)
}
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)
}
}
@@ -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 ? (
<Layout.Content>
<View style={[a.py_xl]}>
<ActivityIndicator />
</View>
</Layout.Content>
) : (
<Layout.Content
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag">
<SearchLinkCard
label={_(msg`Search for "${searchText}"`)}
onPress={native(onSubmit)}
to={
isNative
? undefined
: `/search?q=${encodeURIComponent(searchText)}`
}
style={{borderBottomWidth: 1}}
/>
{autocompleteData?.map(item => (
<SearchProfileCard
key={item.did}
profile={item}
moderation={moderateProfile(item, moderationOpts)}
onPress={() => {
onProfileClick(item)
onResultPress()
}}
/>
))}
<View style={{height: 200}} />
</Layout.Content>
)}
</>
)
}
AutocompleteResults = memo(AutocompleteResults)
export {AutocompleteResults}
@@ -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 (
<Layout.Content
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled">
<View style={[a.w_full, a.px_md]}>
{(searchHistory.length > 0 || selectedProfiles.length > 0) && (
<Text style={[a.text_md, a.font_bold, a.p_md]}>
<Trans>Recent Searches</Trans>
</Text>
)}
{selectedProfiles.length > 0 && (
<View
style={[
styles.selectedProfilesContainer,
!gtMobile && styles.selectedProfilesContainerMobile,
]}>
<RNGHScrollView
keyboardShouldPersistTaps="handled"
horizontal={true}
style={[
a.flex_row,
a.flex_nowrap,
{marginHorizontal: tokens.space._2xl * -1},
]}
contentContainerStyle={[a.px_2xl, a.border_0]}>
{selectedProfiles.slice(0, 5).map((profile, index) => (
<View
key={index}
style={[
styles.profileItem,
!gtMobile && styles.profileItemMobile,
]}>
<Link
href={makeProfileLink(profile)}
title={profile.handle}
asAnchor
anchorNoUnderline
onBeforePress={() => onProfileClick(profile)}
style={[a.align_center, a.w_full]}>
<UserAvatar
avatar={profile.avatar}
type={profile.associated?.labeler ? 'labeler' : 'user'}
size={60}
/>
<Text
emoji
style={[a.text_xs, a.text_center, styles.profileName]}
numberOfLines={1}>
{sanitizeDisplayName(
profile.displayName || profile.handle,
)}
</Text>
</Link>
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Remove profile`)}
accessibilityHint={_(
msg`Removes profile from search history`,
)}
onPress={() => onRemoveProfileClick(profile)}
hitSlop={createHitslop(6)}
style={styles.profileRemoveBtn}>
<XIcon size="xs" style={t.atoms.text_contrast_low} />
</Pressable>
</View>
))}
</RNGHScrollView>
</View>
)}
{searchHistory.length > 0 && (
<View style={[a.pl_md, a.pr_xs, a.mt_md]}>
{searchHistory.slice(0, 5).map((historyItem, index) => (
<View key={index} style={[a.flex_row, a.align_center, a.mt_xs]}>
<Pressable
accessibilityRole="button"
onPress={() => onItemClick(historyItem)}
hitSlop={HITSLOP_10}
style={[a.flex_1, a.py_md]}>
<Text style={[a.text_md]}>{historyItem}</Text>
</Pressable>
<Button
label={_(msg`Remove ${historyItem}`)}
onPress={() => onRemoveItemClick(historyItem)}
size="small"
variant="ghost"
color="secondary"
shape="round">
<ButtonIcon icon={XIcon} />
</Button>
</View>
))}
</View>
)}
</View>
</Layout.Content>
)
}
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',
},
})
@@ -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 (
<Menu.Root>
<Menu.Trigger
label={_(
msg`Filter search by language (currently: ${currentLanguageLabel})`,
)}>
{({props}) => (
<Button
{...props}
label={props.accessibilityLabel}
size="small"
color={platform({native: 'primary', default: 'secondary'})}
variant={platform({native: 'ghost', default: 'solid'})}
style={native([
a.py_sm,
a.px_sm,
{marginRight: tokens.space.sm * -1},
])}>
<ButtonIcon icon={EarthIcon} />
<ButtonText>{currentLanguageLabel}</ButtonText>
<ButtonIcon
icon={platform({
native: ChevronUpDownIcon,
default: ChevronDownIcon,
})}
/>
</Button>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.LabelText>
<Trans>Filter search by language</Trans>
</Menu.LabelText>
<Menu.Item label={_(msg`All languages`)} onPress={() => onChange('')}>
<Menu.ItemText>
<Trans>All languages</Trans>
</Menu.ItemText>
<Menu.ItemRadio selected={value === ''} />
</Menu.Item>
<Menu.Divider />
<Menu.Group>
{languages.map(lang => (
<Menu.Item
key={lang.key}
label={lang.label}
onPress={() => onChange(lang.value)}>
<Menu.ItemText>{lang.label}</Menu.ItemText>
<Menu.ItemRadio selected={value === lang.value} />
</Menu.Item>
))}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
)
}
+13
View File
@@ -0,0 +1,13 @@
import {
type NativeStackScreenProps,
type SearchTabNavigatorParams,
} from '#/lib/routes/types'
import {SearchScreenShell} from './Shell'
export function SearchScreen(
props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
) {
const queryParam = props.route?.params?.q ?? ''
return <SearchScreenShell queryParam={queryParam} testID="searchScreen" />
}
@@ -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}) => (
<TrendingTopic
@@ -3,7 +3,7 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {
useTrendingSettings,
@@ -43,7 +43,7 @@ function Inner() {
const trendingPrompt = Prompt.usePromptControl()
const onConfirmHide = React.useCallback(() => {
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) => <TrendingTopicSkeleton key={i} index={i} />)
.map((__, i) => <TrendingTopicSkeleton key={i} index={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}) => (
<TrendingTopic
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
export {SearchScreen} from '#/view/screens/Search/Search'