Rework the search UI and add (#174)
* Add search tab and move icon to footer * Remove subtitles from view header * Remove unused code * Clean up UI of search screen * Search: give better user feedback to UI state and add a cancel button * Add WhoToFollow section to search * Add a temporary SuggestedPosts solution using the patented 'bsky team algo' * Trigger reload of suggested content in search on open * Wait five min between reloading discovery content * Reduce weight of solid search icon in footer * Fix lint * Fix tests
This commit is contained in:
@@ -36,7 +36,6 @@ export const SuggestedFollows = observer(
|
||||
const store = useStores()
|
||||
const [follows, setFollows] = useState<Record<string, string>>({})
|
||||
|
||||
// Using default import (React.use...) instead of named import (use...) to be able to mock store's data in jest environment
|
||||
const view = React.useMemo<SuggestedActorsViewModel>(
|
||||
() => new SuggestedActorsViewModel(store),
|
||||
[store],
|
||||
@@ -235,6 +234,7 @@ const styles = StyleSheet.create({
|
||||
|
||||
actor: {
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
actorMeta: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react'
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {useStores} from '../../../state'
|
||||
import {SuggestedPostsView} from '../../../state/models/suggested-posts-view'
|
||||
import {s} from '../../lib/styles'
|
||||
import {FeedItem as Post} from '../posts/FeedItem'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
|
||||
export const SuggestedPosts = observer(() => {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const suggestedPostsView = React.useMemo<SuggestedPostsView>(
|
||||
() => new SuggestedPostsView(store),
|
||||
[store],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!suggestedPostsView.hasLoaded) {
|
||||
suggestedPostsView.setup()
|
||||
}
|
||||
}, [store, suggestedPostsView])
|
||||
|
||||
return (
|
||||
<>
|
||||
{(suggestedPostsView.hasContent || suggestedPostsView.isLoading) && (
|
||||
<Text type="lg-heavy" style={[styles.heading, pal.text]}>
|
||||
Recently, on Bluesky...
|
||||
</Text>
|
||||
)}
|
||||
{suggestedPostsView.hasContent && (
|
||||
<>
|
||||
<View style={[pal.border, styles.bottomBorder]}>
|
||||
{suggestedPostsView.posts.map(item => (
|
||||
<Post item={item} key={item._reactKey} />
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
{suggestedPostsView.isLoading && (
|
||||
<View style={s.mt10}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
|
||||
bottomBorder: {
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
|
||||
loadMore: {
|
||||
paddingLeft: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import _omit from 'lodash.omit'
|
||||
import {useStores} from '../../../state'
|
||||
import {
|
||||
SuggestedActorsViewModel,
|
||||
SuggestedActor,
|
||||
} from '../../../state/models/suggested-actors-view'
|
||||
import * as apilib from '../../../state/lib/api'
|
||||
import {s, gradients} from '../../lib/styles'
|
||||
import {ProfileCard} from '../profile/ProfileCard'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
|
||||
export const WhoToFollow = observer(() => {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const [follows, setFollows] = React.useState<Record<string, string>>({})
|
||||
const suggestedActorsView = React.useMemo<SuggestedActorsViewModel>(
|
||||
() => new SuggestedActorsViewModel(store, {pageSize: 5}),
|
||||
[store],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
suggestedActorsView.loadMore(true)
|
||||
}, [store, suggestedActorsView])
|
||||
|
||||
const onPressLoadMoreSuggestedActors = () => {
|
||||
suggestedActorsView.loadMore()
|
||||
}
|
||||
const onToggleFollow = async (item: SuggestedActor) => {
|
||||
if (follows[item.did]) {
|
||||
try {
|
||||
await apilib.unfollow(store, follows[item.did])
|
||||
setFollows(_omit(follows, [item.did]))
|
||||
} catch (e: any) {
|
||||
store.log.error('Failed fo delete follow', e)
|
||||
Toast.show('An issue occurred, please try again.')
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const res = await apilib.follow(store, item.did, item.declaration.cid)
|
||||
setFollows({[item.did]: res.uri, ...follows})
|
||||
} catch (e: any) {
|
||||
store.log.error('Failed fo create follow', e)
|
||||
Toast.show('An issue occurred, please try again.')
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{(suggestedActorsView.hasContent || suggestedActorsView.isLoading) && (
|
||||
<Text type="lg-heavy" style={[styles.heading, pal.text]}>
|
||||
Who to follow
|
||||
</Text>
|
||||
)}
|
||||
{suggestedActorsView.hasContent && (
|
||||
<>
|
||||
<View style={[pal.border, styles.bottomBorder]}>
|
||||
{suggestedActorsView.suggestions.map(item => (
|
||||
<ProfileCard
|
||||
key={item.did}
|
||||
handle={item.handle}
|
||||
displayName={item.displayName}
|
||||
avatar={item.avatar}
|
||||
description={item.description}
|
||||
renderButton={() => (
|
||||
<FollowBtn
|
||||
isFollowing={!!follows[item.did]}
|
||||
onPress={() => onToggleFollow(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{!suggestedActorsView.isLoading && suggestedActorsView.hasMore && (
|
||||
<TouchableOpacity
|
||||
onPress={onPressLoadMoreSuggestedActors}
|
||||
style={styles.loadMore}>
|
||||
<Text type="md-medium" style={pal.link}>
|
||||
Show more
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{suggestedActorsView.isLoading && (
|
||||
<View style={s.mt10}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
function FollowBtn({
|
||||
isFollowing,
|
||||
onPress,
|
||||
}: {
|
||||
isFollowing: boolean
|
||||
onPress: () => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
if (isFollowing) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<View style={[styles.btn, pal.btn]}>
|
||||
<Text type="button" style={pal.text}>
|
||||
Unfollow
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress}>
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[styles.btn, styles.gradientBtn]}>
|
||||
<FontAwesomeIcon icon="plus" style={[s.white, s.mr5]} size={15} />
|
||||
<Text style={[s.white, s.fw600, s.f15]}>Follow</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
|
||||
bottomBorder: {
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
|
||||
loadMore: {
|
||||
paddingLeft: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
|
||||
btn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 7,
|
||||
borderRadius: 50,
|
||||
marginLeft: 6,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
gradientBtn: {
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
@@ -10,14 +10,14 @@ export function ProfileCard({
|
||||
handle,
|
||||
displayName,
|
||||
avatar,
|
||||
description,
|
||||
renderButton,
|
||||
onPressButton,
|
||||
}: {
|
||||
handle: string
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
description?: string
|
||||
renderButton?: () => JSX.Element
|
||||
onPressButton?: () => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
@@ -44,15 +44,16 @@ export function ProfileCard({
|
||||
</Text>
|
||||
</View>
|
||||
{renderButton ? (
|
||||
<View style={styles.layoutButton}>
|
||||
<TouchableOpacity
|
||||
onPress={onPressButton}
|
||||
style={[styles.btn, pal.btn]}>
|
||||
{renderButton()}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.layoutButton}>{renderButton()}</View>
|
||||
) : undefined}
|
||||
</View>
|
||||
{description ? (
|
||||
<View style={styles.details}>
|
||||
<Text style={pal.text} numberOfLines={4}>
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
) : undefined}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -60,6 +61,7 @@ export function ProfileCard({
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
layout: {
|
||||
flexDirection: 'row',
|
||||
@@ -68,7 +70,7 @@ const styles = StyleSheet.create({
|
||||
layoutAvi: {
|
||||
width: 60,
|
||||
paddingLeft: 10,
|
||||
paddingTop: 10,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
avi: {
|
||||
@@ -80,19 +82,15 @@ const styles = StyleSheet.create({
|
||||
layoutContent: {
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
paddingTop: 12,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
layoutButton: {
|
||||
paddingRight: 10,
|
||||
},
|
||||
btn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 7,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 50,
|
||||
marginLeft: 6,
|
||||
details: {
|
||||
paddingLeft: 60,
|
||||
paddingRight: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -4,22 +4,17 @@ import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {UserAvatar} from './UserAvatar'
|
||||
import {Text} from './text/Text'
|
||||
import {MagnifyingGlassIcon} from '../../lib/icons'
|
||||
import {useStores} from '../../../state'
|
||||
import {usePalette} from '../../lib/hooks/usePalette'
|
||||
import {colors} from '../../lib/styles'
|
||||
import {useAnalytics} from '@segment/analytics-react-native'
|
||||
|
||||
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
|
||||
const BACK_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
|
||||
|
||||
export const ViewHeader = observer(function ViewHeader({
|
||||
title,
|
||||
subtitle,
|
||||
canGoBack,
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
canGoBack?: boolean
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
@@ -32,9 +27,6 @@ export const ViewHeader = observer(function ViewHeader({
|
||||
track('ViewHeader:MenuButtonClicked')
|
||||
store.shell.setMainMenuOpen(true)
|
||||
}
|
||||
const onPressSearch = () => {
|
||||
store.nav.navigate('/search')
|
||||
}
|
||||
if (typeof canGoBack === 'undefined') {
|
||||
canGoBack = store.nav.tab.canGoBack
|
||||
}
|
||||
@@ -64,21 +56,7 @@ export const ViewHeader = observer(function ViewHeader({
|
||||
<Text type="title" style={[pal.text, styles.title]}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text
|
||||
type="title-sm"
|
||||
style={[styles.subtitle, pal.textLight]}
|
||||
numberOfLines={1}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={onPressSearch}
|
||||
hitSlop={HITSLOP}
|
||||
style={styles.btn}>
|
||||
<MagnifyingGlassIcon size={21} strokeWidth={3} style={pal.text} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
@@ -100,11 +78,6 @@ const styles = StyleSheet.create({
|
||||
title: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
subtitle: {
|
||||
marginLeft: 4,
|
||||
maxWidth: 200,
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
|
||||
backBtn: {
|
||||
width: 30,
|
||||
@@ -118,19 +91,4 @@ const styles = StyleSheet.create({
|
||||
backIcon: {
|
||||
marginTop: 6,
|
||||
},
|
||||
btn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 20,
|
||||
marginLeft: 4,
|
||||
},
|
||||
littleXIcon: {
|
||||
color: colors.red3,
|
||||
position: 'absolute',
|
||||
right: 7,
|
||||
bottom: 7,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ export const defaultTheme: Theme = {
|
||||
link: colors.blue3,
|
||||
border: '#f0e9e9',
|
||||
borderDark: '#e0d9d9',
|
||||
icon: colors.gray3,
|
||||
icon: colors.gray4,
|
||||
|
||||
// non-standard
|
||||
textVeryLight: colors.gray4,
|
||||
@@ -273,7 +273,7 @@ export const darkTheme: Theme = {
|
||||
link: colors.blue3,
|
||||
border: colors.gray6,
|
||||
borderDark: colors.gray5,
|
||||
icon: colors.gray5,
|
||||
icon: colors.gray4,
|
||||
|
||||
// non-standard
|
||||
textVeryLight: colors.gray4,
|
||||
|
||||
@@ -85,7 +85,7 @@ export const Home = observer(function Home({
|
||||
|
||||
return (
|
||||
<View style={s.h100pct}>
|
||||
<ViewHeader title="Bluesky" subtitle="Private Beta" canGoBack={false} />
|
||||
<ViewHeader title="Bluesky" canGoBack={false} />
|
||||
<Feed
|
||||
testID="homeFeed"
|
||||
key="default"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {useEffect, useMemo, useState} from 'react'
|
||||
import React, {useEffect, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {makeRecordUri} from '../../lib/strings'
|
||||
import {ViewHeader} from '../com/util/ViewHeader'
|
||||
@@ -11,7 +11,6 @@ import {s} from '../lib/styles'
|
||||
export const PostThread = ({navIdx, visible, params}: ScreenParams) => {
|
||||
const store = useStores()
|
||||
const {name, rkey} = params
|
||||
const [viewSubtitle, setViewSubtitle] = useState<string>(`by ${name}`)
|
||||
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
|
||||
const view = useMemo<PostThreadViewModel>(
|
||||
() => new PostThreadViewModel(store, {uri}),
|
||||
@@ -24,7 +23,6 @@ export const PostThread = ({navIdx, visible, params}: ScreenParams) => {
|
||||
const setTitle = () => {
|
||||
const author = view.thread?.post.author
|
||||
const niceName = author?.handle || name
|
||||
setViewSubtitle(`by ${niceName}`)
|
||||
store.nav.setTitle(navIdx, `Post by ${niceName}`)
|
||||
}
|
||||
if (!visible) {
|
||||
@@ -52,7 +50,7 @@ export const PostThread = ({navIdx, visible, params}: ScreenParams) => {
|
||||
|
||||
return (
|
||||
<View style={s.h100pct}>
|
||||
<ViewHeader title="Post" subtitle={viewSubtitle} />
|
||||
<ViewHeader title="Post" />
|
||||
<View style={s.h100pct}>
|
||||
<PostThreadComponent uri={uri} view={view} />
|
||||
</View>
|
||||
|
||||
@@ -18,7 +18,7 @@ export const ProfileFollowers = ({navIdx, visible, params}: ScreenParams) => {
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ViewHeader title="Followers" subtitle={`of ${name}`} />
|
||||
<ViewHeader title="Followers" />
|
||||
<ProfileFollowersComponent name={name} />
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ export const ProfileFollows = ({navIdx, visible, params}: ScreenParams) => {
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ViewHeader title="Followed" subtitle={`by ${name}`} />
|
||||
<ViewHeader title="Followed" />
|
||||
<ProfileFollowsComponent name={name} />
|
||||
</View>
|
||||
)
|
||||
|
||||
+138
-66
@@ -1,14 +1,14 @@
|
||||
import React, {useEffect, useState, useMemo, useRef} from 'react'
|
||||
import React from 'react'
|
||||
import {
|
||||
Keyboard,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {ViewHeader} from '../com/util/ViewHeader'
|
||||
import {SuggestedFollows} from '../com/discover/SuggestedFollows'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {UserAvatar} from '../com/util/UserAvatar'
|
||||
import {Text} from '../com/util/text/Text'
|
||||
import {ScreenParams} from '../routes'
|
||||
@@ -16,26 +16,45 @@ import {useStores} from '../../state'
|
||||
import {UserAutocompleteViewModel} from '../../state/models/user-autocomplete-view'
|
||||
import {s} from '../lib/styles'
|
||||
import {MagnifyingGlassIcon} from '../lib/icons'
|
||||
import {WhoToFollow} from '../com/discover/WhoToFollow'
|
||||
import {SuggestedPosts} from '../com/discover/SuggestedPosts'
|
||||
import {ProfileCard} from '../com/profile/ProfileCard'
|
||||
import {usePalette} from '../lib/hooks/usePalette'
|
||||
import {useAnalytics} from '@segment/analytics-react-native'
|
||||
|
||||
export const Search = ({navIdx, visible, params}: ScreenParams) => {
|
||||
const MENU_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
|
||||
const FIVE_MIN = 5 * 60 * 1e3
|
||||
|
||||
export const Search = observer(({navIdx, visible, params}: ScreenParams) => {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const textInput = useRef<TextInput>(null)
|
||||
const [query, setQuery] = useState<string>('')
|
||||
const autocompleteView = useMemo<UserAutocompleteViewModel>(
|
||||
const {track} = useAnalytics()
|
||||
const textInput = React.useRef<TextInput>(null)
|
||||
const [lastRenderTime, setRenderTime] = React.useState<number>(0) // used to trigger reloads
|
||||
const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
|
||||
const [query, setQuery] = React.useState<string>('')
|
||||
const autocompleteView = React.useMemo<UserAutocompleteViewModel>(
|
||||
() => new UserAutocompleteViewModel(store),
|
||||
[store],
|
||||
)
|
||||
const {name} = params
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
const now = Date.now()
|
||||
if (lastRenderTime - now > FIVE_MIN) {
|
||||
setRenderTime(Date.now()) // trigger reload of suggestions
|
||||
}
|
||||
store.shell.setMinimalShellMode(false)
|
||||
autocompleteView.setup()
|
||||
store.nav.setTitle(navIdx, 'Search')
|
||||
}
|
||||
}, [store, visible, name, navIdx, autocompleteView])
|
||||
}, [store, visible, name, navIdx, autocompleteView, lastRenderTime])
|
||||
|
||||
const onPressMenu = () => {
|
||||
track('ViewHeader:MenuButtonClicked')
|
||||
store.shell.setMainMenuOpen(true)
|
||||
}
|
||||
|
||||
const onChangeQuery = (text: string) => {
|
||||
setQuery(text)
|
||||
@@ -46,87 +65,140 @@ export const Search = ({navIdx, visible, params}: ScreenParams) => {
|
||||
autocompleteView.setActive(false)
|
||||
}
|
||||
}
|
||||
const onSelect = (handle: string) => {
|
||||
textInput.current?.blur()
|
||||
store.nav.navigate(`/profile/${handle}`)
|
||||
const onPressCancelSearch = () => {
|
||||
setQuery('')
|
||||
autocompleteView.setActive(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[pal.view, styles.container]}>
|
||||
<ViewHeader title="Search" />
|
||||
<View style={[pal.view, pal.border, styles.inputContainer]}>
|
||||
<MagnifyingGlassIcon style={[pal.text, styles.inputIcon]} />
|
||||
<TextInput
|
||||
testID="searchTextInput"
|
||||
ref={textInput}
|
||||
placeholder="Type your query here..."
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
selectTextOnFocus
|
||||
returnKeyType="search"
|
||||
style={[pal.text, styles.input]}
|
||||
onChangeText={onChangeQuery}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.outputContainer}>
|
||||
{query ? (
|
||||
<ScrollView testID="searchScrollView" onScroll={Keyboard.dismiss}>
|
||||
{autocompleteView.searchRes.map((item, i) => (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={[pal.view, pal.border, styles.searchResult]}
|
||||
onPress={() => onSelect(item.handle)}>
|
||||
<UserAvatar
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||
<View style={[pal.view, styles.container]}>
|
||||
<View style={[pal.view, pal.border, styles.header]}>
|
||||
<TouchableOpacity
|
||||
testID="viewHeaderBackOrMenuBtn"
|
||||
onPress={onPressMenu}
|
||||
hitSlop={MENU_HITSLOP}
|
||||
style={styles.headerMenuBtn}>
|
||||
<UserAvatar
|
||||
size={30}
|
||||
handle={store.me.handle}
|
||||
displayName={store.me.displayName}
|
||||
avatar={store.me.avatar}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<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]}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
onChangeText={onChangeQuery}
|
||||
/>
|
||||
</View>
|
||||
{query ? (
|
||||
<View style={styles.headerCancelBtn}>
|
||||
<TouchableOpacity onPress={onPressCancelSearch}>
|
||||
<Text>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : undefined}
|
||||
</View>
|
||||
<View style={styles.outputContainer}>
|
||||
{query && autocompleteView.searchRes.length ? (
|
||||
<ScrollView testID="searchScrollView" onScroll={Keyboard.dismiss}>
|
||||
{autocompleteView.searchRes.map(item => (
|
||||
<ProfileCard
|
||||
key={item.did}
|
||||
handle={item.handle}
|
||||
displayName={item.displayName}
|
||||
avatar={item.avatar}
|
||||
size={36}
|
||||
/>
|
||||
<View style={[s.ml10]}>
|
||||
<Text type="title-sm" style={pal.text}>
|
||||
{item.displayName || item.handle}
|
||||
</Text>
|
||||
<Text style={pal.textLight}>@{item.handle}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<View style={s.footerSpacer} />
|
||||
</ScrollView>
|
||||
) : (
|
||||
<SuggestedFollows asLinks />
|
||||
)}
|
||||
))}
|
||||
<View style={s.footerSpacer} />
|
||||
</ScrollView>
|
||||
) : query && !autocompleteView.searchRes.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 on the network
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView onScroll={Keyboard.dismiss}>
|
||||
<WhoToFollow key={`wtf-${lastRenderTime}`} />
|
||||
<SuggestedPosts key={`sp-${lastRenderTime}`} />
|
||||
<View style={s.footerSpacer} />
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
inputContainer: {
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
borderTopWidth: 1,
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 5,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: 10,
|
||||
headerMenuBtn: {
|
||||
width: 40,
|
||||
height: 30,
|
||||
marginLeft: 6,
|
||||
},
|
||||
headerSearchContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 30,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
headerSearchIcon: {
|
||||
marginRight: 6,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
input: {
|
||||
headerSearchInput: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
},
|
||||
headerCancelBtn: {
|
||||
width: 60,
|
||||
paddingLeft: 10,
|
||||
},
|
||||
|
||||
searchPrompt: {
|
||||
textAlign: 'center',
|
||||
paddingTop: 10,
|
||||
},
|
||||
|
||||
outputContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
searchResult: {
|
||||
flexDirection: 'row',
|
||||
borderTopWidth: 1,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
CogIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from '../../lib/icons'
|
||||
import {TabPurpose, TabPurposeMainPath} from '../../../state/models/navigation'
|
||||
import {UserAvatar} from '../../com/util/UserAvatar'
|
||||
import {Text} from '../../com/util/text/Text'
|
||||
import {ToggleButton} from '../../com/util/forms/ToggleButton'
|
||||
@@ -36,10 +37,12 @@ export const Menu = observer(({onClose}: {onClose: () => void}) => {
|
||||
track('Menu:ItemClicked', {url})
|
||||
|
||||
onClose()
|
||||
if (url === '/notifications') {
|
||||
store.nav.switchTo(1, true)
|
||||
if (url === TabPurposeMainPath[TabPurpose.Notifs]) {
|
||||
store.nav.switchTo(TabPurpose.Notifs, true)
|
||||
} else if (url === TabPurposeMainPath[TabPurpose.Search]) {
|
||||
store.nav.switchTo(TabPurpose.Search, true)
|
||||
} else {
|
||||
store.nav.switchTo(0, true)
|
||||
store.nav.switchTo(TabPurpose.Default, true)
|
||||
if (url !== '/') {
|
||||
store.nav.navigate(url)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
useColorScheme,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {ScreenContainer, Screen} from 'react-native-screens'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
@@ -20,7 +19,11 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||
import {TABS_ENABLED} from '../../../build-flags'
|
||||
import {useStores} from '../../../state'
|
||||
import {NavigationModel} from '../../../state/models/navigation'
|
||||
import {
|
||||
NavigationModel,
|
||||
TabPurpose,
|
||||
TabPurposeMainPath,
|
||||
} from '../../../state/models/navigation'
|
||||
import {match, MatchResult} from '../../routes'
|
||||
import {Login} from '../../screens/Login'
|
||||
import {Menu} from './Menu'
|
||||
@@ -39,6 +42,7 @@ import {
|
||||
GridIconSolid,
|
||||
HomeIcon,
|
||||
HomeIconSolid,
|
||||
MagnifyingGlassIcon,
|
||||
BellIcon,
|
||||
BellIconSolid,
|
||||
} from '../../lib/icons'
|
||||
@@ -60,6 +64,8 @@ const Btn = ({
|
||||
| 'menu-solid'
|
||||
| 'home'
|
||||
| 'home-solid'
|
||||
| 'search'
|
||||
| 'search-solid'
|
||||
| 'bell'
|
||||
| 'bell-solid'
|
||||
notificationCount?: number
|
||||
@@ -68,29 +74,52 @@ const Btn = ({
|
||||
onLongPress?: (event: GestureResponderEvent) => void
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
let size = 24
|
||||
let addedStyles
|
||||
let IconEl
|
||||
let iconEl
|
||||
if (icon === 'menu') {
|
||||
IconEl = GridIcon
|
||||
iconEl = <GridIcon style={[styles.ctrlIcon, pal.text]} />
|
||||
} else if (icon === 'menu-solid') {
|
||||
IconEl = GridIconSolid
|
||||
iconEl = <GridIconSolid style={[styles.ctrlIcon, pal.text]} />
|
||||
} else if (icon === 'home') {
|
||||
IconEl = HomeIcon
|
||||
size = 27
|
||||
iconEl = <HomeIcon size={27} style={[styles.ctrlIcon, pal.text]} />
|
||||
} else if (icon === 'home-solid') {
|
||||
IconEl = HomeIconSolid
|
||||
size = 27
|
||||
iconEl = <HomeIconSolid size={27} style={[styles.ctrlIcon, pal.text]} />
|
||||
} else if (icon === 'search') {
|
||||
iconEl = (
|
||||
<MagnifyingGlassIcon
|
||||
size={28}
|
||||
style={[styles.ctrlIcon, pal.text, styles.bumpUpOnePixel]}
|
||||
/>
|
||||
)
|
||||
} else if (icon === 'search-solid') {
|
||||
iconEl = (
|
||||
<MagnifyingGlassIcon
|
||||
size={28}
|
||||
strokeWidth={3}
|
||||
style={[styles.ctrlIcon, pal.text, styles.bumpUpOnePixel]}
|
||||
/>
|
||||
)
|
||||
} else if (icon === 'bell') {
|
||||
IconEl = BellIcon
|
||||
size = 27
|
||||
addedStyles = {position: 'relative', top: -1} as ViewStyle
|
||||
iconEl = (
|
||||
<BellIcon
|
||||
size={27}
|
||||
style={[styles.ctrlIcon, pal.text, styles.bumpUpOnePixel]}
|
||||
/>
|
||||
)
|
||||
} else if (icon === 'bell-solid') {
|
||||
IconEl = BellIconSolid
|
||||
size = 27
|
||||
addedStyles = {position: 'relative', top: -1} as ViewStyle
|
||||
iconEl = (
|
||||
<BellIconSolid
|
||||
size={27}
|
||||
style={[styles.ctrlIcon, pal.text, styles.bumpUpOnePixel]}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
IconEl = FontAwesomeIcon
|
||||
iconEl = (
|
||||
<FontAwesomeIcon
|
||||
icon={icon}
|
||||
size={24}
|
||||
style={[styles.ctrlIcon, pal.text]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -109,11 +138,7 @@ const Btn = ({
|
||||
<Text style={styles.tabCountLabel}>{tabCount}</Text>
|
||||
</View>
|
||||
) : undefined}
|
||||
<IconEl
|
||||
size={size}
|
||||
style={[styles.ctrlIcon, pal.text, addedStyles]}
|
||||
icon={icon}
|
||||
/>
|
||||
{iconEl}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
@@ -138,17 +163,29 @@ export const MobileShell: React.FC = observer(() => {
|
||||
|
||||
const onPressHome = () => {
|
||||
track('MobileShell:HomeButtonPressed')
|
||||
if (store.shell.isMainMenuOpen) {
|
||||
store.shell.setMainMenuOpen(false)
|
||||
}
|
||||
if (store.nav.tab.fixedTabPurpose === 0) {
|
||||
if (store.nav.tab.fixedTabPurpose === TabPurpose.Default) {
|
||||
if (store.nav.tab.current.url === '/') {
|
||||
scrollElRef.current?.scrollToOffset({offset: 0})
|
||||
} else {
|
||||
store.nav.tab.fixedTabReset()
|
||||
}
|
||||
} else {
|
||||
store.nav.switchTo(0, false)
|
||||
store.nav.switchTo(TabPurpose.Default, false)
|
||||
if (store.nav.tab.index === 0) {
|
||||
store.nav.tab.fixedTabReset()
|
||||
}
|
||||
}
|
||||
}
|
||||
const onPressSearch = () => {
|
||||
track('MobileShell:SearchButtonPressed')
|
||||
if (store.nav.tab.fixedTabPurpose === TabPurpose.Search) {
|
||||
if (store.nav.tab.current.url === '/') {
|
||||
scrollElRef.current?.scrollToOffset({offset: 0})
|
||||
} else {
|
||||
store.nav.tab.fixedTabReset()
|
||||
}
|
||||
} else {
|
||||
store.nav.switchTo(TabPurpose.Search, false)
|
||||
if (store.nav.tab.index === 0) {
|
||||
store.nav.tab.fixedTabReset()
|
||||
}
|
||||
@@ -156,13 +193,10 @@ export const MobileShell: React.FC = observer(() => {
|
||||
}
|
||||
const onPressNotifications = () => {
|
||||
track('MobileShell:NotificationsButtonPressed')
|
||||
if (store.shell.isMainMenuOpen) {
|
||||
store.shell.setMainMenuOpen(false)
|
||||
}
|
||||
if (store.nav.tab.fixedTabPurpose === 1) {
|
||||
if (store.nav.tab.fixedTabPurpose === TabPurpose.Notifs) {
|
||||
store.nav.tab.fixedTabReset()
|
||||
} else {
|
||||
store.nav.switchTo(1, false)
|
||||
store.nav.switchTo(TabPurpose.Notifs, false)
|
||||
if (store.nav.tab.index === 0) {
|
||||
store.nav.tab.fixedTabReset()
|
||||
}
|
||||
@@ -344,8 +378,12 @@ export const MobileShell: React.FC = observer(() => {
|
||||
)
|
||||
}
|
||||
|
||||
const isAtHome = store.nav.tab.current.url === '/'
|
||||
const isAtNotifications = store.nav.tab.current.url === '/notifications'
|
||||
const isAtHome =
|
||||
store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Default]
|
||||
const isAtSearch =
|
||||
store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Search]
|
||||
const isAtNotifications =
|
||||
store.nav.tab.current.url === TabPurposeMainPath[TabPurpose.Notifs]
|
||||
|
||||
const screenBg = {
|
||||
backgroundColor: theme.colorScheme === 'dark' ? colors.gray7 : colors.gray1,
|
||||
@@ -458,6 +496,11 @@ export const MobileShell: React.FC = observer(() => {
|
||||
onPress={onPressHome}
|
||||
onLongPress={TABS_ENABLED ? doNewTab('/') : undefined}
|
||||
/>
|
||||
<Btn
|
||||
icon={isAtSearch ? 'search-solid' : 'search'}
|
||||
onPress={onPressSearch}
|
||||
onLongPress={TABS_ENABLED ? doNewTab('/') : undefined}
|
||||
/>
|
||||
{TABS_ENABLED ? (
|
||||
<Btn
|
||||
icon={isTabsSelectorActive ? 'clone' : ['far', 'clone']}
|
||||
@@ -580,7 +623,7 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
borderTopWidth: 1,
|
||||
paddingLeft: 5,
|
||||
paddingRight: 15,
|
||||
paddingRight: 25,
|
||||
},
|
||||
ctrl: {
|
||||
flex: 1,
|
||||
@@ -618,4 +661,8 @@ const styles = StyleSheet.create({
|
||||
inactive: {
|
||||
color: colors.gray3,
|
||||
},
|
||||
bumpUpOnePixel: {
|
||||
position: 'relative',
|
||||
top: -1,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user