From 564a654cc260109a21ac0d1b15b927d39fee89e8 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 27 Sep 2023 21:22:47 -0500 Subject: [PATCH] mobile autocomplete --- src/view/com/composer/Composer.tsx | 10 +- .../com/composer/text-input/TextInput.tsx | 37 +++++- .../text-input/mobile/TagsAutocomplete.tsx | 118 ++++++++++++++++++ 3 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 70d817dbb9..065f27cacc 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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() diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 49af905be5..5218df2ac5 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -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 = 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} /> + ) }) diff --git a/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx b/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx new file mode 100644 index 0000000000..8d03662f7b --- /dev/null +++ b/src/view/com/composer/text-input/mobile/TagsAutocomplete.tsx @@ -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 ( + + {model.isActive ? ( + + {model.suggestions.slice(0, 5).map(item => { + return ( + onSelect(item)} + accessibilityLabel={`Select #${item}`} + accessibilityHint=""> + + #{item} + + + ) + })} + + ) : null} + + ) +}) + +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, + }, +})