Search page (#1912)

* Desktop web work

* Mobile search

* Dedupe suggestions

* Clean up and reorg

* Cleanup

* Cleanup

* Use Pager

* Delete unused code

* Fix conflicts

* Remove search ui model

* Soft reset

* Fix scrollable results, remove observer

* Use correct ScrollView

* Clean up layout

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
zio/stable
Eric Bailey 2023-11-15 17:55:28 -06:00 committed by GitHub
parent d5ea31920c
commit 22b76423a0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 742 additions and 991 deletions

View File

@ -1,69 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {searchProfiles, searchPosts} from 'lib/api/search'
import {PostThreadModel} from '../content/post-thread'
import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
import {RootStoreModel} from '../root-store'
export class SearchUIModel {
isPostsLoading = false
isProfilesLoading = false
query: string = ''
posts: PostThreadModel[] = []
profiles: AppBskyActorDefs.ProfileView[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this)
}
async fetch(q: string) {
this.posts = []
this.profiles = []
this.query = q
if (!q.trim()) {
return
}
this.isPostsLoading = true
this.isProfilesLoading = true
const [postsSearch, profilesSearch] = await Promise.all([
searchPosts(q).catch(_e => []),
searchProfiles(q).catch(_e => []),
])
let posts: AppBskyFeedDefs.PostView[] = []
if (postsSearch?.length) {
do {
const res = await this.rootStore.agent.app.bsky.feed.getPosts({
uris: postsSearch
.splice(0, 25)
.map(p => `at://${p.user.did}/${p.tid}`),
})
posts = posts.concat(res.data.posts)
} while (postsSearch.length)
}
runInAction(() => {
this.posts = posts.map(post =>
PostThreadModel.fromPostView(this.rootStore, post),
)
this.isPostsLoading = false
})
let profiles: AppBskyActorDefs.ProfileView[] = []
if (profilesSearch?.length) {
do {
const res = await this.rootStore.agent.getProfiles({
actors: profilesSearch.splice(0, 25).map(p => p.did),
})
profiles = profiles.concat(res.data.profiles)
} while (profilesSearch.length)
}
this.rootStore.me.follows.hydrateMany(profiles)
runInAction(() => {
this.profiles = profiles
this.isProfilesLoading = false
})
}
}

View File

