mobile autocomplete
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {RichText} from '@atproto/api'
|
||||
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
||||
import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
|
||||
@@ -226,6 +226,14 @@ export const ComposePost = observer(function ComposePost({
|
||||
imageCount: gallery.size,
|
||||
})
|
||||
if (replyTo && replyTo.uri) track('Post:Reply')
|
||||
|
||||
for (const facet of richtext.facets || []) {
|
||||
for (const feature of facet.features) {
|
||||
if (AppBskyRichtextFacet.isTag(feature)) {
|
||||
tagsAutocompleteModel.commitRecentTag(feature.tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!replyTo) {
|
||||
store.me.mainFeed.onPostCreated()
|
||||
|
||||
@@ -21,6 +21,11 @@ import isEqual from 'lodash.isequal'
|
||||
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
||||
import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
|
||||
import {Autocomplete} from './mobile/Autocomplete'
|
||||
import {
|
||||
TagsAutocomplete,
|
||||
getHashtagAt,
|
||||
insertTagAt,
|
||||
} from './mobile/TagsAutocomplete'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
|
||||
@@ -59,6 +64,7 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
placeholder,
|
||||
suggestedLinks,
|
||||
autocompleteView,
|
||||
tagsAutocompleteModel,
|
||||
setRichText,
|
||||
onPhotoPasted,
|
||||
onSuggestedLinksChanged,
|
||||
@@ -96,17 +102,29 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
newRt.detectFacetsWithoutResolution()
|
||||
setRichText(newRt)
|
||||
|
||||
const prefix = getMentionAt(
|
||||
const mentionPrefix = getMentionAt(
|
||||
newText,
|
||||
textInputSelection.current?.start || 0,
|
||||
)
|
||||
if (prefix) {
|
||||
|
||||
if (mentionPrefix) {
|
||||
autocompleteView.setActive(true)
|
||||
autocompleteView.setPrefix(prefix.value)
|
||||
autocompleteView.setPrefix(mentionPrefix.value)
|
||||
} else {
|
||||
autocompleteView.setActive(false)
|
||||
}
|
||||
|
||||
const hashtagPrefix = getHashtagAt(
|
||||
newText,
|
||||
textInputSelection.current?.start || 0,
|
||||
)
|
||||
if (hashtagPrefix) {
|
||||
tagsAutocompleteModel.setActive(true)
|
||||
tagsAutocompleteModel.search(hashtagPrefix.value || '')
|
||||
} else {
|
||||
tagsAutocompleteModel.setActive(false)
|
||||
}
|
||||
|
||||
const set: Set<string> = new Set()
|
||||
|
||||
if (newRt.facets) {
|
||||
@@ -145,6 +163,7 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
suggestedLinks,
|
||||
onSuggestedLinksChanged,
|
||||
onPhotoPasted,
|
||||
tagsAutocompleteModel,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -186,6 +205,17 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
[onChangeText, richtext, autocompleteView],
|
||||
)
|
||||
|
||||
const onSelectTag = useCallback(
|
||||
(tag: string) => {
|
||||
onChangeText(
|
||||
insertTagAt(richtext.text, textInputSelection.current?.start || 0, tag),
|
||||
)
|
||||
tagsAutocompleteModel.commitRecentTag(tag)
|
||||
tagsAutocompleteModel.setActive(false)
|
||||
},
|
||||
[onChangeText, richtext, tagsAutocompleteModel],
|
||||
)
|
||||
|
||||
const textDecorated = useMemo(() => {
|
||||
let i = 0
|
||||
|
||||
@@ -223,6 +253,7 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
view={autocompleteView}
|
||||
onSelect={onSelectAutocompleteItem}
|
||||
/>
|
||||
<TagsAutocomplete model={tagsAutocompleteModel} onSelect={onSelectTag} />
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, {useEffect} from 'react'
|
||||
import {Animated, TouchableOpacity, StyleSheet, View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
|
||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
|
||||
export function getHashtagAt(text: string, position: number) {
|
||||
const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/gi
|
||||
|
||||
let match
|
||||
while ((match = regex.exec(text))) {
|
||||
const [matchedString, tag] = match
|
||||
|
||||
if (tag.length > 66) continue
|
||||
|
||||
const from = match.index + matchedString.indexOf(tag)
|
||||
const to = from + tag.length
|
||||
|
||||
if (position >= from && position <= to) {
|
||||
return {value: tag, index: from}
|
||||
}
|
||||
}
|
||||
|
||||
const hashRegex = /#/g
|
||||
let hashMatch
|
||||
while ((hashMatch = hashRegex.exec(text))) {
|
||||
if (position >= hashMatch.index && position <= hashMatch.index + 1) {
|
||||
return {value: '', index: hashMatch.index}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function insertTagAt(text: string, position: number, tag: string) {
|
||||
const target = getHashtagAt(text, position)
|
||||
if (target) {
|
||||
return `${text.slice(0, target.index)}#${tag} ${text.slice(
|
||||
target.index + target.value.length + 1, // add 1 to include the "@"
|
||||
)}`
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
export const TagsAutocomplete = observer(function AutocompleteImpl({
|
||||
model,
|
||||
onSelect,
|
||||
}: {
|
||||
model: TagsAutocompleteModel
|
||||
onSelect: (item: string) => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const positionInterp = useAnimatedValue(0)
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(positionInterp, {
|
||||
toValue: model.isActive ? 1 : 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start()
|
||||
}, [positionInterp, model.isActive])
|
||||
|
||||
const topAnimStyle = {
|
||||
transform: [
|
||||
{
|
||||
translateY: positionInterp.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [200, 0],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
if (!model.suggestions.length) return null
|
||||
|
||||
return (
|
||||
<Animated.View style={topAnimStyle}>
|
||||
{model.isActive ? (
|
||||
<View style={[pal.view, styles.container, pal.border]}>
|
||||
{model.suggestions.slice(0, 5).map(item => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
testID="autocompleteButton"
|
||||
key={item}
|
||||
style={[pal.border, styles.item]}
|
||||
onPress={() => onSelect(item)}
|
||||
accessibilityLabel={`Select #${item}`}
|
||||
accessibilityHint="">
|
||||
<Text type="sm" style={pal.textLight} numberOfLines={1}>
|
||||
#{item}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginLeft: -50, // Composer avatar width
|
||||
top: 10,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
item: {
|
||||
borderBottomWidth: 1,
|
||||
paddingVertical: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 6,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user