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>
This commit is contained in:
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,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,
},
})