migrate to #/screens
This commit is contained in:
committed by
Eric Bailey
parent
0874d9602a
commit
a0de9f929f
+13
-13
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
}
|
||||
+5
-3
@@ -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
|
||||
+4
-4
@@ -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 +0,0 @@
|
||||
export {SearchScreen} from '#/view/screens/Search/Search'
|
||||
Reference in New Issue
Block a user