mobile autocomplete
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
|||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import LinearGradient from 'react-native-linear-gradient'
|
import LinearGradient from 'react-native-linear-gradient'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
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 {useAnalytics} from 'lib/analytics/analytics'
|
||||||
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
||||||
import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
|
import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
|
||||||
@@ -226,6 +226,14 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
imageCount: gallery.size,
|
imageCount: gallery.size,
|
||||||
})
|
})
|
||||||
if (replyTo && replyTo.uri) track('Post:Reply')
|
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) {
|
if (!replyTo) {
|
||||||
store.me.mainFeed.onPostCreated()
|
store.me.mainFeed.onPostCreated()
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ import isEqual from 'lodash.isequal'
|
|||||||
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
||||||
import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
|
import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
|
||||||
import {Autocomplete} from './mobile/Autocomplete'
|
import {Autocomplete} from './mobile/Autocomplete'
|
||||||
|
import {
|
||||||
|
TagsAutocomplete,
|
||||||
|
getHashtagAt,
|
||||||
|
insertTagAt,
|
||||||
|
} from './mobile/TagsAutocomplete'
|
||||||
import {Text} from 'view/com/util/text/Text'
|
import {Text} from 'view/com/util/text/Text'
|
||||||
import {cleanError} from 'lib/strings/errors'
|
import {cleanError} from 'lib/strings/errors'
|
||||||
import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
|
import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
|
||||||
@@ -59,6 +64,7 @@ export const TextInput = forwardRef(function TextInputImpl(
|
|||||||
placeholder,
|
placeholder,
|
||||||
suggestedLinks,
|
suggestedLinks,
|
||||||
autocompleteView,
|
autocompleteView,
|
||||||
|
tagsAutocompleteModel,
|
||||||
setRichText,
|
setRichText,
|
||||||
onPhotoPasted,
|
onPhotoPasted,
|
||||||
onSuggestedLinksChanged,
|
onSuggestedLinksChanged,
|
||||||
@@ -96,17 +102,29 @@ export const TextInput = forwardRef(function TextInputImpl(
|
|||||||
newRt.detectFacetsWithoutResolution()
|
newRt.detectFacetsWithoutResolution()
|
||||||
setRichText(newRt)
|
setRichText(newRt)
|
||||||
|
|
||||||
const prefix = getMentionAt(
|
const mentionPrefix = getMentionAt(
|
||||||
newText,
|
newText,
|
||||||
textInputSelection.current?.start || 0,
|
textInputSelection.current?.start || 0,
|
||||||
)
|
)
|
||||||
if (prefix) {
|
|
||||||
|
if (mentionPrefix) {
|
||||||
autocompleteView.setActive(true)
|
autocompleteView.setActive(true)
|
||||||
autocompleteView.setPrefix(prefix.value)
|
autocompleteView.setPrefix(mentionPrefix.value)
|
||||||
} else {
|
} else {
|
||||||
autocompleteView.setActive(false)
|
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()
|
const set: Set<string> = new Set()
|
||||||
|
|
||||||
if (newRt.facets) {
|
if (newRt.facets) {
|
||||||
@@ -145,6 +163,7 @@ export const TextInput = forwardRef(function TextInputImpl(
|
|||||||
suggestedLinks,
|
suggestedLinks,
|
||||||
onSuggestedLinksChanged,
|
onSuggestedLinksChanged,
|
||||||
onPhotoPasted,
|
onPhotoPasted,
|
||||||
|
tagsAutocompleteModel,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -186,6 +205,17 @@ export const TextInput = forwardRef(function TextInputImpl(
|
|||||||
[onChangeText, richtext, autocompleteView],
|
[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(() => {
|
const textDecorated = useMemo(() => {
|
||||||
let i = 0
|
let i = 0
|
||||||
|
|
||||||
@@ -223,6 +253,7 @@ export const TextInput = forwardRef(function TextInputImpl(
|
|||||||
view={autocompleteView}
|
view={autocompleteView}
|
||||||
onSelect={onSelectAutocompleteItem}
|
onSelect={onSelectAutocompleteItem}
|
||||||
/>
|
/>
|
||||||
|
<TagsAutocomplete model={tagsAutocompleteModel} onSelect={onSelectTag} />
|
||||||
</View>
|
</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