bsky-app/src/view/shell/desktop/Search.tsx
Eric Bailey 47d2d3cbf2
[PWI] Shell (#1967)
* Sidebars

* Bottom bar

* Drawer

* Translate

* Spacing fix

* Fix responsive regression

* Fix nit
2023-11-21 16:58:13 -06:00

264 lines
7.2 KiB
TypeScript

import React from 'react'
import {
ViewStyle,
TextInput,
View,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
} from 'react-native'
import {useNavigation, StackActions} from '@react-navigation/native'
import {
AppBskyActorDefs,
moderateProfile,
ProfileModeration,
} from '@atproto/api'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {s} from '#/lib/styles'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {makeProfileLink} from '#/lib/routes/links'
import {Link} from '#/view/com/util/Link'
import {usePalette} from 'lib/hooks/usePalette'
import {MagnifyingGlassIcon2} from 'lib/icons'
import {NavigationProp} from 'lib/routes/types'
import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {useModerationOpts} from '#/state/queries/preferences'
export function SearchResultCard({
profile,
style,
moderation,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
style: ViewStyle
moderation: ProfileModeration
}) {
const pal = usePalette('default')
return (
<Link
href={makeProfileLink(profile)}
title={profile.handle}
asAnchor
anchorNoUnderline>
<View
style={[
pal.border,
style,
{
borderTopWidth: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingVertical: 8,
paddingHorizontal: 12,
},
]}>
<UserAvatar
size={40}
avatar={profile.avatar}
moderation={moderation.avatar}
/>
<View style={{flex: 1}}>
<Text
type="lg"
style={[s.bold, pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.profile,
)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View>
</View>
</Link>
)
}
export function DesktopSearch() {
const {_} = useLingui()
const pal = usePalette('default')
const navigation = useNavigation<NavigationProp>()
const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>(
undefined,
)
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[]
>([])
const moderationOpts = useModerationOpts()
const search = useActorAutocompleteFn()
const onChangeText = React.useCallback(
async (text: string) => {
setQuery(text)
if (text.length > 0) {
setIsFetching(true)
setIsActive(true)
if (searchDebounceTimeout.current)
clearTimeout(searchDebounceTimeout.current)
searchDebounceTimeout.current = setTimeout(async () => {
const results = await search({query: text})
if (results) {
setSearchResults(results)
setIsFetching(false)
}
}, 300)
} else {
if (searchDebounceTimeout.current)
clearTimeout(searchDebounceTimeout.current)
setSearchResults([])
setIsFetching(false)
setIsActive(false)
}
},
[setQuery, search, setSearchResults],
)
const onPressCancelSearch = React.useCallback(() => {
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, setSearchResults])
return (
<View style={[styles.container, pal.view]}>
<View
style={[{backgroundColor: pal.colors.backgroundLight}, styles.search]}>
<View style={[styles.inputContainer]}>
<MagnifyingGlassIcon2
size={18}
style={[pal.textLight, styles.iconWrapper]}
/>
<TextInput
testID="searchTextInput"
placeholder={_(msg`Search`)}
placeholderTextColor={pal.colors.textLight}
selectTextOnFocus
returnKeyType="search"
value={query}
style={[pal.textLight, styles.input]}
onChangeText={onChangeText}
onSubmitEditing={onSubmit}
accessibilityRole="search"
accessibilityLabel={_(msg`Search`)}
accessibilityHint=""
/>
{query ? (
<View style={styles.cancelBtn}>
<TouchableOpacity
onPress={onPressCancelSearch}
accessibilityRole="button"
accessibilityLabel={_(msg`Cancel search`)}
accessibilityHint="Exits inputting search query"
onAccessibilityEscape={onPressCancelSearch}>
<Text type="lg" style={[pal.link]}>
<Trans>Cancel</Trans>
</Text>
</TouchableOpacity>
</View>
) : undefined}
</View>
</View>
{query !== '' && isActive && moderationOpts && (
<View style={[pal.view, pal.borderDark, styles.resultsContainer]}>
{isFetching ? (
<View style={{padding: 8}}>
<ActivityIndicator />
</View>
) : (
<>
{searchResults.length ? (
searchResults.map((item, i) => (
<SearchResultCard
key={item.did}
profile={item}
moderation={moderateProfile(item, moderationOpts)}
style={i === 0 ? {borderTopWidth: 0} : {}}
/>
))
) : (
<View>
<Text style={[pal.textLight, styles.noResults]}>
<Trans>No results found for {query}</Trans>
</Text>
</View>
)}
</>
)}
</View>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
position: 'relative',
width: 300,
},
search: {
paddingHorizontal: 16,
paddingVertical: 2,
width: 300,
borderRadius: 20,
},
inputContainer: {
flexDirection: 'row',
},
iconWrapper: {
position: 'relative',
top: 2,
paddingVertical: 7,
marginRight: 8,
},
input: {
flex: 1,
fontSize: 18,
width: '100%',
paddingTop: 7,
paddingBottom: 7,
},
cancelBtn: {
paddingRight: 4,
paddingLeft: 10,
paddingVertical: 7,
},
resultsContainer: {
marginTop: 10,
flexDirection: 'column',
width: 300,
borderWidth: 1,
borderRadius: 6,
},
noResults: {
textAlign: 'center',
paddingVertical: 10,
},
})