@ -36,7 +36,7 @@ export function useActorAutocompleteFn() {
const {data: follows} = useMyFollowsQuery()
return React.useCallback(
async ({query}: {query: string}) => {
async ({query, limit = 8}: {query: string; limit?: number}) => {
let res
if (query) {
try {
@ -47,7 +47,7 @@ export function useActorAutocompleteFn() {
queryFn: () =>
agent.searchActorsTypeahead({
term: query,
limit: 8,
limit,
}),
})
} catch (e) {

View File

@ -0,0 +1,32 @@
import {AppBskyFeedSearchPosts} from '@atproto/api'
import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query'
import {useSession} from '#/state/session'
const searchPostsQueryKey = ({query}: {query: string}) => [
'search-posts',
query,
]
export function useSearchPostsQuery({query}: {query: string}) {
const {agent} = useSession()
return useInfiniteQuery<
AppBskyFeedSearchPosts.OutputSchema,
Error,
InfiniteData<AppBskyFeedSearchPosts.OutputSchema>,
QueryKey,
string | undefined
>({
queryKey: searchPostsQueryKey({query}),
queryFn: async () => {
const res = await agent.app.bsky.feed.searchPosts({
q: query,
limit: 25,
})
return res.data
},
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
})
}

View File

@ -1,3 +1,4 @@
import React from 'react'
import {
AppBskyActorGetSuggestions,
AppBskyGraphGetSuggestedFollowsByActor,
@ -5,7 +6,7 @@ import {
} from '@atproto/api'
import {
useInfiniteQuery,
useMutation,
useQueryClient,
useQuery,
InfiniteData,
QueryKey,
@ -15,7 +16,7 @@ import {useSession} from '#/state/session'
import {useModerationOpts} from '#/state/queries/preferences'
const suggestedFollowsQueryKey = ['suggested-follows']
const suggestedFollowsByActorQuery = (did: string) => [
const suggestedFollowsByActorQueryKey = (did: string) => [
'suggested-follows-by-actor',
did,
]
@ -73,7 +74,7 @@ export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
const {agent} = useSession()
return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
queryKey: suggestedFollowsByActorQuery(did),
queryKey: suggestedFollowsByActorQueryKey(did),
queryFn: async () => {
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
actor: did,
@ -83,17 +84,26 @@ export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
})
}
// TODO: Delete and replace usages with the one above.
// TODO refactor onboarding to use above, but this is still used
export function useGetSuggestedFollowersByActor() {
const {agent} = useSession()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (actor: string) => {
return React.useCallback(
async (actor: string) => {
const res = await queryClient.fetchQuery({
staleTime: 60 * 1000,
queryKey: suggestedFollowsByActorQueryKey(actor),
queryFn: async () => {
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
actor: actor,
})
return res.data
},
})
return res
},
[agent, queryClient],
)
}

View File

@ -1,186 +0,0 @@
import React from 'react'
import {StyleSheet, TextInput, TouchableOpacity, View} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {Text} from 'view/com/util/text/Text'
import {MagnifyingGlassIcon} from 'lib/icons'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics/analytics'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {HITSLOP_10} from 'lib/constants'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSetDrawerOpen} from '#/state/shell'
interface Props {
isInputFocused: boolean
query: string
setIsInputFocused: (v: boolean) => void
onChangeQuery: (v: string) => void
onPressClearQuery: () => void
onPressCancelSearch: () => void
onSubmitQuery: () => void
showMenu?: boolean
}
export function HeaderWithInput({
isInputFocused,
query,
setIsInputFocused,
onChangeQuery,
onPressClearQuery,
onPressCancelSearch,
onSubmitQuery,
showMenu = true,
}: Props) {
const setDrawerOpen = useSetDrawerOpen()
const theme = useTheme()
const pal = usePalette('default')
const {_} = useLingui()
const {track} = useAnalytics()
const textInput = React.useRef<TextInput>(null)
const {isMobile} = useWebMediaQueries()
const onPressMenu = React.useCallback(() => {
track('ViewHeader:MenuButtonClicked')
setDrawerOpen(true)
}, [track, setDrawerOpen])
const onPressCancelSearchInner = React.useCallback(() => {
onPressCancelSearch()
textInput.current?.blur()
}, [onPressCancelSearch, textInput])
return (
<View
style={[
pal.view,
pal.border,
styles.header,
!isMobile && styles.headerDesktop,
]}>
{showMenu && isMobile ? (
<TouchableOpacity
testID="viewHeaderBackOrMenuBtn"
onPress={onPressMenu}
hitSlop={HITSLOP_10}
style={styles.headerMenuBtn}
accessibilityRole="button"
accessibilityLabel={_(msg`Menu`)}
accessibilityHint="Access navigation links and settings">
<FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} />
</TouchableOpacity>
) : null}
<View
style={[
{backgroundColor: pal.colors.backgroundLight},
styles.headerSearchContainer,
]}>
<MagnifyingGlassIcon
style={[pal.icon, styles.headerSearchIcon]}
size={21}
/>
<TextInput
testID="searchTextInput"
ref={textInput}
placeholder="Search"
placeholderTextColor={pal.colors.textLight}
selectTextOnFocus
returnKeyType="search"
value={query}
style={[pal.text, styles.headerSearchInput]}
keyboardAppearance={theme.colorScheme}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onChangeText={onChangeQuery}
onSubmitEditing={onSubmitQuery}
autoFocus={false}
accessibilityRole="search"
accessibilityLabel={_(msg`Search`)}
accessibilityHint=""
autoCorrect={false}
autoCapitalize="none"
/>
{query ? (
<TouchableOpacity
testID="searchTextInputClearBtn"
onPress={onPressClearQuery}
accessibilityRole="button"
accessibilityLabel={_(msg`Clear search query`)}
accessibilityHint="">
<FontAwesomeIcon
icon="xmark"
size={16}
style={pal.textLight as FontAwesomeIconStyle}
/>
</TouchableOpacity>
) : undefined}
</View>
{query || isInputFocused ? (
<View style={styles.headerCancelBtn}>
<TouchableOpacity
onPress={onPressCancelSearchInner}
accessibilityRole="button">
<Text style={pal.text}>
<Trans>Cancel</Trans>
</Text>
</TouchableOpacity>
</View>
) : undefined}
</View>
)
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 12,
paddingVertical: 4,
},
headerDesktop: {
borderWidth: 1,
borderTopWidth: 0,
paddingVertical: 10,
},
headerMenuBtn: {
width: 30,
height: 30,
borderRadius: 30,
marginRight: 6,
paddingBottom: 2,
alignItems: 'center',
justifyContent: 'center',
},
headerSearchContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
borderRadius: 30,
paddingHorizontal: 12,
paddingVertical: 8,
},
headerSearchIcon: {
marginRight: 6,
alignSelf: 'center',
},
headerSearchInput: {
flex: 1,
fontSize: 17,
},
headerCancelBtn: {
paddingLeft: 10,
},
searchPrompt: {
textAlign: 'center',
paddingTop: 10,
},
suggestions: {
marginBottom: 8,
},
})

View File

@ -1,150 +0,0 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {SearchUIModel} from 'state/models/ui/search'
import {CenteredView, ScrollView} from '../util/Views'
import {Pager, RenderTabBarFnProps} 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 {
PostFeedLoadingPlaceholder,
ProfileCardFeedLoadingPlaceholder,
} from 'view/com/util/LoadingPlaceholder'
import {Text} from 'view/com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {s} from 'lib/styles'
const SECTIONS = ['Posts', 'Users']
export const SearchResults = observer(function SearchResultsImpl({
model,
}: {
model: SearchUIModel
}) {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
const renderTabBar = React.useCallback(
(props: RenderTabBarFnProps) => {
return (
<CenteredView style={[pal.border, pal.view, styles.tabBar]}>
<TabBar
items={SECTIONS}
{...props}
key={SECTIONS.join()}
indicatorColor={pal.colors.link}
/>
</CenteredView>
)
},
[pal],
)
return (
<Pager renderTabBar={renderTabBar} tabBarPosition="top" initialPage={0}>
<View
style={{
paddingTop: isMobile ? 42 : 50,
}}>
<PostResults key="0" model={model} />
</View>
<View
style={{
paddingTop: isMobile ? 42 : 50,
}}>
<Profiles key="1" model={model} />
</View>
</Pager>
)
})
const PostResults = observer(function PostResultsImpl({
model,
}: {
model: SearchUIModel
}) {
const pal = usePalette('default')
if (model.isPostsLoading) {
return (
<CenteredView>
<PostFeedLoadingPlaceholder />
</CenteredView>
)
}
if (model.posts.length === 0) {
return (
<CenteredView>
<Text type="xl" style={[styles.empty, pal.text]}>
No posts found for "{model.query}"
</Text>
</CenteredView>
)
}
return (
<ScrollView style={[pal.view]}>
{model.posts.map(post => (
<Post key={post.resolvedUri} view={post} hideError />
))}
<View style={s.footerSpacer} />
<View style={s.footerSpacer} />
<View style={s.footerSpacer} />
</ScrollView>
)
})
const Profiles = observer(function ProfilesImpl({
model,
}: {
model: SearchUIModel
}) {
const pal = usePalette('default')
if (model.isProfilesLoading) {
return (
<CenteredView>
<ProfileCardFeedLoadingPlaceholder />
</CenteredView>
)
}
if (model.profiles.length === 0) {
return (
<CenteredView>
<Text type="xl" style={[styles.empty, pal.text]}>
No users found for "{model.query}"
</Text>
</CenteredView>
)
}
return (
<ScrollView style={pal.view}>
{model.profiles.map(item => (
<ProfileCardWithFollowBtn key={item.did} profile={item} />
))}
<View style={s.footerSpacer} />
<View style={s.footerSpacer} />
<View style={s.footerSpacer} />
</ScrollView>
)
})
const styles = StyleSheet.create({
tabBar: {
borderBottomWidth: 1,
position: 'absolute',
zIndex: 1,
left: 0,
right: 0,
top: 0,
flexDirection: 'column',
alignItems: 'center',
},
empty: {
paddingHorizontal: 14,
paddingVertical: 16,
},
})

View File

@ -1,265 +0,0 @@
import React, {forwardRef, ForwardedRef} from 'react'
import {RefreshControl, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {AppBskyActorDefs} from '@atproto/api'
import {FlatList} from '../util/Views'
import {FoafsModel} from 'state/models/discovery/foafs'
import {
SuggestedActorsModel,
SuggestedActor,
} from 'state/models/discovery/suggested-actors'
import {Text} from '../util/text/Text'
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {ProfileCardLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
interface Heading {
_reactKey: string
type: 'heading'
title: string
}
interface RefWrapper {
_reactKey: string
type: 'ref'
ref: RefWithInfoAndFollowers
}
interface SuggestWrapper {
_reactKey: string
type: 'suggested'
suggested: SuggestedActor
}
interface ProfileView {
_reactKey: string
type: 'profile-view'
view: AppBskyActorDefs.ProfileViewBasic
}
interface LoadingPlaceholder {
_reactKey: string
type: 'loading-placeholder'
}
type Item =
| Heading
| RefWrapper
| SuggestWrapper
| ProfileView
| LoadingPlaceholder
// FIXME(dan): Figure out why the false positives
/* eslint-disable react/prop-types */
export const Suggestions = observer(
forwardRef(function SuggestionsImpl(
{
foafs,
suggestedActors,
}: {
foafs: FoafsModel
suggestedActors: SuggestedActorsModel
},
flatListRef: ForwardedRef<FlatList>,
) {
const pal = usePalette('default')
const [refreshing, setRefreshing] = React.useState(false)
const data = React.useMemo(() => {
let items: Item[] = []
if (suggestedActors.hasContent) {
items = items
.concat([
{
_reactKey: '__suggested_heading__',
type: 'heading',
title: 'Suggested Follows',
},
])
.concat(
suggestedActors.suggestions.map(suggested => ({
_reactKey: `suggested-${suggested.did}`,
type: 'suggested',
suggested,
})),
)
} else if (suggestedActors.isLoading) {
items = items.concat([
{
_reactKey: '__suggested_heading__',
type: 'heading',
title: 'Suggested Follows',
},
{_reactKey: '__suggested_loading__', type: 'loading-placeholder'},
])
}
if (foafs.isLoading) {
items = items.concat([
{
_reactKey: '__popular_heading__',
type: 'heading',
title: 'In Your Network',
},
{_reactKey: '__foafs_loading__', type: 'loading-placeholder'},
])
} else {
if (foafs.popular.length > 0) {
items = items
.concat([
{
_reactKey: '__popular_heading__',
type: 'heading',
title: 'In Your Network',
},
])
.concat(
foafs.popular.map(ref => ({
_reactKey: `popular-${ref.did}`,
type: 'ref',
ref,
})),
)
}
for (const source of foafs.sources) {
const item = foafs.foafs.get(source)
if (!item || item.follows.length === 0) {
continue
}
items = items
.concat([
{
_reactKey: `__${item.did}_heading__`,
type: 'heading',
title: `Followed by ${sanitizeDisplayName(
item.displayName || sanitizeHandle(item.handle),
)}`,
},
])
.concat(
item.follows.slice(0, 10).map(view => ({
_reactKey: `${item.did}-${view.did}`,
type: 'profile-view',
view,
})),
)
}
}
return items
}, [
foafs.isLoading,
foafs.popular,
suggestedActors.isLoading,
suggestedActors.hasContent,
suggestedActors.suggestions,
foafs.sources,
foafs.foafs,
])
const onRefresh = React.useCallback(async () => {
setRefreshing(true)
try {
await foafs.fetch()
} finally {
setRefreshing(false)
}
}, [foafs, setRefreshing])
const renderItem = React.useCallback(
({item}: {item: Item}) => {
if (item.type === 'heading') {
return (
<Text type="title" style={[styles.heading, pal.text]}>
{item.title}
</Text>
)
}
if (item.type === 'ref') {
return (
<View style={[styles.card, pal.view, pal.border]}>
<ProfileCardWithFollowBtn
key={item.ref.did}
profile={item.ref}
noBg
noBorder
followers={
item.ref.followers
? (item.ref.followers as AppBskyActorDefs.ProfileView[])
: undefined
}
/>
</View>
)
}
if (item.type === 'profile-view') {
return (
<View style={[styles.card, pal.view, pal.border]}>
<ProfileCardWithFollowBtn
key={item.view.did}
profile={item.view}
noBg
noBorder
/>
</View>
)
}
if (item.type === 'suggested') {
return (
<View style={[styles.card, pal.view, pal.border]}>
<ProfileCardWithFollowBtn
key={item.suggested.did}
profile={item.suggested}
noBg
noBorder
/>
</View>
)
}
if (item.type === 'loading-placeholder') {
return (
<View>
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
<ProfileCardLoadingPlaceholder />
</View>
)
}
return null
},
[pal],
)
return (
<FlatList
ref={flatListRef}
data={data}
keyExtractor={item => item._reactKey}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
/>
}
renderItem={renderItem}
initialNumToRender={15}
contentContainerStyle={s.contentContainer}
/>
)
}),
)
const styles = StyleSheet.create({
heading: {
fontWeight: 'bold',
paddingHorizontal: 12,
paddingBottom: 8,
paddingTop: 16,
},
card: {
borderTopWidth: 1,
},
})

View File

@ -1 +0,0 @@
export * from './SearchMobile'

View File

@ -1,76 +0,0 @@
import React from 'react'
import {View, StyleSheet} from 'react-native'
import {SearchUIModel} from 'state/models/ui/search'
import {FoafsModel} from 'state/models/discovery/foafs'
import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {Suggestions} from 'view/com/search/Suggestions'
import {SearchResults} from 'view/com/search/SearchResults'
import {observer} from 'mobx-react-lite'
import {
NativeStackScreenProps,
SearchTabNavigatorParams,
} from 'lib/routes/types'
import {useStores} from 'state/index'
import {CenteredView} from 'view/com/util/Views'
import * as Mobile from './SearchMobile'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
export const SearchScreen = withAuthRequired(
observer(function SearchScreenImpl({navigation, route}: Props) {
const store = useStores()
const params = route.params || {}
const foafs = React.useMemo<FoafsModel>(
() => new FoafsModel(store),
[store],
)
const suggestedActors = React.useMemo<SuggestedActorsModel>(
() => new SuggestedActorsModel(store),
[store],
)
const searchUIModel = React.useMemo<SearchUIModel | undefined>(
() => (params.q ? new SearchUIModel(store) : undefined),
[params.q, store],
)
React.useEffect(() => {
if (params.q && searchUIModel) {
searchUIModel.fetch(params.q)
}
if (!foafs.hasData) {
foafs.fetch()
}
if (!suggestedActors.hasLoaded) {
suggestedActors.loadMore(true)
}
}, [foafs, suggestedActors, searchUIModel, params.q])
const {isDesktop} = useWebMediaQueries()
if (searchUIModel) {
return (
<View style={styles.scrollContainer}>
<SearchResults model={searchUIModel} />
</View>
)
}
if (!isDesktop) {
return (
<CenteredView style={styles.scrollContainer}>
<Mobile.SearchScreen navigation={navigation} route={route} />
</CenteredView>
)
}
return <Suggestions foafs={foafs} suggestedActors={suggestedActors} />
}),
)
const styles = StyleSheet.create({
scrollContainer: {
height: '100%',
overflowY: 'auto',
},
})

View File

@ -0,0 +1,639 @@
import React from 'react'
import {
View,
StyleSheet,
ActivityIndicator,
RefreshControl,
TextInput,
Pressable,
} from 'react-native'
import {FlatList, ScrollView, CenteredView} from '#/view/com/util/Views'
import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {useFocusEffect} from '@react-navigation/native'
import {logger} from '#/logger'
import {
NativeStackScreenProps,
SearchTabNavigatorParams,
} from 'lib/routes/types'
import {Text} from '#/view/com/util/text/Text'
import {NotificationFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {Post} from '#/view/com/post/Post'
import {Pager} from '#/view/com/pager/Pager'
import {TabBar} from '#/view/com/pager/TabBar'
import {HITSLOP_10} from '#/lib/constants'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {usePalette} from '#/lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
import {useSession} from '#/state/session'
import {useMyFollowsQuery} from '#/state/queries/my-follows'
import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows'
import {useSearchPostsQuery} from '#/state/queries/search-posts'
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {useSetDrawerOpen} from '#/state/shell'
import {useAnalytics} from '#/lib/analytics/analytics'
import {MagnifyingGlassIcon} from '#/lib/icons'
import {useModerationOpts} from '#/state/queries/preferences'
import {SearchResultCard} from '#/view/shell/desktop/Search'
import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
import {useStores} from '#/state'
import {isWeb} from '#/platform/detection'
function Loader() {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
return (
<CenteredView
style={[
// @ts-ignore web only -prf
{
padding: 18,
height: isWeb ? '100vh' : undefined,
},
pal.border,
]}
sideBorders={!isMobile}>
<ActivityIndicator />
</CenteredView>
)
}
// TODO refactor how to translate?
function EmptyState({message, error}: {message: string; error?: string}) {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
return (
<CenteredView
sideBorders={!isMobile}
style={[
pal.border,
// @ts-ignore web only -prf
{
padding: 18,
height: isWeb ? '100vh' : undefined,
},
]}>
<View style={[pal.viewLight, {padding: 18, borderRadius: 8}]}>
<Text style={[pal.text]}>
<Trans>{message}</Trans>
</Text>
{error && (
<>
<View
style={[
{
marginVertical: 12,
height: 1,
width: '100%',
backgroundColor: pal.text.color,
opacity: 0.2,
},
]}
/>
<Text style={[pal.textLight]}>
<Trans>Error:</Trans> {error}
</Text>
</>
)}
</View>
</CenteredView>
)
}
function SearchScreenSuggestedFollows() {
const pal = usePalette('default')
const {currentAccount} = useSession()
const [dataUpdatedAt, setDataUpdatedAt] = React.useState(0)
const [suggestions, setSuggestions] = React.useState<
AppBskyActorDefs.ProfileViewBasic[]
>([])
const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor()
React.useEffect(() => {
async function getSuggestions() {
// TODO not quite right, doesn't fetch your follows
const friends = await getSuggestedFollowsByActor(
currentAccount!.did,
).then(friendsRes => friendsRes.suggestions)
if (!friends) return // :(
const friendsOfFriends = (
await Promise.all(
friends
.slice(0, 4)
.map(friend =>
getSuggestedFollowsByActor(friend.did).then(
foafsRes => foafsRes.suggestions,
),
),
)
).flat()
setSuggestions(
// dedupe
friendsOfFriends.filter(f => !friends.find(f2 => f.did === f2.did)),
)
setDataUpdatedAt(Date.now())
}
try {
getSuggestions()
} catch (e) {
logger.error(`SearchScreenSuggestedFollows: failed to get suggestions`, {
error: e,
})
}
}, [
currentAccount,
setSuggestions,
setDataUpdatedAt,
getSuggestedFollowsByActor,
])
return suggestions.length ? (
<FlatList
data={suggestions}
renderItem={({item}) => (
<ProfileCardWithFollowBtn
profile={item}
noBg
dataUpdatedAt={dataUpdatedAt}
/>
)}
keyExtractor={item => item.did}
// @ts-ignore web only -prf
desktopFixedHeight
contentContainerStyle={{paddingBottom: 1200}}
/>
) : (
<CenteredView
style={[pal.border, {borderLeftWidth: 1, borderRightWidth: 1}]}>
<NotificationFeedLoadingPlaceholder />
</CenteredView>
)
}
type SearchResultSlice =
| {
type: 'post'
key: string
post: AppBskyFeedDefs.PostView
}
| {
type: 'loadingMore'
key: string
}
function SearchScreenPostResults({query}: {query: string}) {
const pal = usePalette('default')
const [isPTR, setIsPTR] = React.useState(false)
const {
isFetched,
data: results,
isFetching,
error,
refetch,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
dataUpdatedAt,
} = useSearchPostsQuery({query})
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 items: SearchResultSlice[] = []
for (const post of posts) {
items.push({
type: 'post',
key: post.uri,
post,
})
}
if (isFetchingNextPage) {
items.push({
type: 'loadingMore',
key: 'loadingMore',
})
}
return items
}, [posts, isFetchingNextPage])
return error ? (
<EmptyState
message="We're sorry, but your search could not be completed. Please try again in a few minutes."
error={error.toString()}
/>
) : (
<>
{isFetched ? (
<>
{posts.length ? (
<FlatList
data={items}
renderItem={({item}) => {
if (item.type === 'post') {
return <Post post={item.post} dataUpdatedAt={dataUpdatedAt} />
} else {
return <Loader />
}
}}
keyExtractor={item => item.key}
refreshControl={
<RefreshControl
refreshing={isPTR}
onRefresh={onPullToRefresh}
tintColor={pal.colors.text}
titleColor={pal.colors.text}
/>
}
onEndReached={onEndReached}
// @ts-ignore web only -prf
desktopFixedHeight
contentContainerStyle={{paddingBottom: 100}}
/>
) : (
<EmptyState message={`No results found for ${query}`} />
)}
</>
) : (
<Loader />
)}
</>
)
}
function SearchScreenUserResults({query}: {query: string}) {
const [isFetched, setIsFetched] = React.useState(false)
const [dataUpdatedAt, setDataUpdatedAt] = React.useState(0)
const [results, setResults] = React.useState<
AppBskyActorDefs.ProfileViewBasic[]
>([])
const search = useActorAutocompleteFn()
// fuzzy search relies on followers
const {isFetched: isFollowsFetched} = useMyFollowsQuery()
React.useEffect(() => {
async function getResults() {
const results = await search({query, limit: 30})
if (results) {
setDataUpdatedAt(Date.now())
setResults(results)
setIsFetched(true)
}
}
if (query && isFollowsFetched) {
getResults()
} else {
setResults([])
setIsFetched(false)
}
}, [query, isFollowsFetched, setDataUpdatedAt, search])
return isFetched ? (
<>
{results.length ? (
<FlatList
data={results}
renderItem={({item}) => (
<ProfileCardWithFollowBtn
profile={item}
noBg
dataUpdatedAt={dataUpdatedAt}
/>
)}
keyExtractor={item => item.did}
// @ts-ignore web only -prf
desktopFixedHeight
contentContainerStyle={{paddingBottom: 100}}
/>
) : (
<EmptyState message={`No results found for ${query}`} />
)}
</>
) : (
<Loader />
)
}
const SECTIONS = ['Posts', 'Users']
export function SearchScreenInner({query}: {query?: string}) {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
const onPageSelected = React.useCallback(
(index: number) => {
setMinimalShellMode(false)
setDrawerSwipeDisabled(index > 0)
},
[setDrawerSwipeDisabled, setMinimalShellMode],
)
return query ? (
<Pager
tabBarPosition="top"
onPageSelected={onPageSelected}
renderTabBar={props => (
<CenteredView sideBorders style={pal.border}>
<TabBar items={SECTIONS} {...props} />
</CenteredView>
)}
initialPage={0}>
<View>
<SearchScreenPostResults query={query} />
</View>
<View>
<SearchScreenUserResults query={query} />
</View>
</Pager>
) : (
<View>
<CenteredView sideBorders style={pal.border}>
<Text
type="title"
style={[
pal.text,
pal.border,
{
display: 'flex',
paddingVertical: 12,
paddingHorizontal: 18,
fontWeight: 'bold',
},
]}>
<Trans>Suggested Follows</Trans>
</Text>
</CenteredView>
<SearchScreenSuggestedFollows />
</View>
)
}
export function SearchScreenDesktop(
props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
) {
const {isDesktop} = useWebMediaQueries()
return isDesktop ? (
<SearchScreenInner query={props.route.params?.q} />
) : (
<SearchScreenMobile {...props} />
)
}
export function SearchScreenMobile(
_props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
) {
const theme = useTheme()
const textInput = React.useRef<TextInput>(null)
const {_} = useLingui()
const pal = usePalette('default')
const {track} = useAnalytics()
const setDrawerOpen = useSetDrawerOpen()
const moderationOpts = useModerationOpts()
const search = useActorAutocompleteFn()
const setMinimalShellMode = useSetMinimalShellMode()
const store = useStores()
const {isTablet} = useWebMediaQueries()
const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>(
undefined,
)
const [isFetching, setIsFetching] = React.useState<boolean>(false)
const [query, setQuery] = React.useState<string>('')
const [searchResults, setSearchResults] = React.useState<
AppBskyActorDefs.ProfileViewBasic[]
>([])
const [inputIsFocused, setInputIsFocused] = React.useState(false)
const [showAutocompleteResults, setShowAutocompleteResults] =
React.useState(false)
const onPressMenu = React.useCallback(() => {
track('ViewHeader:MenuButtonClicked')
setDrawerOpen(true)
}, [track, setDrawerOpen])
const onPressCancelSearch = React.useCallback(() => {
textInput.current?.blur()
setQuery('')
setShowAutocompleteResults(false)
if (searchDebounceTimeout.current)
clearTimeout(searchDebounceTimeout.current)
}, [textInput])
const onPressClearQuery = React.useCallback(() => {
setQuery('')
setShowAutocompleteResults(false)
}, [setQuery])
const onChangeText = React.useCallback(
async (text: string) => {
setQuery(text)
if (text.length > 0) {
setIsFetching(true)
setShowAutocompleteResults(true)
if (searchDebounceTimeout.current)
clearTimeout(searchDebounceTimeout.current)
searchDebounceTimeout.current = setTimeout(async () => {
const results = await search({query: text, limit: 30})
if (results) {
setSearchResults(results)
setIsFetching(false)
}
}, 300)
} else {
if (searchDebounceTimeout.current)
clearTimeout(searchDebounceTimeout.current)
setSearchResults([])
setIsFetching(false)
setShowAutocompleteResults(false)
}
},
[setQuery, search, setSearchResults],
)
const onSubmit = React.useCallback(() => {
setShowAutocompleteResults(false)
}, [setShowAutocompleteResults])
const onSoftReset = React.useCallback(() => {
onPressCancelSearch()
}, [onPressCancelSearch])
useFocusEffect(
React.useCallback(() => {
const softResetSub = store.onScreenSoftReset(onSoftReset)
setMinimalShellMode(false)
return () => {
softResetSub.remove()
}
}, [store, onSoftReset, setMinimalShellMode]),
)
return (
<View style={{flex: 1}}>
<CenteredView style={[styles.header, pal.border]} sideBorders={isTablet}>
<Pressable
testID="viewHeaderBackOrMenuBtn"
onPress={onPressMenu}
hitSlop={HITSLOP_10}
style={styles.headerMenuBtn}
accessibilityRole="button"
accessibilityLabel={_(msg`Menu`)}
accessibilityHint="Access navigation links and settings">
<FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} />
</Pressable>
<View
style={[
{backgroundColor: pal.colors.backgroundLight},
styles.headerSearchContainer,
]}>
<MagnifyingGlassIcon
style={[pal.icon, styles.headerSearchIcon]}
size={21}
/>
<TextInput
testID="searchTextInput"
ref={textInput}
placeholder="Search"
placeholderTextColor={pal.colors.textLight}
selectTextOnFocus
returnKeyType="search"
value={query}
style={[pal.text, styles.headerSearchInput]}
keyboardAppearance={theme.colorScheme}
onFocus={() => setInputIsFocused(true)}
onBlur={() => setInputIsFocused(false)}
onChangeText={onChangeText}
onSubmitEditing={onSubmit}
autoFocus={false}
accessibilityRole="search"
accessibilityLabel={_(msg`Search`)}
accessibilityHint=""
autoCorrect={false}
autoCapitalize="none"
/>
{query ? (
<Pressable
testID="searchTextInputClearBtn"
onPress={onPressClearQuery}
accessibilityRole="button"
accessibilityLabel={_(msg`Clear search query`)}
accessibilityHint="">
<FontAwesomeIcon
icon="xmark"
size={16}
style={pal.textLight as FontAwesomeIconStyle}
/>
</Pressable>
) : undefined}
</View>
{query || inputIsFocused ? (
<View style={styles.headerCancelBtn}>
<Pressable onPress={onPressCancelSearch} accessibilityRole="button">
<Text style={[pal.text]}>
<Trans>Cancel</Trans>
</Text>
</Pressable>
</View>
) : undefined}
</CenteredView>
{showAutocompleteResults && moderationOpts ? (
<>
{isFetching ? (
<Loader />
) : (
<ScrollView style={{height: '100%'}}>
{searchResults.length ? (
searchResults.map((item, i) => (
<SearchResultCard
key={item.did}
profile={item}
moderation={moderateProfile(item, moderationOpts)}
style={i === 0 ? {borderTopWidth: 0} : {}}
/>
))
) : (
<EmptyState message={`No results found for ${query}`} />
)}
<View style={{height: 200}} />
</ScrollView>
)}
</>
) : (
<SearchScreenInner query={query} />
)}
</View>
)
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 4,
},
headerMenuBtn: {
width: 30,
height: 30,
borderRadius: 30,
marginRight: 6,
paddingBottom: 2,
alignItems: 'center',
justifyContent: 'center',
},
headerSearchContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
borderRadius: 30,
paddingHorizontal: 12,
paddingVertical: 8,
},
headerSearchIcon: {
marginRight: 6,
alignSelf: 'center',
},
headerSearchInput: {
flex: 1,
fontSize: 17,
},
headerCancelBtn: {
paddingLeft: 10,
},
})

