From 163363cd7e2181e5c62d6b922e16407087e0ddfb Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 2 Feb 2023 11:57:26 -0600 Subject: [PATCH] Composer: mention tagging now works in middle of text (close #105) --- __tests__/lib/strings/mention-manip.test.ts | 84 +++++++++++++++++++++ src/lib/strings/mention-manip.ts | 37 +++++++++ src/view/com/composer/ComposePost.tsx | 42 ++++++----- 3 files changed, 144 insertions(+), 19 deletions(-) create mode 100644 __tests__/lib/strings/mention-manip.test.ts create mode 100644 src/lib/strings/mention-manip.ts diff --git a/__tests__/lib/strings/mention-manip.test.ts b/__tests__/lib/strings/mention-manip.test.ts new file mode 100644 index 0000000000..f9075763e5 --- /dev/null +++ b/__tests__/lib/strings/mention-manip.test.ts @@ -0,0 +1,84 @@ +import { + getMentionAt, + insertMentionAt, +} from '../../../src/lib/strings/mention-manip' + +describe('getMentionAt', () => { + type Case = [string, number, string | undefined] + const cases: Case[] = [ + ['hello @alice goodbye', 0, undefined], + ['hello @alice goodbye', 1, undefined], + ['hello @alice goodbye', 2, undefined], + ['hello @alice goodbye', 3, undefined], + ['hello @alice goodbye', 4, undefined], + ['hello @alice goodbye', 5, undefined], + ['hello @alice goodbye', 6, 'alice'], + ['hello @alice goodbye', 7, 'alice'], + ['hello @alice goodbye', 8, 'alice'], + ['hello @alice goodbye', 9, 'alice'], + ['hello @alice goodbye', 10, 'alice'], + ['hello @alice goodbye', 11, 'alice'], + ['hello @alice goodbye', 12, 'alice'], + ['hello @alice goodbye', 13, undefined], + ['hello @alice goodbye', 14, undefined], + ['@alice', 0, 'alice'], + ['@alice hello', 0, 'alice'], + ['@alice hello', 1, 'alice'], + ['@alice hello', 2, 'alice'], + ['@alice hello', 3, 'alice'], + ['@alice hello', 4, 'alice'], + ['@alice hello', 5, 'alice'], + ['@alice hello', 6, 'alice'], + ['@alice hello', 7, undefined], + ['alice@alice', 0, undefined], + ['alice@alice', 6, undefined], + ] + + it.each(cases)( + 'given input string %p and cursor position %p, returns %p', + (str, cursorPos, expected) => { + const output = getMentionAt(str, cursorPos) + expect(output?.value).toEqual(expected) + }, + ) +}) + +describe('insertMentionAt', () => { + type Case = [string, number, string] + const cases: Case[] = [ + ['hello @alice goodbye', 0, 'hello @alice goodbye'], + ['hello @alice goodbye', 1, 'hello @alice goodbye'], + ['hello @alice goodbye', 2, 'hello @alice goodbye'], + ['hello @alice goodbye', 3, 'hello @alice goodbye'], + ['hello @alice goodbye', 4, 'hello @alice goodbye'], + ['hello @alice goodbye', 5, 'hello @alice goodbye'], + ['hello @alice goodbye', 6, 'hello @alice.com goodbye'], + ['hello @alice goodbye', 7, 'hello @alice.com goodbye'], + ['hello @alice goodbye', 8, 'hello @alice.com goodbye'], + ['hello @alice goodbye', 9, 'hello @alice.com goodbye'], + ['hello @alice goodbye', 10, 'hello @alice.com goodbye'], + ['hello @alice goodbye', 11, 'hello @alice.com goodbye'], + ['hello @alice goodbye', 12, 'hello @alice.com goodbye'], + ['hello @alice goodbye', 13, 'hello @alice goodbye'], + ['hello @alice goodbye', 14, 'hello @alice goodbye'], + ['@alice', 0, '@alice.com '], + ['@alice hello', 0, '@alice.com hello'], + ['@alice hello', 1, '@alice.com hello'], + ['@alice hello', 2, '@alice.com hello'], + ['@alice hello', 3, '@alice.com hello'], + ['@alice hello', 4, '@alice.com hello'], + ['@alice hello', 5, '@alice.com hello'], + ['@alice hello', 6, '@alice.com hello'], + ['@alice hello', 7, '@alice hello'], + ['alice@alice', 0, 'alice@alice'], + ['alice@alice', 6, 'alice@alice'], + ] + + it.each(cases)( + 'given input string %p and cursor position %p, returns %p', + (str, cursorPos, expected) => { + const output = insertMentionAt(str, cursorPos, 'alice.com') + expect(output).toEqual(expected) + }, + ) +}) diff --git a/src/lib/strings/mention-manip.ts b/src/lib/strings/mention-manip.ts new file mode 100644 index 0000000000..1f7cbe4344 --- /dev/null +++ b/src/lib/strings/mention-manip.ts @@ -0,0 +1,37 @@ +interface FoundMention { + value: string + index: number +} + +export function getMentionAt( + text: string, + cursorPos: number, +): FoundMention | undefined { + let re = /(^|\s)@([a-z0-9.]*)/gi + let match + while ((match = re.exec(text))) { + const spaceOffset = match[1].length + const index = match.index + spaceOffset + if ( + cursorPos >= index && + cursorPos <= index + match[0].length - spaceOffset + ) { + return {value: match[2], index} + } + } + return undefined +} + +export function insertMentionAt( + text: string, + cursorPos: number, + mention: string, +) { + const target = getMentionAt(text, cursorPos) + if (target) { + return `${text.slice(0, target.index)}@${mention} ${text.slice( + target.index + target.value.length + 1, // add 1 to include the "@" + )}` + } + return text +} diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx index 77f2f42e14..4e3e43e38a 100644 --- a/src/view/com/composer/ComposePost.tsx +++ b/src/view/com/composer/ComposePost.tsx @@ -3,10 +3,12 @@ import {observer} from 'mobx-react-lite' import { ActivityIndicator, KeyboardAvoidingView, + NativeSyntheticEvent, Platform, SafeAreaView, ScrollView, StyleSheet, + TextInputSelectionChangeEventData, TouchableOpacity, TouchableWithoutFeedback, View, @@ -42,6 +44,7 @@ import { import {getLinkMeta} from '../../../lib/link-meta' import {downloadAndResize} from '../../../lib/images' import {UserLocalPhotosModel} from '../../../state/models/user-local-photos' +import {getMentionAt, insertMentionAt} from '../../../lib/strings/mention-manip' import {PhotoCarouselPicker, cropPhoto} from './PhotoCarouselPicker' import {SelectedPhoto} from './SelectedPhoto' import {usePalette} from '../../lib/hooks/usePalette' @@ -50,6 +53,11 @@ const MAX_TEXT_LENGTH = 256 const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} +interface Selection { + start: number + end: number +} + export const ComposePost = observer(function ComposePost({ replyTo, imagesOpen, @@ -65,6 +73,7 @@ export const ComposePost = observer(function ComposePost({ const pal = usePalette('default') const store = useStores() const textInput = useRef(null) + const textInputSelection = useRef({start: 0, end: 0}) const [isProcessing, setIsProcessing] = useState(false) const [processingState, setProcessingState] = useState('') const [error, setError] = useState('') @@ -198,10 +207,10 @@ export const ComposePost = observer(function ComposePost({ const onChangeText = (newText: string) => { setText(newText) - const prefix = extractTextAutocompletePrefix(newText) - if (typeof prefix === 'string') { + const prefix = getMentionAt(newText, textInputSelection.current?.start || 0) + if (prefix) { autocompleteView.setActive(true) - autocompleteView.setPrefix(prefix) + autocompleteView.setPrefix(prefix.value) } else { autocompleteView.setActive(false) } @@ -228,6 +237,16 @@ export const ComposePost = observer(function ComposePost({ const finalImgPath = await cropPhoto(imgFile.uri) onSelectPhotos([...selectedPhotos, finalImgPath]) } + const onSelectionChange = ( + evt: NativeSyntheticEvent, + ) => { + // NOTE we track the input selection using a ref to avoid excessive renders -prf + textInputSelection.current = evt.nativeEvent.selection + } + const onSelectAutocompleteItem = (item: string) => { + setText(insertMentionAt(text, textInputSelection.current?.start || 0, item)) + autocompleteView.setActive(false) + } const onPressCancel = () => hackfixOnClose() const onPressPublish = async () => { if (isProcessing) { @@ -265,10 +284,6 @@ export const ComposePost = observer(function ComposePost({ hackfixOnClose() Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) } - const onSelectAutocompleteItem = (item: string) => { - setText(replaceTextAutocompletePrefix(text, item)) - autocompleteView.setActive(false) - } const canPost = text.length <= MAX_TEXT_LENGTH const progressColor = @@ -399,6 +414,7 @@ export const ComposePost = observer(function ComposePost({ scrollEnabled onChangeText={(str: string) => onChangeText(str)} onPaste={onPaste} + onSelectionChange={onSelectionChange} placeholder={selectTextInputPlaceholder} placeholderTextColor={pal.colors.textLight} style={[ @@ -494,18 +510,6 @@ export const ComposePost = observer(function ComposePost({ ) }) -const atPrefixRegex = /@([a-z0-9.]*)$/i -function extractTextAutocompletePrefix(text: string) { - const match = atPrefixRegex.exec(text) - if (match) { - return match[1] - } - return undefined -} -function replaceTextAutocompletePrefix(text: string, item: string) { - return text.replace(atPrefixRegex, `@${item} `) -} - const styles = StyleSheet.create({ outer: { flexDirection: 'column',