mobile tags input and autocomplete
This commit is contained in:
@@ -4,8 +4,14 @@ import {RootStoreModel} from '../root-store'
|
||||
import Fuse from 'fuse.js'
|
||||
import {isObj, hasProp, isStrArray} from 'lib/type-guards'
|
||||
|
||||
function uniq(arr: string[]) {
|
||||
return Array.from(new Set(arr))
|
||||
}
|
||||
|
||||
/**
|
||||
* Used only to persist recent tags across app restarts.
|
||||
*
|
||||
* TODO may want an LRU?
|
||||
*/
|
||||
export class RecentTagsModel {
|
||||
_tags: string[] = []
|
||||
@@ -62,6 +68,11 @@ export class TagsAutocompleteModel {
|
||||
this.rootStore.recentTags.add(tag)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.query = ''
|
||||
this.searchedTags = []
|
||||
}
|
||||
|
||||
get suggestions() {
|
||||
if (!this.isActive) {
|
||||
return []
|
||||
@@ -87,9 +98,9 @@ export class TagsAutocompleteModel {
|
||||
// Fuse allows weighting values too, if we ever need it
|
||||
const fuse = new Fuse(items)
|
||||
// search amongst mixed set of tags
|
||||
const results = fuse.search(this.query)
|
||||
|
||||
return results.slice(0, 9).map(r => r.item)
|
||||
const results = fuse.search(this.query).map(r => r.item)
|
||||
// backfill again in case search has no results
|
||||
return uniq([...results, ...items]).slice(0, 9)
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
@@ -98,8 +109,6 @@ export class TagsAutocompleteModel {
|
||||
await this.lock.acquireAsync()
|
||||
|
||||
try {
|
||||
// another query was set before we got our chance
|
||||
if (this.query !== this.query) return
|
||||
await this._search()
|
||||
} finally {
|
||||
this.lock.release()
|
||||
|
||||
@@ -81,6 +81,59 @@ export function EditableTag({
|
||||
)
|
||||
}
|
||||
|
||||
export function TagButton({
|
||||
value,
|
||||
icon = 'x',
|
||||
onClick,
|
||||
}: {
|
||||
value: string
|
||||
icon?: React.ComponentProps<typeof FontAwesomeIcon>['icon']
|
||||
onClick?: (tag: string) => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const [hovered, setHovered] = React.useState(false)
|
||||
|
||||
const hoverIn = React.useCallback(() => {
|
||||
setHovered(true)
|
||||
}, [setHovered])
|
||||
|
||||
const hoverOut = React.useCallback(() => {
|
||||
setHovered(false)
|
||||
}, [setHovered])
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => onClick?.(value)}
|
||||
onPointerEnter={hoverIn}
|
||||
onPointerLeave={hoverOut}
|
||||
style={state => [
|
||||
pal.viewLight,
|
||||
styles.editableTag,
|
||||
{
|
||||
opacity: state.pressed || state.focused ? 0.8 : 1,
|
||||
outline: 0,
|
||||
paddingRight: 6,
|
||||
},
|
||||
]}>
|
||||
<Text type="md-medium" style={[pal.textLight]}>
|
||||
#{value}
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon={icon}
|
||||
style={
|
||||
{
|
||||
opacity: hovered ? 1 : 0.5,
|
||||
color: pal.textLight.color,
|
||||
marginTop: 1,
|
||||
} as FontAwesomeIconStyle
|
||||
}
|
||||
size={10}
|
||||
/>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
editableTag: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -446,18 +446,13 @@ export const ComposePost = observer(function ComposePost({
|
||||
|
||||
<View
|
||||
style={[
|
||||
pal.border,
|
||||
{
|
||||
borderTopWidth: 1,
|
||||
paddingVertical: 10,
|
||||
marginTop: 10,
|
||||
paddingHorizontal: 15,
|
||||
},
|
||||
]}>
|
||||
<TagInput
|
||||
onChangeTags={onChangeTags}
|
||||
tagsAutocompleteModel={tagsAutocompleteModel}
|
||||
/>
|
||||
<TagInput onChangeTags={onChangeTags} />
|
||||
</View>
|
||||
|
||||
<View style={[pal.border, styles.bottomBar]}>
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
TextInput,
|
||||
View,
|
||||
StyleSheet,
|
||||
NativeSyntheticEvent,
|
||||
TextInputKeyPressEventData,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
} from 'react-native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import BottomSheet, {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetTextInput,
|
||||
} from '@gorhom/bottom-sheet'
|
||||
|
||||
import {Portal} from 'view/com/util/Portal'
|
||||
import {TagsAutocompleteModel} from 'state/models/ui/tags-autocomplete'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {EditableTag} from 'view/com/Tag'
|
||||
import {TagButton} from 'view/com/Tag'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import * as Sheet from 'view/com/sheets/Base'
|
||||
import {useStores} from 'state/index'
|
||||
import {ActivityIndicator} from 'react-native'
|
||||
|
||||
function uniq(tags: string[]) {
|
||||
return Array.from(new Set(tags))
|
||||
}
|
||||
|
||||
// function sanitize(tagString: string, { max }: { max: number }) {
|
||||
// const sanitized = tagString.replace(/^#/, '')
|
||||
// .split(/\s/)
|
||||
// .map(t => t.trim())
|
||||
// .map(t => t.replace(/^#/, ''))
|
||||
|
||||
// return uniq(sanitized)
|
||||
// .slice(0, max)
|
||||
// }
|
||||
|
||||
function sanitize(tagString: string) {
|
||||
return tagString.trim().replace(/^#/, '')
|
||||
}
|
||||
@@ -41,14 +40,28 @@ export function TagInput({
|
||||
}: {
|
||||
max?: number
|
||||
onChangeTags: (tags: string[]) => void
|
||||
tagsAutocompleteModel: TagsAutocompleteModel
|
||||
}) {
|
||||
const store = useStores()
|
||||
const model = React.useMemo(() => new TagsAutocompleteModel(store), [store])
|
||||
const sheet = React.useRef<BottomSheet>(null)
|
||||
const pal = usePalette('default')
|
||||
const input = React.useRef<TextInput>(null)
|
||||
const input = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const [value, setValue] = React.useState('')
|
||||
const [tags, setTags] = React.useState<string[]>([])
|
||||
const [selectedItemIndex, setSelectedItemIndex] = React.useState(0)
|
||||
const [suggestions, setSuggestions] = React.useState<string[]>([])
|
||||
const [isInitialLoad, setIsInitialLoad] = React.useState(true)
|
||||
|
||||
const handleChangeTags = React.useCallback(
|
||||
const reset = React.useCallback(() => {
|
||||
setValue('')
|
||||
model.setActive(false)
|
||||
model.clear()
|
||||
setSelectedItemIndex(0)
|
||||
setSuggestions([])
|
||||
}, [model, setValue, setSelectedItemIndex, setSuggestions])
|
||||
|
||||
const addTags = React.useCallback(
|
||||
(_tags: string[]) => {
|
||||
setTags(_tags)
|
||||
onChangeTags(_tags)
|
||||
@@ -56,90 +69,222 @@ export function TagInput({
|
||||
[onChangeTags, setTags],
|
||||
)
|
||||
|
||||
const onSubmitEditing = React.useCallback(() => {
|
||||
const tag = sanitize(value)
|
||||
const removeTag = React.useCallback(
|
||||
(tag: string) => {
|
||||
addTags(tags.filter(t => t !== tag))
|
||||
},
|
||||
[tags, addTags],
|
||||
)
|
||||
|
||||
// enforce max hashtag length
|
||||
if (tag.length > 0 && tag.length <= 64) {
|
||||
handleChangeTags(uniq([...tags, tag]).slice(0, max))
|
||||
}
|
||||
const addTagAndReset = React.useCallback(
|
||||
(value: string) => {
|
||||
const tag = sanitize(value)
|
||||
|
||||
// enforce max hashtag length
|
||||
if (tag.length > 0 && tag.length <= 64) {
|
||||
addTags(uniq([...tags, tag]).slice(0, max))
|
||||
}
|
||||
|
||||
if (isWeb) {
|
||||
setValue('')
|
||||
input.current?.focus()
|
||||
} else {
|
||||
// This is a hack to get the input to clear on iOS/Android, and only
|
||||
// positive values work here
|
||||
setTimeout(() => {
|
||||
setValue('')
|
||||
input.current?.focus()
|
||||
}, 1)
|
||||
}
|
||||
}, [max, value, tags, setValue, handleChangeTags])
|
||||
},
|
||||
[max, tags, setValue, addTags],
|
||||
)
|
||||
|
||||
const onSubmitEditing = React.useCallback(() => {
|
||||
const item = suggestions[selectedItemIndex]
|
||||
addTagAndReset(item || value)
|
||||
}, [value, suggestions, selectedItemIndex, addTagAndReset])
|
||||
|
||||
const onKeyPress = React.useCallback(
|
||||
(e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
||||
if (e.nativeEvent.key === 'Backspace' && value === '') {
|
||||
handleChangeTags(tags.slice(0, -1))
|
||||
} else if (e.nativeEvent.key === ' ') {
|
||||
const {key} = e.nativeEvent
|
||||
|
||||
if (key === 'Backspace' && value === '') {
|
||||
addTags(tags.slice(0, -1))
|
||||
} else if (key === ' ') {
|
||||
e.preventDefault() // prevents an additional space on web
|
||||
onSubmitEditing()
|
||||
addTagAndReset(value)
|
||||
}
|
||||
|
||||
if (key === 'Escape') {
|
||||
reset()
|
||||
} else if (key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedItemIndex(
|
||||
(selectedItemIndex + suggestions.length - 1) % suggestions.length,
|
||||
)
|
||||
} else if (key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedItemIndex((selectedItemIndex + 1) % suggestions.length)
|
||||
}
|
||||
},
|
||||
[value, tags, handleChangeTags, onSubmitEditing],
|
||||
[
|
||||
value,
|
||||
tags,
|
||||
selectedItemIndex,
|
||||
suggestions.length,
|
||||
reset,
|
||||
setSelectedItemIndex,
|
||||
addTags,
|
||||
addTagAndReset,
|
||||
],
|
||||
)
|
||||
|
||||
const onChangeText = React.useCallback((v: string) => {
|
||||
setValue(v)
|
||||
}, [])
|
||||
const onChangeText = React.useCallback(
|
||||
async (v: string) => {
|
||||
setValue(v)
|
||||
|
||||
const removeTag = React.useCallback(
|
||||
(tag: string) => {
|
||||
handleChangeTags(tags.filter(t => t !== tag))
|
||||
if (v.length > 0) {
|
||||
model.setActive(true)
|
||||
await model.search(v)
|
||||
|
||||
setSuggestions(model.suggestions)
|
||||
} else {
|
||||
model.clear()
|
||||
|
||||
setSuggestions(model.suggestions)
|
||||
}
|
||||
},
|
||||
[tags, handleChangeTags],
|
||||
[model, setValue],
|
||||
)
|
||||
|
||||
const onCloseSheet = React.useCallback(() => {
|
||||
reset()
|
||||
setIsInitialLoad(true)
|
||||
}, [reset, setIsInitialLoad])
|
||||
|
||||
const onSheetChange = React.useCallback(
|
||||
async (index: number) => {
|
||||
if (index > -1) {
|
||||
model.setActive(true)
|
||||
await model.search('') // get default results
|
||||
setSuggestions(model.suggestions)
|
||||
setIsInitialLoad(false)
|
||||
}
|
||||
},
|
||||
[model, setIsInitialLoad, setSuggestions],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.outer}>
|
||||
{!tags.length && (
|
||||
<FontAwesomeIcon
|
||||
icon="tags"
|
||||
size={14}
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
)}
|
||||
{tags.map(tag => (
|
||||
<EditableTag key={tag} value={tag} onRemove={removeTag} />
|
||||
))}
|
||||
{tags.length >= max ? null : (
|
||||
<TextInput
|
||||
ref={input}
|
||||
value={value}
|
||||
onKeyPress={onKeyPress}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
onChangeText={onChangeText}
|
||||
blurOnSubmit={false}
|
||||
style={[styles.input, pal.textLight]}
|
||||
placeholder="Enter a tag and press enter"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
accessible={true}
|
||||
accessibilityLabel="Add tags to your post"
|
||||
accessibilityHint={`Type a tag and press enter to add it. You can add up to ${max} tag.`}
|
||||
/>
|
||||
)}
|
||||
<View>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
style={styles.selectedTags}
|
||||
onPress={() => {
|
||||
sheet.current?.snapToIndex(0)
|
||||
}}>
|
||||
<View style={[pal.viewLight, styles.button]}>
|
||||
{tags.length ? (
|
||||
<Text type="md-medium" style={[pal.textLight]}>
|
||||
Add +
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<FontAwesomeIcon
|
||||
icon="tags"
|
||||
size={12}
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
<Text type="md-medium" style={[pal.textLight]}>
|
||||
Click to add tags to your post
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{tags.map(tag => (
|
||||
<View key={tag} style={[pal.viewLight, styles.button]}>
|
||||
<Text type="md-medium" style={[pal.textLight]}>
|
||||
#{tag}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</Pressable>
|
||||
|
||||
<Portal>
|
||||
<BottomSheet
|
||||
ref={sheet}
|
||||
index={-1}
|
||||
snapPoints={[200]}
|
||||
enablePanDownToClose
|
||||
android_keyboardInputMode="adjustResize"
|
||||
keyboardBlurBehavior="restore"
|
||||
backdropComponent={props => (
|
||||
<BottomSheetBackdrop
|
||||
appearsOnIndex={0}
|
||||
disappearsOnIndex={-1}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
handleIndicatorStyle={{backgroundColor: pal.text.color}}
|
||||
handleStyle={{display: 'none'}}
|
||||
onChange={onSheetChange}
|
||||
onClose={onCloseSheet}>
|
||||
<Sheet.Outer>
|
||||
<Sheet.Handle />
|
||||
|
||||
<View style={styles.outer}>
|
||||
<FontAwesomeIcon
|
||||
icon="tags"
|
||||
size={14}
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
|
||||
{tags.map(tag => (
|
||||
<TagButton key={tag} value={tag} onClick={removeTag} />
|
||||
))}
|
||||
|
||||
<BottomSheetTextInput
|
||||
placeholder="Add tags..."
|
||||
value={value}
|
||||
style={styles.input}
|
||||
onChangeText={onChangeText}
|
||||
onKeyPress={onKeyPress}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{marginHorizontal: -20}}>
|
||||
<ScrollView horizontal>
|
||||
<View style={styles.suggestions}>
|
||||
{isInitialLoad && <ActivityIndicator />}
|
||||
|
||||
{suggestions
|
||||
.filter(s => !tags.find(t => t === s))
|
||||
.map(suggestion => {
|
||||
return (
|
||||
<TagButton
|
||||
key={suggestion}
|
||||
icon="plus"
|
||||
value={suggestion}
|
||||
onClick={addTagAndReset}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Sheet.Outer>
|
||||
</BottomSheet>
|
||||
</Portal>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
selectedTags: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
outer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 20,
|
||||
},
|
||||
input: {
|
||||
flexGrow: 1,
|
||||
@@ -152,4 +297,20 @@ const styles = StyleSheet.create({
|
||||
paddingTop: 4,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
suggestions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
paddingLeft: 20,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexShrink: 1,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 20,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react'
|
||||
import {View, StyleSheet, Dimensions} from 'react-native'
|
||||
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
export function Outer(props: React.PropsWithChildren<{}>) {
|
||||
const pal = usePalette('default')
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={[pal.view, styles.background]} />
|
||||
|
||||
<View style={styles.content}>{props.children}</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Handle() {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<View style={[styles.handle, {backgroundColor: pal.border.borderColor}]} />
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
background: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
height: Dimensions.get('window').height * 2,
|
||||
zIndex: -1,
|
||||
},
|
||||
content: {
|
||||
paddingVertical: 40,
|
||||
paddingHorizontal: 20,
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
handle: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
alignSelf: 'center',
|
||||
width: 80,
|
||||
height: 6,
|
||||
borderRadius: 10,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
|
||||
type Component = React.ReactElement
|
||||
|
||||
type ContextType = {
|
||||
outlet: Component | null
|
||||
append(id: string, component: Component): void
|
||||
remove(id: string): void
|
||||
}
|
||||
|
||||
type ComponentMap = {
|
||||
[id: string]: Component
|
||||
}
|
||||
|
||||
export const Context = React.createContext<ContextType>({
|
||||
outlet: null,
|
||||
append: () => {},
|
||||
remove: () => {},
|
||||
})
|
||||
|
||||
export function Provider(props: React.PropsWithChildren<{}>) {
|
||||
const map = React.useRef<ComponentMap>({})
|
||||
const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
|
||||
|
||||
const append = React.useCallback<ContextType['append']>((id, component) => {
|
||||
if (map.current[id]) return
|
||||
map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
|
||||
setOutlet(<>{Object.values(map.current)}</>)
|
||||
}, [])
|
||||
|
||||
const remove = React.useCallback<ContextType['remove']>(id => {
|
||||
delete map.current[id]
|
||||
setOutlet(<>{Object.values(map.current)}</>)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Context.Provider value={{outlet, append, remove}}>
|
||||
{props.children}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Outlet() {
|
||||
const ctx = React.useContext(Context)
|
||||
return ctx.outlet
|
||||
}
|
||||
|
||||
export function Portal({children}: React.PropsWithChildren<{}>) {
|
||||
const {append, remove} = React.useContext(Context)
|
||||
const id = React.useId()
|
||||
React.useEffect(() => {
|
||||
append(id, children as Component)
|
||||
return () => remove(id)
|
||||
}, [id, children, append, remove])
|
||||
return null
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {Drawer} from 'react-native-drawer-layout'
|
||||
import {useNavigationState} from '@react-navigation/native'
|
||||
import {Provider, Outlet} from 'view/com/util/Portal'
|
||||
import {useStores} from 'state/index'
|
||||
import {ModalsContainer} from 'view/com/modals/Modal'
|
||||
import {Lightbox} from 'view/com/lightbox/Lightbox'
|
||||
@@ -79,6 +80,7 @@ const ShellInner = observer(function ShellInnerImpl() {
|
||||
/>
|
||||
<ModalsContainer />
|
||||
<Lightbox />
|
||||
<Outlet />
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -88,12 +90,16 @@ export const Shell: React.FC = observer(function ShellImpl() {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<SafeAreaProvider style={pal.view}>
|
||||
<View testID="mobileShellView" style={[styles.outerContainer, pal.view]}>
|
||||
<StatusBar style={theme.colorScheme === 'dark' ? 'light' : 'dark'} />
|
||||
<RoutesContainer>
|
||||
<ShellInner />
|
||||
</RoutesContainer>
|
||||
</View>
|
||||
<Provider>
|
||||
<View
|
||||
testID="mobileShellView"
|
||||
style={[styles.outerContainer, pal.view]}>
|
||||
<StatusBar style={theme.colorScheme === 'dark' ? 'light' : 'dark'} />
|
||||
<RoutesContainer>
|
||||
<ShellInner />
|
||||
</RoutesContainer>
|
||||
</View>
|
||||
</Provider>
|
||||
</SafeAreaProvider>
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user