View File

@ -0,0 +1 @@
export {SearchScreenMobile as SearchScreen} from '#/view/screens/Search/Search'

View File

@ -0,0 +1 @@
export {SearchScreenDesktop as SearchScreen} from '#/view/screens/Search/Search'

View File

@ -1,205 +0,0 @@
import React, {useCallback} from 'react'
import {
StyleSheet,
TouchableWithoutFeedback,
Keyboard,
View,
} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {FlatList, ScrollView} from 'view/com/util/Views'
import {
NativeStackScreenProps,
SearchTabNavigatorParams,
} from 'lib/routes/types'
import {observer} from 'mobx-react-lite'
import {Text} from 'view/com/util/text/Text'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {useStores} from 'state/index'
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
import {SearchUIModel} from 'state/models/ui/search'
import {FoafsModel} from 'state/models/discovery/foafs'
import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors'
import {HeaderWithInput} from 'view/com/search/HeaderWithInput'
import {Suggestions} from 'view/com/search/Suggestions'
import {SearchResults} from 'view/com/search/SearchResults'
import {s} from 'lib/styles'
import {ProfileCard} from 'view/com/profile/ProfileCard'
import {usePalette} from 'lib/hooks/usePalette'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {isAndroid, isIOS} from 'platform/detection'
import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>
export const SearchScreen = withAuthRequired(
observer<Props>(function SearchScreenImpl({}: Props) {
const pal = usePalette('default')
const store = useStores()
const setMinimalShellMode = useSetMinimalShellMode()
const setIsDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
const scrollViewRef = React.useRef<ScrollView>(null)
const flatListRef = React.useRef<FlatList>(null)
const [onMainScroll] = useOnMainScroll()
const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
const [query, setQuery] = React.useState<string>('')
const autocompleteView = React.useMemo<UserAutocompleteModel>(
() => new UserAutocompleteModel(store),
[store],
)
const foafs = React.useMemo<FoafsModel>(
() => new FoafsModel(store),
[store],
)
const suggestedActors = React.useMemo<SuggestedActorsModel>(
() => new SuggestedActorsModel(store),
[store],
)
const [searchUIModel, setSearchUIModel] = React.useState<
SearchUIModel | undefined
>()
const onChangeQuery = React.useCallback(
(text: string) => {
setQuery(text)
if (text.length > 0) {
autocompleteView.setActive(true)
autocompleteView.setPrefix(text)
} else {
autocompleteView.setActive(false)
}
},
[setQuery, autocompleteView],
)
const onPressClearQuery = React.useCallback(() => {
setQuery('')
}, [setQuery])
const onPressCancelSearch = React.useCallback(() => {
setQuery('')
autocompleteView.setActive(false)
setSearchUIModel(undefined)
setIsDrawerSwipeDisabled(false)
}, [setQuery, autocompleteView, setIsDrawerSwipeDisabled])
const onSubmitQuery = React.useCallback(() => {
if (query.length === 0) {
return
}
const model = new SearchUIModel(store)
model.fetch(query)
setSearchUIModel(model)
setIsDrawerSwipeDisabled(true)
}, [query, setSearchUIModel, store, setIsDrawerSwipeDisabled])
const onSoftReset = React.useCallback(() => {
scrollViewRef.current?.scrollTo({x: 0, y: 0})
flatListRef.current?.scrollToOffset({offset: 0})
onPressCancelSearch()
}, [scrollViewRef, flatListRef, onPressCancelSearch])
useFocusEffect(
React.useCallback(() => {
const softResetSub = store.onScreenSoftReset(onSoftReset)
const cleanup = () => {
softResetSub.remove()
}
setMinimalShellMode(false)
autocompleteView.setup()
if (!foafs.hasData) {
foafs.fetch()
}
if (!suggestedActors.hasLoaded) {
suggestedActors.loadMore(true)
}
return cleanup
}, [
store,
autocompleteView,
foafs,
suggestedActors,
onSoftReset,
setMinimalShellMode,
]),
)
const onPress = useCallback(() => {
if (isIOS || isAndroid) {
Keyboard.dismiss()
}
}, [])
const scrollHandler = useAnimatedScrollHandler(onMainScroll)
return (
<TouchableWithoutFeedback onPress={onPress} accessible={false}>
<View style={[pal.view, styles.container]}>
<HeaderWithInput
isInputFocused={isInputFocused}
query={query}
setIsInputFocused={setIsInputFocused}
onChangeQuery={onChangeQuery}
onPressClearQuery={onPressClearQuery}
onPressCancelSearch={onPressCancelSearch}
onSubmitQuery={onSubmitQuery}
/>
{searchUIModel ? (
<SearchResults model={searchUIModel} />
) : !isInputFocused && !query ? (
<Suggestions
ref={flatListRef}
foafs={foafs}
suggestedActors={suggestedActors}
/>
) : (
<ScrollView
ref={scrollViewRef}
testID="searchScrollView"
style={pal.view}
onScroll={scrollHandler}
scrollEventThrottle={1}>
{query && autocompleteView.suggestions.length ? (
<>
{autocompleteView.suggestions.map((suggestion, index) => (
<ProfileCard
key={suggestion.did}
testID={`searchAutoCompleteResult-${suggestion.handle}`}
profile={suggestion}
noBorder={index === 0}
/>
))}
</>
) : query && !autocompleteView.suggestions.length ? (
<View>
<Text style={[pal.textLight, styles.searchPrompt]}>
No results found for {autocompleteView.prefix}
</Text>
</View>
) : isInputFocused ? (
<View>
<Text style={[pal.textLight, styles.searchPrompt]}>
Search for users and posts on the network
</Text>
</View>
) : null}
<View style={s.footerSpacer} />
</ScrollView>
)}
</View>
</TouchableWithoutFeedback>
)
}),
)
const styles = StyleSheet.create({
container: {
flex: 1,
},
searchPrompt: {
textAlign: 'center',
paddingTop: 10,
},
})

