This commit is contained in:
@@ -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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@ import {observer} from 'mobx-react-lite'
|
|||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
|
NativeSyntheticEvent,
|
||||||
Platform,
|
Platform,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
|
TextInputSelectionChangeEventData,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
TouchableWithoutFeedback,
|
TouchableWithoutFeedback,
|
||||||
View,
|
View,
|
||||||
@@ -42,6 +44,7 @@ import {
|
|||||||
import {getLinkMeta} from '../../../lib/link-meta'
|
import {getLinkMeta} from '../../../lib/link-meta'
|
||||||
import {downloadAndResize} from '../../../lib/images'
|
import {downloadAndResize} from '../../../lib/images'
|
||||||
import {UserLocalPhotosModel} from '../../../state/models/user-local-photos'
|
import {UserLocalPhotosModel} from '../../../state/models/user-local-photos'
|
||||||
|
import {getMentionAt, insertMentionAt} from '../../../lib/strings/mention-manip'
|
||||||
import {PhotoCarouselPicker, cropPhoto} from './PhotoCarouselPicker'
|
import {PhotoCarouselPicker, cropPhoto} from './PhotoCarouselPicker'
|
||||||
import {SelectedPhoto} from './SelectedPhoto'
|
import {SelectedPhoto} from './SelectedPhoto'
|
||||||
import {usePalette} from '../../lib/hooks/usePalette'
|
import {usePalette} from '../../lib/hooks/usePalette'
|
||||||
@@ -50,6 +53,11 @@ const MAX_TEXT_LENGTH = 256
|
|||||||
const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH
|
const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH
|
||||||
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
|
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
|
||||||
|
|
||||||
|
interface Selection {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
export const ComposePost = observer(function ComposePost({
|
export const ComposePost = observer(function ComposePost({
|
||||||
replyTo,
|
replyTo,
|
||||||
imagesOpen,
|
imagesOpen,
|
||||||
@@ -65,6 +73,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const textInput = useRef<PasteInputRef>(null)
|
const textInput = useRef<PasteInputRef>(null)
|
||||||
|
const textInputSelection = useRef<Selection>({start: 0, end: 0})
|
||||||
const [isProcessing, setIsProcessing] = useState(false)
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
const [processingState, setProcessingState] = useState('')
|
const [processingState, setProcessingState] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -198,10 +207,10 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
const onChangeText = (newText: string) => {
|
const onChangeText = (newText: string) => {
|
||||||
setText(newText)
|
setText(newText)
|
||||||
|
|
||||||
const prefix = extractTextAutocompletePrefix(newText)
|
const prefix = getMentionAt(newText, textInputSelection.current?.start || 0)
|
||||||
if (typeof prefix === 'string') {
|
if (prefix) {
|
||||||
autocompleteView.setActive(true)
|
autocompleteView.setActive(true)
|
||||||
autocompleteView.setPrefix(prefix)
|
autocompleteView.setPrefix(prefix.value)
|
||||||
} else {
|
} else {
|
||||||
autocompleteView.setActive(false)
|
autocompleteView.setActive(false)
|
||||||
}
|
}
|
||||||
@@ -228,6 +237,16 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
const finalImgPath = await cropPhoto(imgFile.uri)
|
const finalImgPath = await cropPhoto(imgFile.uri)
|
||||||
onSelectPhotos([...selectedPhotos, finalImgPath])
|
onSelectPhotos([...selectedPhotos, finalImgPath])
|
||||||
}
|
}
|
||||||
|
const onSelectionChange = (
|
||||||
|
evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>,
|
||||||
|
) => {
|
||||||
|
// 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 onPressCancel = () => hackfixOnClose()
|
||||||
const onPressPublish = async () => {
|
const onPressPublish = async () => {
|
||||||
if (isProcessing) {
|
if (isProcessing) {
|
||||||
@@ -265,10 +284,6 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
hackfixOnClose()
|
hackfixOnClose()
|
||||||
Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
|
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 canPost = text.length <= MAX_TEXT_LENGTH
|
||||||
const progressColor =
|
const progressColor =
|
||||||
@@ -399,6 +414,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
scrollEnabled
|
scrollEnabled
|
||||||
onChangeText={(str: string) => onChangeText(str)}
|
onChangeText={(str: string) => onChangeText(str)}
|
||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
|
onSelectionChange={onSelectionChange}
|
||||||
placeholder={selectTextInputPlaceholder}
|
placeholder={selectTextInputPlaceholder}
|
||||||
placeholderTextColor={pal.colors.textLight}
|
placeholderTextColor={pal.colors.textLight}
|
||||||
style={[
|
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({
|
const styles = StyleSheet.create({
|
||||||
outer: {
|
outer: {
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
|||||||
Reference in New Issue
Block a user