View File

@ -5,6 +5,7 @@ import {
View,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
} from 'react-native'
import {useNavigation, StackActions} from '@react-navigation/native'
import {
@ -12,7 +13,6 @@ import {
moderateProfile,
ProfileModeration,
} from '@atproto/api'
import {observer} from 'mobx-react-lite'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@ -84,14 +84,15 @@ export function SearchResultCard({
)
}
export const DesktopSearch = observer(function DesktopSearch() {
export function DesktopSearch() {
const {_} = useLingui()
const pal = usePalette('default')
const navigation = useNavigation<NavigationProp>()
const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>(
undefined,
)
const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
const [isActive, setIsActive] = React.useState<boolean>(false)
const [isFetching, setIsFetching] = React.useState<boolean>(false)
const [query, setQuery] = React.useState<string>('')
const [searchResults, setSearchResults] = React.useState<
AppBskyActorDefs.ProfileViewBasic[]
@ -104,7 +105,10 @@ export const DesktopSearch = observer(function DesktopSearch() {
async (text: string) => {
setQuery(text)
if (text.length > 0 && isInputFocused) {
if (text.length > 0) {
setIsFetching(true)
setIsActive(true)
if (searchDebounceTimeout.current)
clearTimeout(searchDebounceTimeout.current)
@ -113,24 +117,34 @@ export const DesktopSearch = observer(function DesktopSearch() {
if (results) {
setSearchResults(results)
setIsFetching(false)
}
}, 300)
} else {
if (searchDebounceTimeout.current)
clearTimeout(searchDebounceTimeout.current)
setSearchResults([])
setIsFetching(false)
setIsActive(false)
}
},
[setQuery, isInputFocused, search, setSearchResults],
[setQuery, search, setSearchResults],
)
const onPressCancelSearch = React.useCallback(() => {
onChangeText('')
}, [onChangeText])
setQuery('')
setIsActive(false)
if (searchDebounceTimeout.current)
clearTimeout(searchDebounceTimeout.current)
}, [setQuery])
const onSubmit = React.useCallback(() => {
setIsActive(false)
if (!query.length) return
setSearchResults([])
if (searchDebounceTimeout.current)
clearTimeout(searchDebounceTimeout.current)
navigation.dispatch(StackActions.push('Search', {q: query}))
}, [query, navigation])
}, [query, navigation, setSearchResults])
return (
<View style={[styles.container, pal.view]}>
@ -149,8 +163,6 @@ export const DesktopSearch = observer(function DesktopSearch() {
returnKeyType="search"
value={query}
style={[pal.textLight, styles.input]}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onChangeText={onChangeText}
onSubmitEditing={onSubmit}
accessibilityRole="search"
@ -174,9 +186,15 @@ export const DesktopSearch = observer(function DesktopSearch() {
</View>
</View>
{query !== '' && (
{query !== '' && isActive && moderationOpts && (
<View style={[pal.view, pal.borderDark, styles.resultsContainer]}>
{searchResults.length && moderationOpts ? (
{isFetching ? (
<View style={{padding: 8}}>
<ActivityIndicator />
</View>
) : (
<>
{searchResults.length ? (
searchResults.map((item, i) => (
<SearchResultCard
key={item.did}
@ -192,11 +210,13 @@ export const DesktopSearch = observer(function DesktopSearch() {
</Text>
</View>
)}
</>
)}
</View>
)}
</View>
)
})
}
const styles = StyleSheet.create({
container: {